Compare commits

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

Cheers,

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

Cheers,

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

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

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

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

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

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

## Others: 

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

 Docker / Build / Testing

  - pip cache mounts - Faster Docker builds via BuildKit cache
  - npm cache mounts - Faster frontend builds
  - APT cleanup - Smaller final image size
  - Added make restart command for quick restarts without rebuild
- New pytest-based test framework with proper configuration
(pyproject.toml)
- Unit tests for all download clients (qBittorrent, Transmission,
Deluge, NZBGet, SABnzbd)
  - Bencode parsing tests
  - Cache tests
  - Integration tests for Prowlarr handler
  - E2E test framework
2025-12-27 14:59:06 +00:00
Alex 2cf336d704 Update readme (#359)
Update readme to the v2 version
2025-12-26 18:06:10 +00:00
177 changed files with 29778 additions and 6587 deletions
@@ -8,7 +8,7 @@ on:
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
IMAGE_NAME: ${{ github.repository_owner }}/shelfmark
jobs:
build-and-push-images:
runs-on: ubuntu-latest
@@ -20,15 +20,9 @@ jobs:
strategy:
matrix:
include:
- suffix: ""
target: cwa-bd
image_name_suffix: ""
- suffix: "-tor"
target: cwa-bd-tor
image_name_suffix: "-tor"
- suffix: "-extbp"
target: cwa-bd-extbp
image_name_suffix: "-extbp"
- target: shelfmark
- target: shelfmark-lite
image_name_suffix: "-lite"
steps:
- name: Get current date
id: date
@@ -80,4 +74,71 @@ jobs:
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.image_name_suffix }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
push-to-registry: true
# Create aliases for backwards compatibility
create-aliases:
needs: build-and-push-images
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
permissions:
contents: read
packages: write
env:
# Legacy name for backwards compatibility (hardcoded so it works after rename)
LEGACY_NAME: calibre-web-automated-book-downloader
steps:
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Create legacy aliases
run: |
# Current image names (follows repo name)
STANDARD="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
LITE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-lite"
# Legacy image names (hardcoded for backwards compatibility)
LEGACY="${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.LEGACY_NAME }}"
LEGACY_TOR="${LEGACY}-tor"
LEGACY_EXTBP="${LEGACY}-extbp"
SHA_SHORT=$(echo "${{ github.sha }}" | cut -c1-7)
# Helper function to create alias with all standard tags
create_alias() {
local SOURCE=$1
local ALIAS=$2
# Always create SHA tag
docker buildx imagetools create -t "${ALIAS}:sha-${SHA_SHORT}" "${SOURCE}:sha-${SHA_SHORT}"
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
VERSION="${{ github.ref_name }}"
VERSION_NUM="${VERSION#v}"
MINOR="${VERSION_NUM%.*}"
docker buildx imagetools create -t "${ALIAS}:latest" "${SOURCE}:latest"
docker buildx imagetools create -t "${ALIAS}:${VERSION_NUM}" "${SOURCE}:${VERSION_NUM}"
docker buildx imagetools create -t "${ALIAS}:${MINOR}" "${SOURCE}:${MINOR}"
docker buildx imagetools create -t "${ALIAS}:${VERSION}" "${SOURCE}:${VERSION}"
else
docker buildx imagetools create -t "${ALIAS}:dev" "${SOURCE}:dev"
fi
}
# Create legacy aliases pointing to current images
# calibre-web-automated-book-downloader → standard image
create_alias "${STANDARD}" "${LEGACY}"
# calibre-web-automated-book-downloader-tor → standard image
create_alias "${STANDARD}" "${LEGACY_TOR}"
# calibre-web-automated-book-downloader-extbp → lite image
create_alias "${LITE}" "${LEGACY_EXTBP}"
+3
View File
@@ -228,3 +228,6 @@ pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/macos,visualstudiocode,python
/downloaded_files
/.local/
*.local.*
.claude/
.playwright-mcp/
+8 -8
View File
@@ -2,17 +2,17 @@
"version": "0.2.0",
"configurations": [
{
"name": "Python Debugger: Current CWABD File",
"name": "Python Debugger: Current Shelfmark File",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal",
"justMyCode": false,
"env": {
"INGEST_DIR": "/tmp/cwa-book-downloader",
"TEMP_DIR": "/tmp/cwa-book-downloader",
"INGEST_DIR": "/tmp/shelfmark",
"TEMP_DIR": "/tmp/shelfmark",
"LOG_LEVEL": "DEBUG",
"LOG_ROOT": "/tmp/cwa-book-downloader",
"LOG_ROOT": "/tmp/shelfmark",
"ENABLE_LOGGING": "true",
"DOCKERMODE": "false",
"DEBUG": "true",
@@ -27,7 +27,7 @@
"preLaunchTask": "docker-compose up (dev)", // Spin up dev containers
"postDebugTask": "docker-compose down (dev)", // Optional: tear them down
"env": {
"INGEST_DIR": "/tmp/cwa-book-downloader"
"INGEST_DIR": "/tmp/shelfmark"
},
},
{
@@ -38,7 +38,7 @@
"preLaunchTask": "docker-compose up (prod)",
"postDebugTask": "docker-compose down (prod)",
"env": {
"INGEST_DIR": "/tmp/cwa-book-downloader"
"INGEST_DIR": "/tmp/shelfmark"
},
},
{
@@ -54,9 +54,9 @@
],
"compounds": [
{
"name": "Launch CWA-BD",
"name": "Launch Shelfmark",
"configurations": [
"Launch cwa-bd app.py",
"Launch Shelfmark app.py",
"Launch Browser"
]
}
+30 -44
View File
@@ -14,8 +14,9 @@ WORKDIR /frontend
# Copy frontend package files
COPY src/frontend/package*.json ./
# Install dependencies
RUN npm ci
# Install dependencies (cache mount for faster rebuilds)
RUN --mount=type=cache,target=/root/.npm \
npm ci
# Copy frontend source
COPY src/frontend/ ./
@@ -44,9 +45,9 @@ ENV DEBIAN_FRONTEND=noninteractive \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_DEFAULT_TIMEOUT=100 \
NAME=Calibre-Web-Automated-Book-Downloader \
NAME=Shelfmark \
PYTHONPATH=/app \
# UID/GID will be handled by entrypoint script, but TZ/Locale are still needed
# PUID/PGID will be handled by entrypoint script, but TZ/Locale are still needed
LANG=en_US.UTF-8 \
LANGUAGE=en_US:en \
LC_ALL=en_US.UTF-8
@@ -67,7 +68,14 @@ RUN apt-get update && \
# For debug
zip iputils-ping \
# For user switching
sudo && \
sudo \
# --- Tor support (activated via USING_TOR=true) ---
tor \
supervisor \
iptables && \
# Configure iptables alternatives for tor.sh compatibility
update-alternatives --set iptables /usr/sbin/iptables-legacy && \
update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy && \
# Cleanup APT cache *after* all installs in this layer
apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false && \
apt-get clean && \
@@ -84,12 +92,11 @@ RUN apt-get update && \
WORKDIR /app
# Install Python dependencies using pip
# Upgrade pip first, then copy requirements and install
# Copying requirements-base.txt separately leverages build cache
COPY requirements-base.txt .
RUN pip install --no-cache-dir -r requirements-base.txt && \
# Clean root's pip cache
rm -rf /root/.cache
# Copying requirements files separately leverages build cache
# Cache mount persists pip cache between builds for faster installs
COPY requirements-base.txt requirements-shelfmark.txt ./
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements-base.txt
# Copy application code *after* dependencies are installed
COPY . .
@@ -100,7 +107,7 @@ COPY --from=frontend-builder /frontend/dist /app/frontend-dist
# Final setup: permissions and directories in one layer
# Only creating directories and setting executable bits.
# Ownership will be handled by the entrypoint script.
RUN mkdir -p /var/log/cwa-book-downloader /cwa-book-ingest && \
RUN mkdir -p /var/log/shelfmark /books && \
chmod +x /app/entrypoint.sh /app/tor.sh /app/genDebug.sh
# Expose the application port
@@ -115,7 +122,7 @@ HEALTHCHECK --interval=60s --timeout=60s --start-period=60s --retries=3 \
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
FROM base AS cwa-bd
FROM base AS shelfmark
RUN apt-get update && \
apt-get install -y --no-install-recommends \
@@ -132,15 +139,17 @@ RUN apt-get update && \
# For RAR extraction
unrar-free && \
# Create symlink so rarfile library can find unrar
ln -sf /usr/bin/unrar-free /usr/bin/unrar
ln -sf /usr/bin/unrar-free /usr/bin/unrar && \
# Cleanup APT cache
apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# install additional dependencies
COPY requirements-cwa-bd.txt ./
RUN pip install --no-cache-dir -r requirements-cwa-bd.txt && \
# Clean root's pip cache
rm -rf /root/.cache
# Install additional dependencies (requirements file already copied in base stage)
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements-shelfmark.txt
# Add this line to grant read/execute permissions to others
# 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/
@@ -148,30 +157,7 @@ RUN chmod -R o+rx /usr/bin/chromium && \
# Default command to run the application entrypoint script
CMD ["/app/entrypoint.sh"]
FROM cwa-bd AS cwa-bd-tor
ENV USING_TOR=true
# Install Tor and dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends \
# --- Tor ---
tor \
# --- Supervisor ---
supervisor \
# --- iptables ---
iptables && \
update-alternatives --set iptables /usr/sbin/iptables-legacy && \
update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy && \
# Cleanup APT cache *after* all installs in this layer
apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Override the default command to run Tor
CMD ["/app/entrypoint.sh"]
FROM base AS cwa-bd-extbp
FROM base AS shelfmark-lite
ENV USING_EXTERNAL_BYPASSER=true
+7 -1
View File
@@ -1,4 +1,4 @@
.PHONY: help install dev build preview typecheck clean up down docker-build refresh
.PHONY: help install dev build preview typecheck clean up down docker-build refresh restart
# Frontend directory
FRONTEND_DIR := src/frontend
@@ -21,6 +21,7 @@ help:
@echo "Backend (Docker):"
@echo " up - Start backend services"
@echo " down - Stop backend services"
@echo " restart - Restart backend services (no rebuild)"
@echo " docker-build - Build Docker image"
@echo " refresh - Rebuild and restart backend services"
@@ -70,6 +71,11 @@ docker-build:
@echo "Building Docker image..."
docker compose -f $(COMPOSE_FILE) build
# Restart backend services (no rebuild)
restart:
@echo "Restarting backend services..."
docker compose -f $(COMPOSE_FILE) restart
# Rebuild and restart backend services
refresh:
@echo "Rebuilding and restarting backend services..."
-240
View File
@@ -1,240 +0,0 @@
# 📚 Book Downloader
*calibre-web-automated-book-downloader*
<img src="src/frontend/public/logo.png" alt="Book Downloader" width="200">
A unified web interface for searching and downloading books 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.
**Fully standalone** — no external dependencies required. Works great alongside library tools like [Calibre-Web-Automated](https://github.com/crocodilestick/Calibre-Web-Automated) or [Booklore](https://github.com/booklore-app/booklore) for automatic import.
## ✨ Features
- **One-Stop Interface** - A clean, modern UI to search, browse, and download from multiple sources in one place
- **Real-Time Progress** - Unified download queue with live status updates across all sources
- **Two Search Modes**:
- **Direct Download** - Search and download from popular web sources
- **Universal Mode** - Search metadata providers (Hardcover, Open Library) for richer book discovery and multi-source downloads *(additional sources in development - coming soon!)*
- **Format Support** - EPUB, MOBI, AZW3, FB2, DJVU, CBZ, CBR and more
- **Cloudflare Bypass** - Built-in bypasser for reliable access to protected sources
- **PWA Support** - Install as a mobile app for quick access
- **Docker Deployment** - Up and running in minutes
## 🖼️ Screenshots
**Home screen**
![Home screen](README_images/homescreen.png 'Home screen')
**Search results**
![Search results](README_images/search-results.png 'Search results')
**Multi-source downloads**
![Multi-source downloads](README_images/multi-source.png 'Multi-source downloads')
**Download queue**
![Download queue](README_images/downloads.png 'Download queue')
## 🚀 Quick Start
### Prerequisites
- Docker & Docker Compose
### Installation
1. Download the docker-compose file:
```bash
curl -O https://raw.githubusercontent.com/calibrain/calibre-web-automated-book-downloader/main/docker-compose.yml
```
2. Start the service:
```bash
docker compose up -d
```
3. Open `http://localhost:8084`
That's it! Configure settings through the web interface as needed.
### Volume Setup
```yaml
volumes:
- /your/config/path:/config # Config, database, and artwork cache directory
- /your/download/path:/cwa-book-ingest # Downloaded books
```
> **Tip**: Point the download volume to your CWA or Booklore ingest folder for automatic import.
> **Note**: CIFS shares require `nobrl` mount option to avoid database lock errors.
## ⚙️ Configuration
### Search Modes
**Direct Download Mode** (default)
- Works out of the box, no setup required
- Searches a huge library of books directly
- Returns downloadable releases immediately
**Universal Mode**
- Cleaner search results via metadata providers (Hardcover, Open Library)
- Aggregates releases from multiple configured sources
- Requires manual setup (API keys, additional sources)
Set the mode via Settings or `SEARCH_MODE` environment variable.
### Environment Variables
Environment variables work for initial setup and Docker deployments. They serve as defaults that can be overridden in the web interface.
| Variable | Description | Default |
|----------|-------------|---------|
| `FLASK_PORT` | Web interface port | `8084` |
| `INGEST_DIR` | Book download directory | `/cwa-book-ingest` |
| `TZ` | Container timezone | `UTC` |
| `UID` / `GID` | Runtime user/group ID | `1000` / `100` |
| `SEARCH_MODE` | `direct` or `universal` | `direct` |
Some of the additional options available in Settings:
- **AA Donator Key** - Use your paid account to skip Cloudflare challenges entirely and use faster, direct downloads
- **Library Link** - Add a link to your Calibre-Web or Booklore instance in the UI header
- **Content Folders** - Route fiction, non-fiction, comics, etc. to separate directories
- **Network Resilience** - Auto DNS rotation and mirror fallback when sources are unreachable
- **Format & Language** - Filter downloads by preferred formats and languages
- **Metadata Providers** - Configure API keys for Hardcover, Open Library, etc.
## 🐳 Docker Variants
### Standard
```bash
docker compose up -d
```
### Tor Variant
Routes all traffic through Tor for enhanced privacy:
```bash
curl -O https://raw.githubusercontent.com/calibrain/calibre-web-automated-book-downloader/main/docker-compose.tor.yml
docker compose -f docker-compose.tor.yml up -d
```
**Notes:**
- Requires `NET_ADMIN` and `NET_RAW` capabilities
- Timezone is auto-detected from Tor exit node
- Custom DNS/proxy settings are ignored
### External Cloudflare Resolver
Use FlareSolverr or ByParr instead of the built-in bypasser:
```bash
curl -O https://raw.githubusercontent.com/calibrain/calibre-web-automated-book-downloader/main/docker-compose.extbp.yml
docker compose -f docker-compose.extbp.yml up -d
```
Configure the resolver URL in Settings under the Cloudflare tab.
**When to use external vs internal bypasser:**
- **External** is useful if you already run FlareSolverr for other services (saves resources) or if you rarely need bypassing
- **Internal** (default) is faster and more reliable for most users - it's optimized specifically for this application
## 🔐 Authentication
Authentication is optional but recommended for shared or exposed instances. Enable in Settings.
**Alternative**: 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
```
## Health Monitoring
The application exposes a health endpoint at `/api/status`. Add a health check to your compose:
```yaml
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8084/api/status"]
interval: 30s
timeout: 30s
retries: 3
```
## Logging
Logs are available via:
- `docker logs <container-name>`
- `/var/log/cwa-book-downloader/` inside the container (when `ENABLE_LOGGING=true`)
Log level is configurable via Settings or `LOG_LEVEL` environment variable.
## Development
```bash
# Frontend development
make install # Install dependencies
make dev # Start Vite dev server (localhost:5173)
make build # Production build
make typecheck # TypeScript checks
# Backend (Docker)
make up # Start backend via docker-compose.dev.yml
make down # Stop services
make refresh # Rebuild and restart
```
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 (Anna's Archive → Libgen → Welib) │
├─────────────────────────────────────────────────────────────┤
│ 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
### Copyright Notice
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
### 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.
## Support
For issues or questions, please [file an issue](https://github.com/calibrain/calibre-web-automated-book-downloader/issues) on GitHub.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 504 KiB

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 764 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

+20
View File
@@ -0,0 +1,20 @@
# 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
@@ -0,0 +1,21 @@
# 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
@@ -0,0 +1,16 @@
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
+16
View File
@@ -0,0 +1,16 @@
services:
shelfmark-lite:
image: ghcr.io/calibrain/shelfmark-lite:latest
environment:
# TZ: America/New_York
# EXT_BYPASSER_URL: http://flaresolverr:8191 #If using Flaresolverr
# 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
+21
View File
@@ -0,0 +1,21 @@
# Routes all traffic through Tor - requires NET_ADMIN capability
services:
shelfmark-tor:
image: ghcr.io/calibrain/shelfmark:latest
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
@@ -0,0 +1,16 @@
services:
shelfmark:
image: ghcr.io/calibrain/shelfmark:latest
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
-1
View File
@@ -1 +0,0 @@
"""CWA Book Downloader - book search and download service."""
-8
View File
@@ -1,8 +0,0 @@
"""Package entry point for `python -m cwa_book_downloader`."""
from cwa_book_downloader.main import app, socketio
from cwa_book_downloader.config.env import FLASK_HOST, FLASK_PORT
from cwa_book_downloader.core.config import config
if __name__ == "__main__":
socketio.run(app, host=FLASK_HOST, port=FLASK_PORT, debug=config.get("DEBUG", False))
-1
View File
@@ -1 +0,0 @@
"""Cloudflare bypass utilities."""
@@ -1,162 +0,0 @@
"""External Cloudflare bypasser using FlareSolverr."""
from threading import Event
from typing import Optional, TYPE_CHECKING
import requests
import time
import random
from cwa_book_downloader.core.config import config
from cwa_book_downloader.core.logger import setup_logger
if TYPE_CHECKING:
from cwa_book_downloader.download import network
class BypassCancelledException(Exception):
"""Raised when a bypass operation is cancelled."""
pass
logger = setup_logger(__name__)
# Connection timeout (seconds) - how long to wait for external bypasser to accept connection
CONNECT_TIMEOUT = 10
# Maximum read timeout cap (seconds) - hard limit regardless of EXT_BYPASSER_TIMEOUT
MAX_READ_TIMEOUT = 120
# Buffer added to bypasser's configured timeout (seconds) - accounts for processing overhead
READ_TIMEOUT_BUFFER = 15
# Retry settings for bypasser failures
MAX_RETRY = 5
BACKOFF_BASE = 1.0
BACKOFF_CAP = 10.0
def _fetch_via_bypasser(target_url: str) -> Optional[str]:
"""Make a single request to the external bypasser service.
Args:
target_url: The URL to fetch through the bypasser
Returns:
HTML content if successful, None otherwise
"""
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)
if not bypasser_url or not bypasser_path:
logger.error("External bypasser not configured. Check EXT_BYPASSER_URL and EXT_BYPASSER_PATH.")
return None
bypasser_endpoint = f"{bypasser_url}{bypasser_path}"
headers = {"Content-Type": "application/json"}
payload = {
"cmd": "request.get",
"url": target_url,
"maxTimeout": bypasser_timeout
}
# Calculate read timeout: bypasser timeout (ms -> s) + buffer, capped at max
read_timeout = min((bypasser_timeout / 1000) + READ_TIMEOUT_BUFFER, MAX_READ_TIMEOUT)
try:
response = requests.post(
bypasser_endpoint,
headers=headers,
json=payload,
timeout=(CONNECT_TIMEOUT, read_timeout)
)
response.raise_for_status()
result = response.json()
status = result.get('status', 'unknown')
message = result.get('message', '')
logger.debug(f"External bypasser response for '{target_url}': {status} - {message}")
# Check for error status (bypasser returns status="error" with solution=null on failure)
if status != 'ok':
logger.warning(f"External bypasser failed for '{target_url}': {status} - {message}")
return None
solution = result.get('solution')
if not solution:
logger.warning(f"External bypasser returned empty solution for '{target_url}'")
return None
html = solution.get('response', '')
if not html:
logger.warning(f"External bypasser returned empty response for '{target_url}'")
return None
return html
except requests.exceptions.Timeout:
logger.warning(f"External bypasser timed out for '{target_url}' (connect: {CONNECT_TIMEOUT}s, read: {read_timeout:.0f}s)")
return None
except requests.exceptions.RequestException as e:
logger.warning(f"External bypasser request failed for '{target_url}': {e}")
return None
except (KeyError, TypeError, ValueError) as e:
logger.warning(f"External bypasser returned malformed response for '{target_url}': {e}")
return None
def get_bypassed_page(url: str, selector: Optional["network.AAMirrorSelector"] = None, cancel_flag: Optional[Event] = None) -> Optional[str]:
"""Fetch HTML content from a URL using an external Cloudflare bypasser service.
Retries with exponential backoff and mirror/DNS rotation on failure.
Args:
url: Target URL to fetch
selector: Mirror selector for AA URL rewriting and rotation
cancel_flag: Optional threading Event to signal cancellation
Returns:
HTML content if successful, None otherwise
Raises:
BypassCancelledException: If cancel_flag is set during operation
"""
from cwa_book_downloader.download import network as network_module
sel = selector or network_module.AAMirrorSelector()
for attempt in range(1, MAX_RETRY + 1):
# Check for cancellation before each attempt
if cancel_flag and cancel_flag.is_set():
logger.info("External bypasser cancelled by user")
raise BypassCancelledException("Bypass cancelled")
attempt_url = sel.rewrite(url)
result = _fetch_via_bypasser(attempt_url)
if result:
return result
if attempt == MAX_RETRY:
break
# Check for cancellation before backoff wait
if cancel_flag and cancel_flag.is_set():
logger.info("External bypasser cancelled during retry")
raise BypassCancelledException("Bypass cancelled")
# Backoff with jitter before retry, checking cancellation during wait
delay = min(BACKOFF_CAP, BACKOFF_BASE * (2 ** (attempt - 1))) + random.random()
logger.info(f"External bypasser attempt {attempt}/{MAX_RETRY} failed, retrying in {delay:.1f}s")
# Check cancellation during delay (check every second)
for _ in range(int(delay)):
if cancel_flag and cancel_flag.is_set():
logger.info("External bypasser cancelled during backoff")
raise BypassCancelledException("Bypass cancelled")
time.sleep(1)
# Sleep remaining fraction
remaining = delay - int(delay)
if remaining > 0:
time.sleep(remaining)
# Rotate mirror/DNS for next attempt
new_base, action = sel.next_mirror_or_rotate_dns()
if action in ("mirror", "dns") and new_base:
logger.info(f"Rotated {action} for retry")
return None
File diff suppressed because it is too large Load Diff
-151
View File
@@ -1,151 +0,0 @@
"""Environment variable parsing. No local dependencies - import first."""
import os
import shutil
from pathlib import Path
def string_to_bool(s: str) -> bool:
return s.lower() in ["true", "yes", "1", "y"]
# Authentication and session settings
SESSION_COOKIE_SECURE_ENV = os.getenv("SESSION_COOKIE_SECURE", "false")
CWA_DB = os.getenv("CWA_DB_PATH")
CWA_DB_PATH = Path(CWA_DB) if CWA_DB else None
CONFIG_DIR = Path(os.getenv("CONFIG_DIR", "/config"))
LOG_ROOT = Path(os.getenv("LOG_ROOT", "/var/log/"))
LOG_DIR = LOG_ROOT / "cwa-book-downloader"
TMP_DIR = Path(os.getenv("TMP_DIR", "/tmp/cwa-book-downloader"))
INGEST_DIR = Path(os.getenv("INGEST_DIR", "/cwa-book-ingest"))
INGEST_DIR_BOOK_FICTION = os.getenv("INGEST_DIR_BOOK_FICTION", "")
INGEST_DIR_BOOK_NON_FICTION = os.getenv("INGEST_DIR_BOOK_NON_FICTION", "")
INGEST_DIR_BOOK_UNKNOWN = os.getenv("INGEST_DIR_BOOK_UNKNOWN", "")
INGEST_DIR_MAGAZINE = os.getenv("INGEST_DIR_MAGAZINE", "")
INGEST_DIR_COMIC_BOOK = os.getenv("INGEST_DIR_COMIC_BOOK", "")
INGEST_DIR_AUDIOBOOK = os.getenv("INGEST_DIR_AUDIOBOOK", "")
INGEST_DIR_STANDARDS_DOCUMENT = os.getenv("INGEST_DIR_STANDARDS_DOCUMENT", "")
INGEST_DIR_MUSICAL_SCORE = os.getenv("INGEST_DIR_MUSICAL_SCORE", "")
INGEST_DIR_OTHER = os.getenv("INGEST_DIR_OTHER", "")
DOWNLOAD_PATHS = {
"book (fiction)": Path(INGEST_DIR_BOOK_FICTION) if INGEST_DIR_BOOK_FICTION else INGEST_DIR,
"book (non-fiction)": Path(INGEST_DIR_BOOK_NON_FICTION) if INGEST_DIR_BOOK_NON_FICTION else INGEST_DIR,
"book (unknown)": Path(INGEST_DIR_BOOK_UNKNOWN) if INGEST_DIR_BOOK_UNKNOWN else INGEST_DIR,
"magazine": Path(INGEST_DIR_MAGAZINE) if INGEST_DIR_MAGAZINE else INGEST_DIR,
"comic book": Path(INGEST_DIR_COMIC_BOOK) if INGEST_DIR_COMIC_BOOK else INGEST_DIR,
"audiobook": Path(INGEST_DIR_AUDIOBOOK) if INGEST_DIR_AUDIOBOOK else INGEST_DIR,
"standards document": Path(INGEST_DIR_STANDARDS_DOCUMENT) if INGEST_DIR_STANDARDS_DOCUMENT else INGEST_DIR,
"musical score": Path(INGEST_DIR_MUSICAL_SCORE) if INGEST_DIR_MUSICAL_SCORE else INGEST_DIR,
"other": Path(INGEST_DIR_OTHER) if INGEST_DIR_OTHER else INGEST_DIR,
}
STATUS_TIMEOUT = int(os.getenv("STATUS_TIMEOUT", "3600"))
USE_BOOK_TITLE = string_to_bool(os.getenv("USE_BOOK_TITLE", "false"))
MAX_RETRY = int(os.getenv("MAX_RETRY", "10"))
DEFAULT_SLEEP = int(os.getenv("DEFAULT_SLEEP", "5"))
USE_CF_BYPASS = string_to_bool(os.getenv("USE_CF_BYPASS", "true"))
HTTP_PROXY = os.getenv("HTTP_PROXY", "").strip()
HTTPS_PROXY = os.getenv("HTTPS_PROXY", "").strip()
AA_DONATOR_KEY = os.getenv("AA_DONATOR_KEY", "").strip()
_AA_BASE_URL = os.getenv("AA_BASE_URL", "auto").strip()
_AA_ADDITIONAL_URLS = os.getenv("AA_ADDITIONAL_URLS", "").strip()
_SUPPORTED_FORMATS = os.getenv("SUPPORTED_FORMATS", "epub,mobi,azw3,fb2,djvu,cbz,cbr").lower()
_BOOK_LANGUAGE = os.getenv("BOOK_LANGUAGE", "en").lower()
_CUSTOM_SCRIPT = os.getenv("CUSTOM_SCRIPT", "").strip()
FLASK_HOST = os.getenv("FLASK_HOST", "0.0.0.0")
FLASK_PORT = int(os.getenv("FLASK_PORT", "8084"))
DEBUG = string_to_bool(os.getenv("DEBUG", "false"))
# Debug: skip specific download sources for testing fallback chains
# Comma-separated values: aa-fast, aa-slow-nowait, aa-slow-wait, libgen, zlib, welib
_DEBUG_SKIP_SOURCES_RAW = os.getenv("DEBUG_SKIP_SOURCES", "").strip().lower()
DEBUG_SKIP_SOURCES = set(s.strip() for s in _DEBUG_SKIP_SOURCES_RAW.split(",") if s.strip())
# Legacy welib settings - replaced by SOURCE_PRIORITY OrderableListField
# Kept for migration: if set, used to build initial SOURCE_PRIORITY config
_LEGACY_PRIORITIZE_WELIB = string_to_bool(os.getenv("PRIORITIZE_WELIB", "false"))
_LEGACY_ALLOW_USE_WELIB = string_to_bool(os.getenv("ALLOW_USE_WELIB", "true"))
# Version information from Docker build
BUILD_VERSION = os.getenv("BUILD_VERSION", "N/A")
RELEASE_VERSION = os.getenv("RELEASE_VERSION", "N/A")
# If debug is true, we want to log everything
if DEBUG:
LOG_LEVEL = "DEBUG"
else:
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
ENABLE_LOGGING = string_to_bool(os.getenv("ENABLE_LOGGING", "true"))
MAIN_LOOP_SLEEP_TIME = int(os.getenv("MAIN_LOOP_SLEEP_TIME", "5"))
MAX_CONCURRENT_DOWNLOADS = int(os.getenv("MAX_CONCURRENT_DOWNLOADS", "3"))
DOWNLOAD_PROGRESS_UPDATE_INTERVAL = int(os.getenv("DOWNLOAD_PROGRESS_UPDATE_INTERVAL", "1"))
DOCKERMODE = string_to_bool(os.getenv("DOCKERMODE", "false"))
_CUSTOM_DNS = os.getenv("CUSTOM_DNS", "auto").strip()
USE_DOH = string_to_bool(os.getenv("USE_DOH", "true"))
BYPASS_RELEASE_INACTIVE_MIN = int(os.getenv("BYPASS_RELEASE_INACTIVE_MIN", "5"))
BYPASS_WARMUP_ON_CONNECT = string_to_bool(os.getenv("BYPASS_WARMUP_ON_CONNECT", "true"))
# Logging settings
LOG_FILE = LOG_DIR / "cwa-book-downloader.log"
USING_EXTERNAL_BYPASSER = string_to_bool(os.getenv("USING_EXTERNAL_BYPASSER", "false"))
if USING_EXTERNAL_BYPASSER:
EXT_BYPASSER_URL = os.getenv("EXT_BYPASSER_URL", "http://flaresolverr:8191").strip()
EXT_BYPASSER_PATH = os.getenv("EXT_BYPASSER_PATH", "/v1").strip()
EXT_BYPASSER_TIMEOUT = int(os.getenv("EXT_BYPASSER_TIMEOUT", "60000"))
USING_TOR = string_to_bool(os.getenv("USING_TOR", "false"))
# If using Tor, we don't need to set custom DNS, use DOH, or proxy
if USING_TOR:
_CUSTOM_DNS = ""
USE_DOH = False
HTTP_PROXY = ""
HTTPS_PROXY = ""
# Detect Tor variant (has tor binary installed)
TOR_VARIANT_AVAILABLE = shutil.which("tor") is not None
# Calibre-Web URL for navigation button
CALIBRE_WEB_URL = os.getenv("CALIBRE_WEB_URL", "").strip()
# Metadata provider settings (Stage 2)
# Set to "hardcover" or "openlibrary" to enable metadata-first search mode
METADATA_PROVIDER = os.getenv("METADATA_PROVIDER", "").strip().lower()
HARDCOVER_API_KEY = os.getenv("HARDCOVER_API_KEY", "").strip()
# Cache TTL settings (in seconds)
METADATA_CACHE_SEARCH_TTL = int(os.getenv("METADATA_CACHE_SEARCH_TTL", "300")) # 5 minutes
METADATA_CACHE_BOOK_TTL = int(os.getenv("METADATA_CACHE_BOOK_TTL", "600")) # 10 minutes
# Cover image cache settings
def _is_config_dir_writable() -> bool:
"""Check if the config directory exists and is writable."""
try:
if not CONFIG_DIR.exists() or not CONFIG_DIR.is_dir():
return False
test_file = CONFIG_DIR / ".write_test"
test_file.touch()
test_file.unlink()
return True
except (OSError, PermissionError):
return False
def is_covers_cache_enabled() -> bool:
"""Check if cover caching is enabled (dynamic, respects settings changes).
Cache is only enabled if:
1. The COVERS_CACHE_ENABLED setting is true
2. The config directory is writable
"""
from cwa_book_downloader.core.config import config
setting_enabled = config.get("COVERS_CACHE_ENABLED", True)
return setting_enabled and _is_config_dir_writable()
# Legacy static value - use is_covers_cache_enabled() for dynamic checks
_COVERS_CACHE_ENABLED_ENV = string_to_bool(os.getenv("COVERS_CACHE_ENABLED", "true"))
COVERS_CACHE_ENABLED = _COVERS_CACHE_ENABLED_ENV and _is_config_dir_writable()
COVERS_CACHE_DIR = CONFIG_DIR / "covers"
COVERS_CACHE_TTL = int(os.getenv("COVERS_CACHE_TTL", "0")) # 0 = forever (covers are static)
COVERS_CACHE_MAX_SIZE_MB = int(os.getenv("COVERS_CACHE_MAX_SIZE_MB", "500"))
-5
View File
@@ -1,5 +0,0 @@
"""Core module - shared models, queue, and utilities."""
from cwa_book_downloader.core.models import BookInfo, QueueItem, SearchFilters, QueueStatus
from cwa_book_downloader.core.queue import BookQueue, book_queue
from cwa_book_downloader.core.logger import setup_logger
-354
View File
@@ -1,354 +0,0 @@
"""Archive extraction utilities for downloaded book archives."""
import os
import shutil
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional, Tuple
from cwa_book_downloader.core.logger import setup_logger
logger = setup_logger(__name__)
# Check for rarfile availability at module load
try:
import rarfile
RAR_AVAILABLE = True
except ImportError:
RAR_AVAILABLE = False
logger.warning("rarfile not installed - RAR extraction disabled")
# Book file extensions that should be kept after extraction
BOOK_EXTENSIONS = frozenset({
"epub", "mobi", "azw", "azw3", "pdf", "fb2", "djvu",
"cbz", "cbr", "txt", "rtf", "doc", "docx", "lit", "pdb",
})
class ArchiveExtractionError(Exception):
"""Raised when archive extraction fails."""
pass
class PasswordProtectedError(ArchiveExtractionError):
"""Raised when archive requires a password."""
pass
class CorruptedArchiveError(ArchiveExtractionError):
"""Raised when archive is corrupted."""
pass
def is_archive(file_path: Path) -> bool:
"""Check if file is a supported archive format."""
suffix = file_path.suffix.lower().lstrip(".")
return suffix in ("zip", "rar")
def _is_book_file(file_path: Path) -> bool:
"""Check if file is a recognized book format."""
ext = file_path.suffix.lower().lstrip(".")
return ext in BOOK_EXTENSIONS
def _filter_book_files(extracted_files: List[Path]) -> Tuple[List[Path], List[Path]]:
"""
Filter extracted files to only book formats.
Returns:
Tuple of (book_files, non_book_files)
"""
book_files = []
non_book_files = []
for file_path in extracted_files:
if _is_book_file(file_path):
book_files.append(file_path)
else:
non_book_files.append(file_path)
return book_files, non_book_files
def extract_archive(
archive_path: Path,
output_dir: Path,
) -> Tuple[List[Path], List[str]]:
"""
Extract book files from an archive.
Extracts all files, then filters to only keep recognized book formats.
Non-book files (HTML, images, etc.) are deleted.
Args:
archive_path: Path to the archive file
output_dir: Directory to extract files to
Returns:
Tuple of (extracted_book_file_paths, warnings)
Raises:
ArchiveExtractionError: If extraction fails
PasswordProtectedError: If archive requires password
CorruptedArchiveError: If archive is corrupted
"""
suffix = archive_path.suffix.lower().lstrip(".")
if suffix == "zip":
extracted_files, warnings = _extract_zip(archive_path, output_dir)
elif suffix == "rar":
extracted_files, warnings = _extract_rar(archive_path, output_dir)
else:
raise ArchiveExtractionError(f"Unsupported archive format: {suffix}")
# Filter to only book files, delete non-book files
book_files, non_book_files = _filter_book_files(extracted_files)
for non_book_file in non_book_files:
try:
non_book_file.unlink()
logger.debug(f"Deleted non-book file: {non_book_file.name}")
except OSError as e:
logger.warning(f"Failed to delete non-book file {non_book_file}: {e}")
if non_book_files:
warnings.append(f"Skipped {len(non_book_files)} non-book file(s)")
return book_files, warnings
def _extract_zip(
archive_path: Path,
output_dir: Path,
) -> Tuple[List[Path], List[str]]:
"""Extract files from a ZIP archive."""
extracted_files = []
warnings = []
try:
with zipfile.ZipFile(archive_path, "r") as zf:
# Check for password protection
for info in zf.infolist():
if info.flag_bits & 0x1: # Encrypted flag
raise PasswordProtectedError("ZIP archive is password protected")
# Test archive integrity
bad_file = zf.testzip()
if bad_file:
raise CorruptedArchiveError(f"Corrupted file in archive: {bad_file}")
# Extract all files
for info in zf.infolist():
if info.is_dir():
continue
# Use only filename, strip directory path (security: prevent path traversal)
filename = Path(info.filename).name
if not filename:
continue
# Extract to output_dir with flat structure
target_path = output_dir / filename
target_path = _handle_duplicate_filename(target_path)
with zf.open(info) as src, open(target_path, "wb") as dst:
dst.write(src.read())
extracted_files.append(target_path)
logger.debug(f"Extracted: {filename}")
except zipfile.BadZipFile as e:
raise CorruptedArchiveError(f"Invalid or corrupted ZIP: {e}")
except PermissionError as e:
raise ArchiveExtractionError(f"Permission denied: {e}")
return extracted_files, warnings
def _extract_rar(
archive_path: Path,
output_dir: Path,
) -> Tuple[List[Path], List[str]]:
"""Extract files from a RAR archive."""
if not RAR_AVAILABLE:
raise ArchiveExtractionError("RAR extraction not available - rarfile library not installed")
extracted_files = []
warnings = []
try:
with rarfile.RarFile(archive_path, "r") as rf:
# Check for password protection
if rf.needs_password():
raise PasswordProtectedError("RAR archive is password protected")
# Test archive integrity
rf.testrar()
# Extract all files
for info in rf.infolist():
if info.is_dir():
continue
# Use only filename, strip directory path (security: prevent path traversal)
filename = Path(info.filename).name
if not filename:
continue
# Extract to output_dir with flat structure
target_path = output_dir / filename
target_path = _handle_duplicate_filename(target_path)
with rf.open(info) as src, open(target_path, "wb") as dst:
dst.write(src.read())
extracted_files.append(target_path)
logger.debug(f"Extracted: {filename}")
except rarfile.BadRarFile as e:
raise CorruptedArchiveError(f"Invalid or corrupted RAR: {e}")
except rarfile.RarCannotExec:
raise ArchiveExtractionError("unrar binary not found - install unrar package")
except PermissionError as e:
raise ArchiveExtractionError(f"Permission denied: {e}")
return extracted_files, warnings
def _handle_duplicate_filename(target_path: Path) -> Path:
"""Handle duplicate filenames by appending counter."""
if not target_path.exists():
return target_path
base = target_path.stem
ext = target_path.suffix
parent = target_path.parent
counter = 1
while target_path.exists():
target_path = parent / f"{base}_{counter}{ext}"
counter += 1
return target_path
@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,
) -> ArchiveResult:
"""
Process an archive file: extract, filter to book files, move to ingest.
This is the main entry point for archive handling, usable by any download handler.
Args:
archive_path: Path to the downloaded archive file
temp_dir: Base temp directory for extraction (e.g., TMP_DIR)
ingest_dir: Final destination directory for book files
archive_id: Unique identifier for temp directory naming
Returns:
ArchiveResult with success status, final paths, and status message
"""
extract_dir = temp_dir / f"extract_{archive_id}"
try:
# Create temp extraction directory
os.makedirs(extract_dir, exist_ok=True)
os.makedirs(ingest_dir, exist_ok=True)
# Extract to temp directory (filters to book files only)
extracted_files, warnings = extract_archive(archive_path, extract_dir)
if not extracted_files:
# Clean up and return error
shutil.rmtree(extract_dir, ignore_errors=True)
archive_path.unlink(missing_ok=True)
return ArchiveResult(
success=False,
final_paths=[],
message="",
error="No book files found in archive",
)
for warning in warnings:
logger.debug(warning)
logger.info(f"Extracted {len(extracted_files)} book file(s) from archive")
# Move book files to ingest folder
final_paths = []
for extracted_file in extracted_files:
final_path = ingest_dir / extracted_file.name
final_path = _handle_duplicate_filename(final_path)
shutil.move(str(extracted_file), str(final_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 extracted formats
formats = [p.suffix.lstrip(".").upper() for p in final_paths]
if len(formats) == 1:
message = f"Extracted: {formats[0]}"
else:
message = f"Extracted: {len(formats)} files ({', '.join(formats)})"
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,798 +0,0 @@
"""Download queue orchestration and worker management.
## Download Architecture
All downloads follow a two-stage process:
1. **Staging (TMP_DIR)**: Handlers download/copy files to a temp staging area.
- Direct downloads: Downloaded directly to staging
- Torrent downloads: Copied from torrent client's completed folder to staging
- NZB downloads: Moved from NZB client's completed folder to staging
2. **Ingest (INGEST_DIR)**: Orchestrator moves staged files to the final location.
- Archive extraction (RAR/ZIP) happens here
- Custom scripts run here
- Final move to ingest folder
This ensures:
- Handlers don't need to know about ingest folder logic
- Archive handling works uniformly for all sources
- Single point of control for what enters the ingest folder
"""
import os
import random
import shutil
import subprocess
import threading
import time
from concurrent.futures import Future, ThreadPoolExecutor
from pathlib import Path
from threading import Event, Lock
from typing import Any, Dict, List, Optional, Tuple
from cwa_book_downloader.release_sources import direct_download
from cwa_book_downloader.release_sources.direct_download import SearchUnavailable
from cwa_book_downloader.core.config import config
from cwa_book_downloader.config.env import TMP_DIR, DOWNLOAD_PATHS, INGEST_DIR
from cwa_book_downloader.download.archive import is_archive, process_archive
from cwa_book_downloader.release_sources import get_handler, get_source_display_name
from cwa_book_downloader.core.logger import setup_logger
from cwa_book_downloader.core.models import BookInfo, DownloadTask, QueueStatus, SearchFilters
from cwa_book_downloader.core.queue import book_queue
logger = setup_logger(__name__)
# =============================================================================
# Staging Directory Helpers
# =============================================================================
# Handlers should use these to get paths in the staging area.
# The orchestrator handles moving staged files to the ingest folder.
def get_staging_dir() -> Path:
"""Get the staging directory for downloads.
All handlers should stage their downloads here. The orchestrator
handles moving staged files to the final ingest location.
"""
TMP_DIR.mkdir(parents=True, exist_ok=True)
return TMP_DIR
def get_staging_path(task_id: str, extension: str) -> Path:
"""Get a staging path for a download.
Args:
task_id: Unique task identifier
extension: File extension (e.g., 'epub', 'zip')
Returns:
Path in staging directory for this download
"""
staging_dir = get_staging_dir()
return staging_dir / f"{task_id}.{extension.lstrip('.')}"
def stage_file(source_path: Path, task_id: str, copy: bool = False) -> Path:
"""Stage a file for ingest processing.
Use this when a download client has completed a download and the file
needs to be staged for orchestrator processing.
Args:
source_path: Path to the completed download
task_id: Unique task identifier
copy: If True, copy the file (for torrents). If False, move it.
Returns:
Path to the staged file
"""
staging_dir = get_staging_dir()
staged_path = staging_dir / f"{task_id}{source_path.suffix}"
if copy:
shutil.copy2(str(source_path), str(staged_path))
logger.debug(f"Copied to staging: {source_path} -> {staged_path}")
else:
shutil.move(str(source_path), str(staged_path))
logger.debug(f"Moved to staging: {source_path} -> {staged_path}")
return staged_path
# WebSocket manager (initialized by app.py)
try:
from cwa_book_downloader.api.websocket import ws_manager
except ImportError:
ws_manager = None
# Progress update throttling - track last broadcast time per book
_progress_last_broadcast: Dict[str, float] = {}
_progress_lock = Lock()
# Stall detection - track last activity time per download
_last_activity: Dict[str, float] = {}
STALL_TIMEOUT = 300 # 5 minutes without progress/status update = stalled
def search_books(query: str, filters: SearchFilters) -> List[Dict[str, Any]]:
"""Search for books matching the query.
Args:
query: Search term
filters: Search filters object
Returns:
List[Dict]: List of book information dictionaries
"""
try:
books = direct_download.search_books(query, filters)
return [_book_info_to_dict(book) for book in books]
except SearchUnavailable:
raise
except Exception as e:
logger.error_trace(f"Error searching books: {e}")
raise
def get_book_info(book_id: str) -> Optional[Dict[str, Any]]:
"""Get detailed information for a specific book.
Args:
book_id: Book identifier
Returns:
Optional[Dict]: Book information dictionary if found, None if not found
Raises:
Exception: If there's an error fetching the book info
"""
try:
book = direct_download.get_book_info(book_id)
return _book_info_to_dict(book)
except Exception as e:
logger.error_trace(f"Error getting book info: {e}")
raise
def queue_book(book_id: str, priority: int = 0, source: str = "direct_download") -> bool:
"""Add a book to the download queue with specified priority.
Fetches display info and creates a DownloadTask. The handler will fetch
the full book details (including download URLs) when processing.
Args:
book_id: Book identifier (e.g., AA MD5 hash)
priority: Priority level (lower number = higher priority)
source: Release source handler to use (default: direct_download)
Returns:
bool: True if book was successfully queued
"""
try:
# Fetch book info for display purposes
book_info = direct_download.get_book_info(book_id)
if not book_info:
logger.warning(f"Could not fetch book info for {book_id}")
return False
# Create a source-agnostic download task
task = DownloadTask(
task_id=book_id,
source=source,
title=book_info.title,
author=book_info.author,
format=book_info.format,
size=book_info.size,
preview=book_info.preview,
content_type=book_info.content,
priority=priority,
)
if not book_queue.add(task):
logger.info(f"Book already in queue: {book_info.title}")
return False
logger.info(f"Book queued with priority {priority}: {book_info.title}")
# Broadcast status update via WebSocket
if ws_manager:
ws_manager.broadcast_status_update(queue_status())
return True
except Exception as e:
logger.error_trace(f"Error queueing book: {e}")
return False
def queue_release(release_data: dict, priority: int = 0) -> bool:
"""Add a release to the download queue.
This is used when downloading from the ReleaseModal where we already have
all the release data from the search - no need to re-fetch.
Creates a DownloadTask directly from the release data. The handler will
fetch full details when processing.
Args:
release_data: Release dictionary with source, source_id, title, format, etc.
priority: Priority level (lower number = higher priority)
Returns:
bool: True if release was successfully queued
"""
try:
source = release_data.get('source', 'direct_download')
extra = release_data.get('extra', {})
# Get author, preview, and content_type from top-level (preferred) or extra (fallback)
author = release_data.get('author') or extra.get('author')
preview = release_data.get('preview') or extra.get('preview')
content_type = release_data.get('content_type') or extra.get('content_type')
# Create a source-agnostic download task from release data
task = DownloadTask(
task_id=release_data['source_id'],
source=source,
title=release_data.get('title', 'Unknown'),
author=author,
format=release_data.get('format'),
size=release_data.get('size'),
preview=preview,
content_type=content_type,
priority=priority,
)
if not book_queue.add(task):
logger.info(f"Release already in queue: {task.title}")
return False
logger.info(f"Release queued with priority {priority}: {task.title}")
# Broadcast status update via WebSocket
if ws_manager:
ws_manager.broadcast_status_update(queue_status())
return True
except ValueError as e:
# Handler not found for this source
logger.warning(f"Unknown release source: {e}")
return False
except Exception as e:
logger.error_trace(f"Error queueing release: {e}")
return False
def queue_status() -> Dict[str, Dict[str, Any]]:
"""Get current status of the download queue.
Returns:
Dict: Queue status organized by status type with serialized task data
"""
status = book_queue.get_status()
for _, tasks in status.items():
for _, task in tasks.items():
if task.download_path:
if not os.path.exists(task.download_path):
task.download_path = None
# Convert Enum keys to strings and DownloadTask objects to dicts for JSON serialization
return {
status_type.value: {
task_id: _task_to_dict(task)
for task_id, task in tasks.items()
}
for status_type, tasks in status.items()
}
def get_book_data(task_id: str) -> Tuple[Optional[bytes], Optional[DownloadTask]]:
"""Get downloaded file data for a specific task.
Args:
task_id: Task identifier
Returns:
Tuple[Optional[bytes], Optional[DownloadTask]]: File data if available, and the task
"""
task = None
try:
task = book_queue.get_task(task_id)
if not task:
return None, None
path = task.download_path
if not path:
return None, task
with open(path, "rb") as f:
return f.read(), task
except Exception as e:
logger.error_trace(f"Error getting book data: {e}")
if task:
task.download_path = None
return None, task
def _book_info_to_dict(book: BookInfo) -> Dict[str, Any]:
"""Convert BookInfo object to dictionary representation.
Transforms external preview URLs to local proxy URLs when cover caching is enabled.
"""
import base64
from cwa_book_downloader.config.env import is_covers_cache_enabled
result = {
key: value for key, value in book.__dict__.items()
if value is not None
}
# Transform external preview URLs to local proxy URLs
# Skip if already a local URL (starts with /)
if result.get('preview') and is_covers_cache_enabled() and not result['preview'].startswith('/'):
original_url = result['preview']
encoded_url = base64.urlsafe_b64encode(original_url.encode()).decode()
result['preview'] = f"/api/covers/{book.id}?url={encoded_url}"
return result
def _task_to_dict(task: DownloadTask) -> Dict[str, Any]:
"""Convert DownloadTask object to dictionary representation.
Maps DownloadTask fields to the format expected by the frontend,
maintaining compatibility with the previous BookInfo-based format.
Transforms external preview URLs to local proxy URLs when cover caching is enabled.
"""
import base64
from cwa_book_downloader.config.env import is_covers_cache_enabled
preview = task.preview
# Transform external preview URLs to local proxy URLs
# Skip if already a local URL (starts with /)
if preview and is_covers_cache_enabled() and not preview.startswith('/'):
encoded_url = base64.urlsafe_b64encode(preview.encode()).decode()
preview = f"/api/covers/{task.task_id}?url={encoded_url}"
return {
'id': task.task_id,
'title': task.title,
'author': task.author,
'format': task.format,
'size': task.size,
'preview': preview,
'content_type': task.content_type,
'source': task.source,
'source_display_name': get_source_display_name(task.source),
'priority': task.priority,
'added_time': task.added_time,
'progress': task.progress,
'status': task.status,
'status_message': task.status_message,
'download_path': task.download_path,
}
def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]:
"""Download a task with cancellation support.
Delegates to the appropriate handler based on the task's source.
Handlers return a temp file path, orchestrator handles post-processing
(archive extraction, moving to ingest) uniformly for all sources.
Args:
task_id: Task identifier
cancel_flag: Threading event to signal cancellation
Returns:
str: Path to the downloaded file if successful, None otherwise
"""
try:
# Check for cancellation before starting
if cancel_flag.is_set():
logger.info(f"Download cancelled before starting: {task_id}")
return None
task = book_queue.get_task(task_id)
if not task:
logger.error(f"Task not found in queue: {task_id}")
return None
# Create callbacks that update the orchestrator's tracking
progress_callback = lambda progress: update_download_progress(task_id, progress)
status_callback = lambda status, message=None: update_download_status(task_id, status, message)
# Get the download handler based on the task's source
handler = get_handler(task.source)
temp_path = handler.download(
task,
cancel_flag,
progress_callback,
status_callback
)
# Handler returns temp path - orchestrator handles post-processing
if not temp_path:
return None
temp_file = Path(temp_path)
if not temp_file.exists():
logger.error(f"Handler returned non-existent path: {temp_path}")
return None
# Check cancellation before post-processing
if cancel_flag.is_set():
logger.info(f"Download cancelled before post-processing: {task_id}")
temp_file.unlink(missing_ok=True)
return None
# Post-processing: archive extraction or direct move to ingest
return _post_process_download(
temp_file, task, cancel_flag, status_callback
)
except Exception as e:
if cancel_flag.is_set():
logger.info(f"Download cancelled during error handling: {task_id}")
else:
logger.error_trace(f"Error downloading: {e}")
return None
def _post_process_download(
temp_file: Path,
task: DownloadTask,
cancel_flag: Event,
status_callback,
) -> Optional[str]:
"""Post-process a downloaded file: handle archives and move to ingest.
This runs uniformly for all download sources, ensuring consistent behavior.
Args:
temp_file: Path to downloaded file in temp directory
task: Download task with metadata
cancel_flag: Cancellation event
status_callback: Callback for status updates
Returns:
Final path in ingest directory, or None on failure
"""
# Route to content-type-specific ingest directory if configured
content_type = task.content_type.lower() if task.content_type else None
ingest_dir = DOWNLOAD_PATHS.get(content_type, INGEST_DIR)
if content_type and ingest_dir != INGEST_DIR:
logger.debug(f"Routing content type '{content_type}' to {ingest_dir}")
os.makedirs(ingest_dir, exist_ok=True)
# Handle archive extraction (RAR/ZIP)
if is_archive(temp_file):
logger.info(f"Archive detected, extracting: {temp_file.name}")
status_callback("resolving", "Extracting archive...")
result = process_archive(
archive_path=temp_file,
temp_dir=TMP_DIR,
ingest_dir=ingest_dir,
archive_id=task.task_id,
)
if result.success:
status_callback("complete", result.message)
return str(result.final_paths[0])
else:
status_callback("error", result.error)
return None
# Non-archive: run custom script if configured, then move to ingest
if config.CUSTOM_SCRIPT:
logger.info(f"Running custom script: {config.CUSTOM_SCRIPT}")
subprocess.run([config.CUSTOM_SCRIPT, str(temp_file)])
# Check cancellation before final move
if cancel_flag.is_set():
logger.info(f"Download cancelled before ingest: {task.task_id}")
temp_file.unlink(missing_ok=True)
return None
# Generate filename and move to ingest
filename = task.get_filename()
if not filename:
filename = f"{task.task_id}.{task.format or 'bin'}"
final_path = ingest_dir / filename
# Handle duplicate filenames
if final_path.exists():
base = final_path.stem
ext = final_path.suffix
counter = 1
while final_path.exists():
final_path = ingest_dir / f"{base}_{counter}{ext}"
counter += 1
logger.info(f"File already exists, saving as: {final_path.name}")
# Use intermediate .crdownload file for atomic move
intermediate_path = ingest_dir / f"{task.task_id}.crdownload"
try:
shutil.move(str(temp_file), str(intermediate_path))
except Exception as e:
logger.debug(f"Error moving file: {e}, trying copy instead")
try:
shutil.copyfile(str(temp_file), str(intermediate_path))
temp_file.unlink(missing_ok=True)
except Exception as e2:
logger.error(f"Failed to move/copy file to ingest: {e2}")
return None
# Final cancellation check
if cancel_flag.is_set():
logger.info(f"Download cancelled before final rename: {task.task_id}")
intermediate_path.unlink(missing_ok=True)
return None
os.rename(str(intermediate_path), str(final_path))
logger.info(f"Download completed: {final_path.name}")
return str(final_path)
def update_download_progress(book_id: str, progress: float) -> None:
"""Update download progress with throttled WebSocket broadcasts.
Progress is always stored in the queue, but WebSocket broadcasts are
throttled to avoid flooding clients with updates. Broadcasts occur:
- At most once per DOWNLOAD_PROGRESS_UPDATE_INTERVAL seconds
- Always at 0% (start) and 100% (complete)
- On significant progress jumps (>10%)
"""
book_queue.update_progress(book_id, progress)
# Track activity for stall detection
with _progress_lock:
_last_activity[book_id] = time.time()
# Broadcast progress via WebSocket with throttling
if ws_manager:
current_time = time.time()
should_broadcast = False
with _progress_lock:
last_broadcast = _progress_last_broadcast.get(book_id, 0)
last_progress = _progress_last_broadcast.get(f"{book_id}_progress", 0)
time_elapsed = current_time - last_broadcast
# Always broadcast at start (0%) or completion (>=99%)
if progress <= 1 or progress >= 99:
should_broadcast = True
# Broadcast if enough time has passed (convert interval from seconds)
elif time_elapsed >= config.DOWNLOAD_PROGRESS_UPDATE_INTERVAL:
should_broadcast = True
# Broadcast on significant progress jumps (>10%)
elif progress - last_progress >= 10:
should_broadcast = True
if should_broadcast:
_progress_last_broadcast[book_id] = current_time
_progress_last_broadcast[f"{book_id}_progress"] = progress
if should_broadcast:
ws_manager.broadcast_download_progress(book_id, progress, 'downloading')
def update_download_status(book_id: str, status: str, message: Optional[str] = None) -> None:
"""Update download status with optional detailed message.
Args:
book_id: Book identifier
status: Status string (e.g., 'resolving', 'downloading')
message: Optional detailed status message for UI display
"""
# Map string status to QueueStatus enum
status_map = {
'queued': QueueStatus.QUEUED,
'resolving': QueueStatus.RESOLVING,
'downloading': QueueStatus.DOWNLOADING,
'complete': QueueStatus.COMPLETE,
'available': QueueStatus.AVAILABLE,
'error': QueueStatus.ERROR,
'done': QueueStatus.DONE,
'cancelled': QueueStatus.CANCELLED,
}
queue_status_enum = status_map.get(status.lower())
if queue_status_enum:
book_queue.update_status(book_id, queue_status_enum)
# Track activity for stall detection
with _progress_lock:
_last_activity[book_id] = time.time()
# Update status message if provided (empty string clears the message)
if message is not None:
book_queue.update_status_message(book_id, message)
# Broadcast status update via WebSocket
if ws_manager:
ws_manager.broadcast_status_update(queue_status())
def cancel_download(book_id: str) -> bool:
"""Cancel a download.
Args:
book_id: Book identifier to cancel
Returns:
bool: True if cancellation was successful
"""
result = book_queue.cancel_download(book_id)
# Broadcast status update via WebSocket
if result and ws_manager and ws_manager.is_enabled():
ws_manager.broadcast_status_update(queue_status())
return result
def set_book_priority(book_id: str, priority: int) -> bool:
"""Set priority for a queued book.
Args:
book_id: Book identifier
priority: New priority level (lower = higher priority)
Returns:
bool: True if priority was successfully changed
"""
return book_queue.set_priority(book_id, priority)
def reorder_queue(book_priorities: Dict[str, int]) -> bool:
"""Bulk reorder queue.
Args:
book_priorities: Dict mapping book_id to new priority
Returns:
bool: True if reordering was successful
"""
return book_queue.reorder_queue(book_priorities)
def get_queue_order() -> List[Dict[str, any]]:
"""Get current queue order for display."""
return book_queue.get_queue_order()
def get_active_downloads() -> List[str]:
"""Get list of currently active downloads."""
return book_queue.get_active_downloads()
def clear_completed() -> int:
"""Clear all completed downloads from tracking."""
return book_queue.clear_completed()
def _cleanup_progress_tracking(task_id: str) -> None:
"""Clean up progress tracking data for a completed/cancelled download."""
with _progress_lock:
_progress_last_broadcast.pop(task_id, None)
_progress_last_broadcast.pop(f"{task_id}_progress", None)
_last_activity.pop(task_id, None)
def _process_single_download(task_id: str, cancel_flag: Event) -> None:
"""Process a single download job."""
try:
# Status will be updated through callbacks during download process
# (resolving -> downloading -> complete)
download_path = _download_task(task_id, cancel_flag)
# Clean up progress tracking
_cleanup_progress_tracking(task_id)
if cancel_flag.is_set():
book_queue.update_status(task_id, QueueStatus.CANCELLED)
# Broadcast cancellation
if ws_manager:
ws_manager.broadcast_status_update(queue_status())
return
if download_path:
book_queue.update_download_path(task_id, download_path)
# Only update status if not already set (e.g., by archive extraction callback)
task = book_queue.get_task(task_id)
if not task or task.status != QueueStatus.COMPLETE:
book_queue.update_status(task_id, QueueStatus.COMPLETE)
else:
book_queue.update_status(task_id, QueueStatus.ERROR)
# Broadcast final status (completed or error)
if ws_manager:
ws_manager.broadcast_status_update(queue_status())
except Exception as e:
# Clean up progress tracking even on error
_cleanup_progress_tracking(task_id)
if not cancel_flag.is_set():
logger.error_trace(f"Error in download processing: {e}")
book_queue.update_status(task_id, QueueStatus.ERROR)
# Set error message if not already set by handler
task = book_queue.get_task(task_id)
if task and not task.status_message:
book_queue.update_status_message(task_id, f"Download failed: {type(e).__name__}: {str(e)}")
else:
logger.info(f"Download cancelled: {task_id}")
book_queue.update_status(task_id, QueueStatus.CANCELLED)
# Broadcast error/cancelled status
if ws_manager:
ws_manager.broadcast_status_update(queue_status())
def concurrent_download_loop() -> None:
"""Main download coordinator using ThreadPoolExecutor for concurrent downloads."""
max_workers = config.MAX_CONCURRENT_DOWNLOADS
logger.info(f"Starting concurrent download loop with {max_workers} workers")
with ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="Download") as executor:
active_futures: Dict[Future, str] = {} # Track active download futures
while True:
# Clean up completed futures
completed_futures = [f for f in active_futures if f.done()]
for future in completed_futures:
task_id = active_futures.pop(future)
try:
future.result() # This will raise any exceptions from the worker
except Exception as e:
logger.error_trace(f"Future exception for {task_id}: {e}")
# Check for stalled downloads (no activity in STALL_TIMEOUT seconds)
current_time = time.time()
with _progress_lock:
for future, task_id in list(active_futures.items()):
last_active = _last_activity.get(task_id, current_time)
if current_time - last_active > STALL_TIMEOUT:
logger.warning(f"Download stalled for {task_id}, cancelling")
book_queue.cancel_download(task_id)
book_queue.update_status_message(task_id, f"Download stalled (no activity for {STALL_TIMEOUT}s)")
# Start new downloads if we have capacity
while len(active_futures) < max_workers:
next_download = book_queue.get_next()
if not next_download:
break
# Stagger concurrent downloads to avoid rate limiting on shared download servers
# Only delay if other downloads are already active
if active_futures:
stagger_delay = random.uniform(2, 5)
logger.debug(f"Staggering download start by {stagger_delay:.1f}s")
time.sleep(stagger_delay)
task_id, cancel_flag = next_download
# Submit download job to thread pool
future = executor.submit(_process_single_download, task_id, cancel_flag)
active_futures[future] = task_id
# Brief sleep to prevent busy waiting
time.sleep(config.MAIN_LOOP_SLEEP_TIME)
# Download coordinator thread (started explicitly via start())
_coordinator_thread: Optional[threading.Thread] = None
_started = False
def start() -> None:
"""Start the download coordinator thread.
This should be called once during application startup.
Calling multiple times is safe - subsequent calls are no-ops.
"""
global _coordinator_thread, _started
if _started:
logger.debug("Download coordinator already started")
return
_coordinator_thread = threading.Thread(
target=concurrent_download_loop,
daemon=True,
name="DownloadCoordinator"
)
_coordinator_thread.start()
_started = True
logger.info(f"Download coordinator started with {config.MAX_CONCURRENT_DOWNLOADS} concurrent workers")
@@ -1,55 +0,0 @@
"""External download client integrations (qBittorrent, SABnzbd, etc.)."""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import List, Optional, Tuple
from enum import Enum
class DownloadStatus(Enum):
"""Status of a download in an external client."""
QUEUED = "queued"
DOWNLOADING = "downloading"
PAUSED = "paused"
COMPLETED = "completed"
FAILED = "failed"
SEEDING = "seeding" # Torrents only
@dataclass
class ClientDownloadProgress:
"""Progress info from external download client."""
status: DownloadStatus
progress: float # 0-100
download_speed: Optional[int] # bytes/sec
eta: Optional[int] # seconds remaining
save_path: Optional[str] # Where the file will be/is
class DownloadClient(ABC):
"""Abstract base class for download clients."""
@abstractmethod
def add_download(self, url: str, title: str) -> str:
"""Add a download (torrent/magnet or NZB URL). Returns download ID for tracking."""
pass
@abstractmethod
def get_download(self, download_id: str) -> Optional[ClientDownloadProgress]:
"""Get progress of a specific download."""
pass
@abstractmethod
def list_downloads(self) -> List[Tuple[str, ClientDownloadProgress]]:
"""List all downloads with their progress."""
pass
@abstractmethod
def get_completed_path(self, download_id: str) -> Optional[str]:
"""Get the path to completed download."""
pass
@abstractmethod
def test_connection(self) -> bool:
"""Test if the client is reachable and credentials are valid."""
pass
+25
View File
@@ -0,0 +1,25 @@
# Local development - External bypasser variant (lite)
services:
shelfmark-lite-dev:
extends:
file: ./compose/edge/docker-compose.extbp.yml
service: shelfmark-lite
build:
context: .
dockerfile: Dockerfile
target: shelfmark-lite
environment:
DEBUG: true
EXT_BYPASSER_URL: http://flaresolverr:8191
EXT_BYPASSER_PATH: /v1
EXT_BYPASSER_TIMEOUT: 60000
volumes:
- ./.local/config:/config
- ./.local/books:/books
- ./.local/log:/var/log/shelfmark
- ./.local/tmp:/tmp/shelfmark
# 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
+20
View File
@@ -0,0 +1,20 @@
# Local development - Tor variant
services:
shelfmark-tor-dev:
extends:
file: ./compose/edge/docker-compose.tor.yml
service: shelfmark-tor
build:
context: .
dockerfile: Dockerfile
target: shelfmark
environment:
DEBUG: true
USING_TOR: true
volumes:
- ./.local/config:/config
- ./.local/books:/books
- ./.local/log:/var/log/shelfmark
- ./.local/tmp:/tmp/shelfmark
# Download client mount - path must match your torrent/usenet client's volume exactly
# - /path/to/downloads:/path/to/downloads
+12 -7
View File
@@ -1,17 +1,22 @@
# Local development - builds from source with debug enabled
services:
calibre-web-automated-book-downloader-dev:
shelfmark-dev:
extends:
file: ./docker-compose.yml
service: calibre-web-automated-book-downloader
file: ./compose/edge/docker-compose.yml
service: shelfmark
build:
context: .
dockerfile: Dockerfile
target: cwa-bd
target: shelfmark
cap_add:
- SYS_PTRACE
environment:
DEBUG: true
volumes:
- ./.local/config:/config
- ./.local/ingest:/cwa-book-ingest
- ./.local/log:/var/log/cwa-book-downloader
- ./.local/tmp:/tmp/cwa-book-downloader
- ./.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
# - /path/to/downloads:/path/to/downloads
-23
View File
@@ -1,23 +0,0 @@
# Local development - External bypasser variant
services:
calibre-web-automated-book-downloader-extbp-dev:
extends:
file: ./docker-compose.extbp.yml
service: calibre-web-automated-book-downloader-extbp
build:
context: .
dockerfile: Dockerfile
target: cwa-bd-extbp
environment:
DEBUG: true
EXT_BYPASSER_URL: http://flaresolverr:8191
EXT_BYPASSER_PATH: /v1
EXT_BYPASSER_TIMEOUT: 60000
volumes:
- ./.local/config:/config
- ./.local/ingest:/cwa-book-ingest
- ./.local/log:/var/log/cwa-book-downloader
- ./.local/tmp:/tmp/cwa-book-downloader
flaresolverr:
image: ghcr.io/flaresolverr/flaresolverr:latest
-20
View File
@@ -1,20 +0,0 @@
# Uses external Cloudflare bypasser (FlareSolverr/ByParr) instead of built-in Selenium
services:
calibre-web-automated-book-downloader-extbp:
image: ghcr.io/calibrain/calibre-web-automated-book-downloader-extbp:latest
environment:
TZ: America/New_York
EXT_BYPASSER_URL: http://flaresolverr:8191
# UID: 1000
# GID: 100
# CWA_DB_PATH: /auth/app.db
ports:
- 8084:8084
restart: unless-stopped
volumes:
- /tmp/data/calibre-web/ingest:/cwa-book-ingest
- /path/to/config:/config
# - /cwa/config/path/app.db:/auth/app.db:ro
flaresolverr:
image: ghcr.io/flaresolverr/flaresolverr:latest
-17
View File
@@ -1,17 +0,0 @@
# Local development - Tor variant
services:
calibre-web-automated-book-downloader-tor-dev:
extends:
file: ./docker-compose.tor.yml
service: calibre-web-automated-book-downloader-tor
build:
context: .
dockerfile: Dockerfile
target: cwa-bd-tor
environment:
DEBUG: true
volumes:
- ./.local/config:/config
- ./.local/ingest:/cwa-book-ingest
- ./.local/log:/var/log/cwa-book-downloader
- ./.local/tmp:/tmp/cwa-book-downloader
-19
View File
@@ -1,19 +0,0 @@
# Routes all traffic through Tor - requires NET_ADMIN capability
services:
calibre-web-automated-book-downloader-tor:
image: ghcr.io/calibrain/calibre-web-automated-book-downloader-tor:latest
environment:
FLASK_PORT: 8084
TZ: America/New_York
USING_TOR: true
# CWA_DB_PATH: /auth/app.db
cap_add:
- NET_ADMIN
- NET_RAW
ports:
- 8084:8084
restart: unless-stopped
volumes:
- /tmp/data/calibre-web/ingest:/cwa-book-ingest
- /path/to/config:/config
# - /cwa/config/path/app.db:/auth/app.db:ro
-15
View File
@@ -1,15 +0,0 @@
services:
calibre-web-automated-book-downloader:
image: ghcr.io/calibrain/calibre-web-automated-book-downloader:latest
container_name: calibre-web-automated-book-downloader
environment:
TZ: America/New_York
# UID: 1000
# GID: 100
# CWA_DB_PATH: /auth/app.db
ports:
- 8084:8084
restart: unless-stopped
volumes:
- /tmp/data/calibre-web/ingest:/cwa-book-ingest # This is where the books will be downloaded and ingested by your book management application
- /path/to/config:/config # Configuration files and database
+11 -11
View File
@@ -16,7 +16,7 @@ The settings system uses a decorator-based registration pattern. Plugins registe
Add settings to your plugin in 3 steps:
```python
from cwa_book_downloader.core.settings_registry import (
from shelfmark.core.settings_registry import (
register_settings,
TextField,
PasswordField,
@@ -249,7 +249,7 @@ The field will be greyed out with the specified reason when the condition is met
Register a group to organize related settings tabs in the sidebar:
```python
from cwa_book_downloader.core.settings_registry import register_group
from shelfmark.core.settings_registry import register_group
# Register a group (do this once, usually in a central config file)
register_group(
@@ -289,7 +289,7 @@ The `general` tab uses `CONFIG_DIR/settings.json` instead of the plugins subdire
Use the `config` singleton to read setting values in your plugin code:
```python
from cwa_book_downloader.core.config import config
from shelfmark.core.config import config
# Get a setting value with default fallback
api_key = config.get("MY_PLUGIN_API_KEY", "")
@@ -312,13 +312,13 @@ The config singleton:
Here's a complete example for a metadata provider plugin:
```python
# cwa_book_downloader/metadata_providers/my_provider.py
# shelfmark/metadata_providers/my_provider.py
from cwa_book_downloader.metadata_providers.base import (
from shelfmark.metadata_providers.base import (
MetadataProvider,
register_provider,
)
from cwa_book_downloader.core.settings_registry import (
from shelfmark.core.settings_registry import (
register_settings,
HeadingField,
TextField,
@@ -326,7 +326,7 @@ from cwa_book_downloader.core.settings_registry import (
CheckboxField,
ActionButton,
)
from cwa_book_downloader.core.config import config
from shelfmark.core.config import config
def _test_connection():
@@ -422,15 +422,15 @@ class MyProvider(MetadataProvider):
Here's a complete example for a release source plugin:
```python
# cwa_book_downloader/release_sources/my_source.py
# shelfmark/release_sources/my_source.py
from cwa_book_downloader.release_sources.base import (
from shelfmark.release_sources.base import (
ReleaseSource,
DownloadHandler,
register_source,
register_handler,
)
from cwa_book_downloader.core.settings_registry import (
from shelfmark.core.settings_registry import (
register_settings,
HeadingField,
TextField,
@@ -439,7 +439,7 @@ from cwa_book_downloader.core.settings_registry import (
SelectField,
ActionButton,
)
from cwa_book_downloader.core.config import config
from shelfmark.core.config import config
def _test_source():
+27 -27
View File
@@ -1,6 +1,6 @@
# Release Sources Plugin Development Guide
This guide explains how to create custom release source plugins for the CWA Book Downloader. The plugin system allows you to add new sources for searching and downloading books while integrating seamlessly with the existing queue, progress reporting, and settings infrastructure.
This guide explains how to create custom release source plugins for the Shelfmark. The plugin system allows you to add new sources for searching and downloading books while integrating seamlessly with the existing queue, progress reporting, and settings infrastructure.
## Table of Contents
@@ -77,21 +77,21 @@ class MyHandler(DownloadHandler): ...
## Quick Start
Create a new file at `cwa_book_downloader/release_sources/my_source.py`:
Create a new file at `shelfmark/release_sources/my_source.py`:
```python
from typing import Callable, List, Optional
from threading import Event
from cwa_book_downloader.release_sources import (
from shelfmark.release_sources import (
Release,
ReleaseSource,
DownloadHandler,
register_source,
register_handler,
)
from cwa_book_downloader.metadata_providers import BookMetadata
from cwa_book_downloader.core.models import DownloadTask
from shelfmark.metadata_providers import BookMetadata
from shelfmark.core.models import DownloadTask
@register_source("my_source")
@@ -123,11 +123,11 @@ class MyHandler(DownloadHandler):
return False # Cancellation via cancel_flag
```
Register the import in `cwa_book_downloader/release_sources/__init__.py`:
Register the import in `shelfmark/release_sources/__init__.py`:
```python
# At the bottom of the file
from cwa_book_downloader.release_sources import my_source # noqa: F401, E402
from shelfmark.release_sources import my_source # noqa: F401, E402
```
---
@@ -139,8 +139,8 @@ The `ReleaseSource` abstract base class defines the search interface:
```python
from abc import ABC, abstractmethod
from typing import List
from cwa_book_downloader.metadata_providers import BookMetadata
from cwa_book_downloader.release_sources import Release, ReleaseColumnConfig
from shelfmark.metadata_providers import BookMetadata
from shelfmark.release_sources import Release, ReleaseColumnConfig
class ReleaseSource(ABC):
"""Interface for searching a release source."""
@@ -237,7 +237,7 @@ The `DownloadHandler` abstract base class defines the download interface:
from abc import ABC, abstractmethod
from typing import Callable, Optional
from threading import Event
from cwa_book_downloader.core.models import DownloadTask
from shelfmark.core.models import DownloadTask
class DownloadHandler(ABC):
"""Interface for executing downloads from a source."""
@@ -458,7 +458,7 @@ class BookMetadata:
### Decorators
```python
from cwa_book_downloader.release_sources import register_source, register_handler
from shelfmark.release_sources import register_source, register_handler
@register_source("my_source")
class MySource(ReleaseSource):
@@ -472,7 +472,7 @@ class MyHandler(DownloadHandler):
### Registry Functions
```python
from cwa_book_downloader.release_sources import (
from shelfmark.release_sources import (
get_source,
get_handler,
list_available_sources,
@@ -497,8 +497,8 @@ available = list_available_sources()
```python
# At the bottom of __init__.py
from cwa_book_downloader.release_sources import direct_download # noqa: F401, E402
from cwa_book_downloader.release_sources import my_source # noqa: F401, E402
from shelfmark.release_sources import direct_download # noqa: F401, E402
from shelfmark.release_sources import my_source # noqa: F401, E402
```
The `noqa` comments suppress linter warnings about unused imports.
@@ -510,7 +510,7 @@ The `noqa` comments suppress linter warnings about unused imports.
Register plugin settings using the settings registry decorator:
```python
from cwa_book_downloader.core.settings_registry import (
from shelfmark.core.settings_registry import (
register_settings,
HeadingField,
TextField,
@@ -621,7 +621,7 @@ def test_my_source_connection():
### Reading Settings
```python
from cwa_book_downloader.core.settings_registry import (
from shelfmark.core.settings_registry import (
get_setting_value,
is_value_from_env,
load_config_file,
@@ -658,7 +658,7 @@ When a value comes from an ENV var, the UI shows a "locked" badge and the field
Customize how releases are displayed in the release modal by overriding `get_column_config()`:
```python
from cwa_book_downloader.release_sources import (
from shelfmark.release_sources import (
ReleaseColumnConfig,
ColumnSchema,
ColumnRenderType,
@@ -907,7 +907,7 @@ from pathlib import Path
from typing import Callable, Dict, List, Optional
from threading import Event
from cwa_book_downloader.release_sources import (
from shelfmark.release_sources import (
Release,
ReleaseSource,
DownloadHandler,
@@ -921,10 +921,10 @@ from cwa_book_downloader.release_sources import (
LeadingCellConfig,
LeadingCellType,
)
from cwa_book_downloader.metadata_providers import BookMetadata
from cwa_book_downloader.core.models import DownloadTask
from cwa_book_downloader.core.logger import setup_logger
from cwa_book_downloader.core.settings_registry import (
from shelfmark.metadata_providers import BookMetadata
from shelfmark.core.models import DownloadTask
from shelfmark.core.logger import setup_logger
from shelfmark.core.settings_registry import (
register_settings,
HeadingField,
TextField,
@@ -934,7 +934,7 @@ from cwa_book_downloader.core.settings_registry import (
ActionButton,
load_config_file,
)
from cwa_book_downloader.config.env import INGEST_DIR
from shelfmark.config.env import INGEST_DIR
logger = setup_logger(__name__)
@@ -1421,7 +1421,7 @@ PasswordField(
Use the project's logger for consistent output:
```python
from cwa_book_downloader.core.logger import setup_logger
from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
@@ -1437,13 +1437,13 @@ logger.error("Failures") # Unrecoverable errors
When creating a new plugin:
1. Create `cwa_book_downloader/release_sources/my_plugin.py`
1. Create `shelfmark/release_sources/my_plugin.py`
2. Implement `ReleaseSource` subclass with `@register_source("my_plugin")`
3. Implement `DownloadHandler` subclass with `@register_handler("my_plugin")`
4. Add settings with `@register_settings("my_plugin", ...)`
5. Add import to `cwa_book_downloader/release_sources/__init__.py`:
5. Add import to `shelfmark/release_sources/__init__.py`:
```python
from cwa_book_downloader.release_sources import my_plugin # noqa: F401, E402
from shelfmark.release_sources import my_plugin # noqa: F401, E402
```
6. Test `is_available()` returns `True` when configured
7. Test search returns valid `Release` objects
+119 -31
View File
@@ -1,7 +1,7 @@
#!/bin/bash
LOG_DIR=${LOG_ROOT:-/var/log/}/cwa-book-downloader
LOG_DIR=${LOG_ROOT:-/var/log/}/shelfmark
mkdir -p $LOG_DIR
LOG_FILE=${LOG_DIR}/cwa-bd_entrypoint.log
LOG_FILE=${LOG_DIR}/shelfmark_entrypoint.log
# Cleanup any existing files or folders in the log directory
rm -rf $LOG_DIR/*
@@ -28,34 +28,57 @@ if [ "$TZ" ]; then
ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
fi
# Set UID if not set
if [ -z "$UID" ]; then
UID=1000
# Determine user ID with proper precedence:
# 1. PUID (LinuxServer.io standard - recommended)
# 2. UID (legacy, for backward compatibility with existing installs)
# 3. Default to 1000
#
# Note: $UID is a bash builtin that's always set. We use `printenv` to detect
# if UID was explicitly set as an environment variable (e.g., via docker-compose).
if [ -n "$PUID" ]; then
RUN_UID="$PUID"
echo "Using PUID=$RUN_UID"
elif printenv UID >/dev/null 2>&1; then
RUN_UID="$(printenv UID)"
echo "Using UID=$RUN_UID (legacy - consider migrating to PUID)"
else
RUN_UID=1000
echo "Using default UID=$RUN_UID"
fi
# Set GID if not set
if [ -z "$GID" ]; then
GID=100
# Determine group ID with proper precedence:
# 1. PGID (LinuxServer.io standard - recommended)
# 2. GID (legacy, for backward compatibility with existing installs)
# 3. Default to 1000
if [ -n "$PGID" ]; then
RUN_GID="$PGID"
echo "Using PGID=$RUN_GID"
elif [ -n "$GID" ]; then
RUN_GID="$GID"
echo "Using GID=$RUN_GID (legacy - consider migrating to PGID)"
else
RUN_GID=1000
echo "Using default GID=$RUN_GID"
fi
if ! getent group "$GID" >/dev/null; then
echo "Adding group $GID with name appuser"
groupadd -g "$GID" appuser
if ! getent group "$RUN_GID" >/dev/null; then
echo "Adding group $RUN_GID with name appuser"
groupadd -g "$RUN_GID" appuser
fi
# Create user if it doesn't exist
if ! id -u "$UID" >/dev/null 2>&1; then
echo "Adding user $UID with name appuser"
useradd -u "$UID" -g "$GID" -d /app -s /sbin/nologin appuser
if ! id -u "$RUN_UID" >/dev/null 2>&1; then
echo "Adding user $RUN_UID with name appuser"
useradd -u "$RUN_UID" -g "$RUN_GID" -d /app -s /sbin/nologin appuser
fi
# Get username for the UID (whether we just created it or it existed)
USERNAME=$(getent passwd "$UID" | cut -d: -f1)
echo "Username for UID $UID is $USERNAME"
USERNAME=$(getent passwd "$RUN_UID" | cut -d: -f1)
echo "Username for UID $RUN_UID is $USERNAME"
test_write() {
folder=$1
test_file=$folder/calibre-web-automated-book-downloader_TEST_WRITE
test_file=$folder/shelfmark_TEST_WRITE
mkdir -p $folder
(
echo 0123456789_TEST | sudo -E -u "$USERNAME" HOME=/app tee $test_file > /dev/null
@@ -84,7 +107,16 @@ make_writable() {
else
echo "Folder $folder is not writable, changing ownership"
change_ownership $folder
chmod g+r,g+w $folder || echo "Failed to change group permissions for ${folder}, continuing..."
chmod -R g+r,g+w $folder || echo "Failed to change group permissions for ${folder}, continuing..."
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
fi
test_write $folder || echo "Failed to test write to ${folder}, continuing..."
}
@@ -93,23 +125,70 @@ make_writable() {
change_ownership() {
folder=$1
mkdir -p $folder
echo "Changing ownership of $folder to $USERNAME:$GID"
chown -R "${UID}" "${folder}" || echo "Failed to change user ownership for ${folder}, continuing..."
chown -R ":${GID}" "${folder}" || echo "Failed to change group ownership for ${folder}, continuing..."
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..."
}
change_ownership /app
change_ownership /var/log/cwa-book-downloader
change_ownership /tmp/cwa-book-downloader
change_ownership /var/log/shelfmark
change_ownership /tmp/shelfmark
# Test write to all folders
make_writable /cwa-book-ingest
make_writable ${CONFIG_DIR:-/config}
make_writable ${INGEST_DIR:-/books}
# Fix permissions on directories configured in settings
echo "Checking for additional configured directories..."
if [ -f /app/scripts/fix_permissions.py ]; then
configured_dirs=$(python3 /app/scripts/fix_permissions.py 2>/dev/null || echo "")
if [ -n "$configured_dirs" ]; then
echo "$configured_dirs" | while read -r dir; do
if [ -n "$dir" ] && [ -d "$dir" ]; then
echo "Checking configured directory: $dir"
make_writable "$dir"
fi
done
fi
fi
# Fallback to root if config dir is still not writable (common on NAS/Unraid after upgrade from v0.4.0)
CONFIG_PATH=${CONFIG_DIR:-/config}
set +e
test_write "$CONFIG_PATH" >/dev/null 2>&1
config_ok=$?
set -e
if [ $config_ok -ne 0 ] && [ "$RUN_UID" != "0" ]; then
config_owner=$(stat -c '%u' "$CONFIG_PATH" 2>/dev/null || echo "unknown")
if [ "$config_owner" = "0" ]; then
echo ""
echo "========================================================"
echo "WARNING: Permission issue detected!"
echo ""
echo "Config directory is owned by root but PUID=$RUN_UID."
echo "This typically happens after upgrading from v0.4.0 where"
echo "PUID/PGID settings were not respected."
echo ""
echo "Falling back to running as root to prevent data loss."
echo ""
echo "To fix this permanently, run on your HOST machine:"
echo " chown -R $RUN_UID:$RUN_GID /path/to/config"
echo ""
echo "Then restart the container."
echo "========================================================"
echo ""
RUN_UID=0
RUN_GID=0
USERNAME=root
fi
fi
# Always run Gunicorn (even when DEBUG=true) to ensure Socket.IO WebSocket
# upgrades work reliably on customer machines.
# Map app LOG_LEVEL (often DEBUG/INFO/...) to gunicorn's --log-level (lowercase).
gunicorn_loglevel=$([ "$DEBUG" = "true" ] && echo debug || echo "${LOG_LEVEL:-info}" | tr '[:upper:]' '[:lower:]')
command="gunicorn --log-level ${gunicorn_loglevel} --access-logfile - --error-logfile - --worker-class geventwebsocket.gunicorn.workers.GeventWebSocketWorker --workers 1 -t 300 -b ${FLASK_HOST:-0.0.0.0}:${FLASK_PORT:-8084} cwa_book_downloader.main:app"
command="gunicorn --log-level ${gunicorn_loglevel} --access-logfile - --error-logfile - --worker-class geventwebsocket.gunicorn.workers.GeventWebSocketWorker --workers 1 -t 300 -b ${FLASK_HOST:-0.0.0.0}:${FLASK_PORT:-8084} shelfmark.main:app"
# If DEBUG and not using an external bypass
if [ "$DEBUG" = "true" ] && [ "$USING_EXTERNAL_BYPASSER" != "true" ]; then
@@ -165,16 +244,25 @@ if [ "$DEBUG" = "true" ] && [ "$USING_EXTERNAL_BYPASSER" != "true" ]; then
echo "^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^"
fi
# Hacky way to verify /tmp has at least 1MB of space and is writable/readable
# Verify /tmp has at least 1MB of space and is writable/readable
echo "Verifying /tmp has enough space"
rm -f /tmp/test.cwa-bd
for i in {1..150000}; do printf "%04d\n" $i; done > /tmp/test.cwa-bd
sum=$(python3 -c "print(sum(int(l.strip()) for l in open('/tmp/test.cwa-bd').readlines()))")
[ "$sum" == 11250075000 ] && echo "Success: /tmp is writable" || (echo "Failure: /tmp is not writable" && exit 1)
rm /tmp/test.cwa-bd
rm -f /tmp/test.shelfmark
if dd if=/dev/zero of=/tmp/test.shelfmark bs=1M count=1 2>/dev/null && \
[ "$(wc -c < /tmp/test.shelfmark)" -eq 1048576 ]; then
rm -f /tmp/test.shelfmark
echo "Success: /tmp is writable and readable"
else
echo "Failure: /tmp is not writable or has insufficient space"
exit 1
fi
echo "Running command: '$command' as '$USERNAME' (debug=$is_debug)"
# Set umask for file permissions (default: 0022 = files 644, dirs 755)
UMASK_VALUE=${UMASK:-0022}
echo "Setting umask to $UMASK_VALUE"
umask $UMASK_VALUE
# Stop logging
exec 1>&3 2>&4
exec 3>&- 4>&-
+8 -8
View File
@@ -2,8 +2,8 @@
# Set up log paths
LOG_ROOT=${LOG_ROOT:-"/var/log"}
LOG_DIR="$LOG_ROOT/cwa-book-downloader"
OUTPUT_FILE_NAME="cwa-book-downloader-debug_BUILD-${BUILD_VERSION:-local}_RELEASE-${RELEASE_VERSION:-NA}_$(date +%Y%m%d-%H%M%S)"
LOG_DIR="$LOG_ROOT/shelfmark"
OUTPUT_FILE_NAME="shelfmark-debug_BUILD-${BUILD_VERSION:-local}_RELEASE-${RELEASE_VERSION:-NA}_$(date +%Y%m%d-%H%M%S)"
OUTPUT_FILE="/tmp/$OUTPUT_FILE_NAME.zip"
# Create LOG_DIR if it doesn't exist
@@ -100,14 +100,14 @@ echo "=== Permissions ===" > "$LOG_DIR/permissions.txt"
echo "ls -all /app" >> "$LOG_DIR/permissions.txt"
ls -all /app >> "$LOG_DIR/permissions.txt" 2>&1
echo "" >> "$LOG_DIR/permissions.txt"
echo "ls -all /cwa-book-ingest" >> "$LOG_DIR/permissions.txt"
ls -all /cwa-book-ingest >> "$LOG_DIR/permissions.txt" 2>&1
echo "ls -all ${INGEST_DIR:-/books}" >> "$LOG_DIR/permissions.txt"
ls -all ${INGEST_DIR:-/books} >> "$LOG_DIR/permissions.txt" 2>&1
echo "" >> "$LOG_DIR/permissions.txt"
echo "ls -all /var/log/cwa-book-downloader" >> "$LOG_DIR/permissions.txt"
ls -all /var/log/cwa-book-downloader >> "$LOG_DIR/permissions.txt" 2>&1
echo "ls -all /var/log/shelfmark" >> "$LOG_DIR/permissions.txt"
ls -all /var/log/shelfmark >> "$LOG_DIR/permissions.txt" 2>&1
echo "" >> "$LOG_DIR/permissions.txt"
echo "ls -all /tmp/cwa-book-downloader" >> "$LOG_DIR/permissions.txt"
ls -all /tmp/cwa-book-downloader >> "$LOG_DIR/permissions.txt" 2>&1
echo "ls -all /tmp/shelfmark" >> "$LOG_DIR/permissions.txt"
ls -all /tmp/shelfmark >> "$LOG_DIR/permissions.txt" 2>&1
echo "" >> "$LOG_DIR/permissions.txt"
# Check Iptables (NAT)
+26
View File
@@ -0,0 +1,26 @@
[project]
name = "shelfmark"
version = "0.1.0"
description = "Shelfmark - Book Downloader"
requires-python = ">=3.10"
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
"-v",
"--tb=short",
]
markers = [
"integration: marks tests that require running services (deselect with '-m \"not integration\"')",
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"e2e: marks end-to-end tests that require the full application stack",
]
[tool.mypy]
python_version = "3.10"
warn_return_any = true
warn_unused_ignores = true
ignore_missing_imports = true
+185 -300
View File
@@ -1,365 +1,250 @@
# 📚 Calibre-Web-Automated-Book-Downloader
# 📚 Shelfmark: Book Downloader
<img src="src/frontend/public/logo.png" alt="Calibre-Web Automated Book Downloader" width="200">
Formerly *Calibre Web Automated Book Downloader (CWABD)*
An intuitive web interface for searching and requesting book downloads, designed to work seamlessly with [Calibre-Web-Automated](https://github.com/crocodilestick/Calibre-Web-Automated). This project streamlines the process of downloading books and preparing them for integration into your Calibre library.
<img src="src/frontend/public/logo.png" alt="Shelfmark" width="200">
Shelfmark is a unified web interface for searching and downloading books and audiobooks 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.
**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.
## ✨ Features
- 🌐 User-friendly web interface for book search and download
- 🔄 Automated download to your specified ingest folder
- 🔌 Seamless integration with Calibre-Web-Automated
- 📖 Support for multiple book formats (epub, mobi, azw3, fb2, djvu, cbz, cbr)
- 🛡️ Cloudflare bypass capability for reliable downloads
- 🐳 Docker-based deployment for quick setup
- **One-Stop Interface** - A clean, modern UI to search, browse, and download from multiple 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
- **Two Search Modes**:
- **Direct** - Search and download books from popular web sources
- **Universal** - Search metadata providers (Hardcover, Open Library) for richer book and audiobook discovery, with multi-source downloads
- **Cloudflare Bypass** - Built-in bypasser for reliable access to protected sources
## 🖼️ Screenshots
![Homescreen](README_images/homescreen.png 'Homescreen')
**Home screen**
![Home screen](README_images/homescreen.png 'Home screen')
**Search results**
![Search results](README_images/search-results.png 'Search results')
![Active downloads](README_images/downloads.png 'Active downloads')
**Multi-source downloads**
![Multi-source downloads](README_images/multi-source.png 'Multi-source downloads')
**Download queue**
![Download queue](README_images/downloads.png 'Download queue')
## 🚀 Quick Start
### Prerequisites
- Docker
- Docker Compose
- A running instance of [Calibre-Web-Automated](https://github.com/crocodilestick/Calibre-Web-Automated) (recommended)
- Docker & Docker Compose
### Installation Steps
1. Get the docker-compose.yml:
### Installation
1. Download the docker-compose file:
```bash
curl -O https://raw.githubusercontent.com/calibrain/calibre-web-automated-book-downloader/refs/heads/main/docker-compose.yml
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/stable/docker-compose.yml
```
2. Start the service:
```bash
docker compose up -d
```
3. Access the web interface at `http://localhost:8084`
> **Edge users**: If you're tracking the main branch (`:dev` tag), use compose files from `compose/edge/` instead.
## ⚙️ Configuration
3. Open `http://localhost:8084`
### Environment Variables
That's it! Configure settings through the web interface as needed.
#### Application Settings
| Variable | Description | Default Value |
| ----------------- | ----------------------- | ------------------ |
| `FLASK_PORT` | Web interface port | `8084` |
| `FLASK_HOST` | Web interface binding | `0.0.0.0` |
| `DEBUG` | Debug mode toggle | `false` |
| `INGEST_DIR` | Book download directory | `/cwa-book-ingest` |
| `TZ` | Container timezone | `UTC` |
| `UID` | Runtime user ID | `1000` |
| `GID` | Runtime group ID | `100` |
| `CWA_DB_PATH` | Calibre-Web's database | None |
| `ENABLE_LOGGING` | Enable log file | `true` |
| `LOG_LEVEL` | Log level to use | `info` |
| `SESSION_COOKIE_SECURE` | Secure cookie enforcement - Use for HTTPS connections only | `false` |
| `CALIBRE_WEB_URL` | Custom WebUI library link | None |
| `BYPASS_WARMUP_ON_CONNECT` | Warm up Cloudflare bypasser when first client connects | `true` |
If you wish to enable authentication, you must set `CWA_DB_PATH` to point to Calibre-Web's `app.db`, in order to match the username and password.
Set `CALIBRE_WEB_URL` to your Calibre-Web / Booklore base URL. A ‘Go to library’ button will appear in the Web UI for quick access while downloading, and it also provides library access when CWA-BD is installed as a mobile PWA.
If logging is enabled, log folder default location is `/var/log/cwa-book-downloader`
Available log levels: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`. Higher levels show fewer messages.
Note that if using TOR, the TZ will be calculated automatically based on IP.
#### Download Settings
| Variable | Description | Default Value |
| ---------------------- | --------------------------------------------------------- | --------------------------------- |
| `MAX_RETRY` | Maximum retry attempts | `3` |
| `DEFAULT_SLEEP` | Retry delay (seconds) | `5` |
| `MAIN_LOOP_SLEEP_TIME` | Processing loop delay (seconds) | `5` |
| `SUPPORTED_FORMATS` | Supported book formats | `epub,mobi,azw3,fb2,djvu,cbz,cbr` |
| `BOOK_LANGUAGE` | Preferred language for books | `en` |
| `AA_DONATOR_KEY` | Optional Donator key for Anna's Archive fast download API | `` |
| `USE_BOOK_TITLE` | Use book title as filename instead of ID | `false` |
| `PRIORITIZE_WELIB` | When downloading, download from WELIB first instead of AA | `false` |
| `ALLOW_USE_WELIB` | Allow usage of welib for downloading books if found there | `true` |
If you change `BOOK_LANGUAGE`, you can add multiple comma separated languages, such as `en,fr,ru` etc.
Use the following environment variables to set specific folders in which to download
different content types (Book, Magazine, Comic, etc.):
| Variable | Description | Default Value |
|---------------------------------|--------------------------------|---------------|
| `INGEST_DIR_BOOK_FICTION` | Book (fiction) folder name | `` |
| `INGEST_DIR_BOOK_NON_FICTION` | Book (non-fiction) folder name | `` |
| `INGEST_DIR_BOOK_UNKNOWN` | Book (unknown) folder name | `` |
| `INGEST_DIR_MAGAZINE` | Magazine folder name | `` |
| `INGEST_DIR_COMIC_BOOK` | Comic book folder name | `` |
| `INGEST_DIR_AUDIOBOOK` | Audiobook folder name | `` |
| `INGEST_DIR_STANDARDS_DOCUMENT` | Standards document folder name | `` |
| `INGEST_DIR_MUSICAL_SCORE` | Musical score folder name | `` |
If no specific path is set for a content type the default is `INGEST_DIR`.
Remember to map the specified paths to where your instance of Calibre-Web-Automated (CWA) will find them, e.g.:
```
volumes:
- /tmp/data/calibre-web/comicbook-ingest:/cwa-comicbook-ingest
```
if `INGEST_DIR_COMIC_BOOK=/cwa-comicbook-ingest` and your CWA is configured to use `/tmp/data/calibre-web/comicbook-ingest`
for comic books.
#### AA
| Variable | Description | Default Value |
| ---------------------- | --------------------------------------------------------- | --------------------------------- |
| `AA_BASE_URL` | Base URL of Annas-Archive (could be changed for a proxy) | `https://annas-archive.org` |
| `USE_CF_BYPASS` | Disable CF bypass and use alternative links instead | `true` |
If you are a donator on AA, you can use your Key in `AA_DONATOR_KEY` to speed up downloads and bypass the wait times.
If disabling the cloudflare bypass, you will be using alternative download hosts, such as libgen or z-lib, but they usually have a delay before getting the more recent books and their collection is not as big as aa's. But this setting should work for the majority of books.
#### Network Settings
| Variable | Description | Default Value |
| ---------------------- | ------------------------------- | ----------------------- |
| `AA_ADDITIONAL_URLS` | Proxy URLs for AA (, separated) | `` |
| `HTTP_PROXY` | HTTP proxy URL | `` |
| `HTTPS_PROXY` | HTTPS proxy URL | `` |
| `CUSTOM_DNS` | DNS configuration | `auto` |
| `USE_DOH` | Use DNS over HTTPS | `false` |
**Proxy Configuration**
For proxy configuration, you can specify URLs in the following format:
```bash
# Basic proxy
HTTP_PROXY=http://proxy.example.com:8080
HTTPS_PROXY=http://proxy.example.com:8080
# Proxy with authentication
HTTP_PROXY=http://username:password@proxy.example.com:8080
HTTPS_PROXY=http://username:password@proxy.example.com:8080
```
**DNS Configuration**
The `CUSTOM_DNS` setting controls how DNS resolution works. By default, it is set to `auto` which provides automatic failover for reliable connectivity.
**Auto Mode (Default)**
When `CUSTOM_DNS=auto`, the application starts with your system's default DNS. If DNS resolution fails, it automatically rotates through alternative providers using DNS over HTTPS (DoH):
1. System DNS (initial)
2. Cloudflare (1.1.1.1)
3. Google (8.8.8.8)
4. Quad9 (9.9.9.9)
5. OpenDNS (208.67.222.222)
This automatic rotation helps bypass ISP-level blocks and DNS issues without any manual configuration.
**Manual DNS Configuration**
If you prefer to use a specific DNS configuration, you can override the auto behavior:
1. **Preset DNS Providers**: Use one of these predefined options:
- `google` - Google DNS (8.8.8.8, 8.8.4.4)
- `quad9` - Quad9 DNS (9.9.9.9, 149.112.112.112)
- `cloudflare` - Cloudflare DNS (1.1.1.1, 1.0.0.1)
- `opendns` - OpenDNS (208.67.222.222, 208.67.220.220)
2. **Custom DNS Servers**: A comma-separated list of DNS server IP addresses
- Example: `127.0.0.53,127.0.1.53` (useful for PiHole)
- Supports both IPv4 and IPv6 addresses
When using preset providers, you can optionally enable DNS over HTTPS with `USE_DOH=true`:
```bash
CUSTOM_DNS=cloudflare
USE_DOH=true
```
Note: When using custom IP addresses, the `USE_DOH` flag is ignored since DoH requires a known provider endpoint.
#### Custom configuration
| Variable | Description | Default Value |
| ---------------------- | ----------------------------------------------------------- | ----------------------- |
| `CUSTOM_SCRIPT` | Path to an executable script that tuns after each download | `` |
If `CUSTOM_SCRIPT` is set, it will be executed after each successful download but before the file is moved to the ingest directory. This allows for custom processing like format conversion or validation.
The script is called with the full path of the downloaded file as its argument. Important notes:
- The script must preserve the original filename for proper processing
- The file can be modified or even deleted if needed
- The file will be moved to `/cwa-book-ingest` after the script execution (if not deleted)
You can specify these configuration in this format :
```
environment:
- CUSTOM_SCRIPT=/scripts/process-book.sh
volumes:
- local/scripts/custom_script.sh:/scripts/process-book.sh
```
### Volume Configuration
### Volume Setup
```yaml
volumes:
- /your/local/path:/cwa-book-ingest
- /cwa/config/path/app.db:/auth/app.db:ro
- /your/config/path:/config # Config, database, and artwork cache directory
- /your/download/path:/books # Downloaded books
- /client/path:/client/path # Optional: For Torrent/Usenet downloads, match your client directory exactly.
```
**Note** - If your library volume is on a cifs share, you will get a "database locked" error until you add **nobrl** to your mount line in your fstab file. e.g. //192.168.1.1/Books /media/books cifs credentials=.smbcredentials,uid=1000,gid=1000,iocharset=utf8,**nobrl** - See https://github.com/crocodilestick/Calibre-Web-Automated/issues/64#issuecomment-2712769777
Mount should align with your Calibre-Web-Automated ingest folder.
> **Tip**: Point the download volume to your CWA or Booklore ingest folder for automatic import.
## Variants:
> **Note**: CIFS shares require `nobrl` mount option to avoid database lock errors.
### 🧅 Tor Variant
## ⚙️ Configuration
This application also offers a variant that routes all its traffic through the Tor network. This can be useful for enhanced privacy or bypassing network restrictions.
### Search Modes
To use the Tor variant:
**Direct** (default)
- Works out of the box, no setup required
- Searches a huge library of books directly
- Returns downloadable releases immediately
1. Get the Tor-specific docker-compose file:
```bash
curl -O https://raw.githubusercontent.com/calibrain/calibre-web-automated-book-downloader/refs/heads/main/docker-compose.tor.yml
```
2. Start the service using this file:
```bash
docker compose -f docker-compose.tor.yml up -d
```
**Universal**
- Cleaner search results via metadata providers (Hardcover is recommended)
- Aggregates releases from multiple configured sources
- Full Audiobook support
- Requires manual setup (API keys, additional sources)
**Important Considerations for Tor:**
### Environment Variables
* **Capabilities:** This variant requires the `NET_ADMIN` and `NET_RAW` Docker capabilities to configure `iptables` for transparent Tor proxying.
* **Timezone:** When running in Tor mode, the container will attempt to determine the timezone based on the Tor exit node's IP address and set it automatically. This will override the `TZ` environment variable if it is set.
* **Network Settings:** Custom DNS, DoH, and HTTP(S) proxy settings (`CUSTOM_DNS`, `USE_DOH`, `HTTP_PROXY`, `HTTPS_PROXY`) are ignored when using the Tor variant, as all traffic goes through Tor.
Environment variables work for initial setup and Docker deployments. They serve as defaults that can be overridden in the web interface.
### External Cloudflare resolver variant
| Variable | Description | Default |
|----------|-------------|---------|
| `FLASK_PORT` | Web interface port | `8084` |
| `INGEST_DIR` | Book download directory | `/books` |
| `TZ` | Container timezone | `UTC` |
| `PUID` / `PGID` | Runtime user/group ID (also supports legacy `UID`/`GID`) | `1000` / `1000` |
| `SEARCH_MODE` | `direct` or `universal` | `direct` |
| `USING_TOR` | Enable Tor routing (requires `NET_ADMIN` capability) | `false` |
This variant allows the application to use an external service to bypass Cloudflare protection, instead of relying on the built-in bypasser. This is useful if you already have a dedicated Cloudflare resolver (such as [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr) or compatible services like [ByParr](https://github.com/ThePhaseless/Byparr)) running elsewhere.
Some of the additional options available in Settings:
- **AA Donator 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
- **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
- **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
- **Metadata Providers** - Configure API keys for Hardcover, Open Library, etc.
#### How it works:
## 🐳 Docker Variants
- When enabled, all requests that require Cloudflare bypass are sent to your external resolver service.
- The application communicates with the resolver using its API.
#### Configuration
| Variable | Description | Default Value |
| ---------------------- | ----------------------------------------------------------- | ----------------------- |
| `EXT_BYPASSER_URL` | The full URL of your external resolver (required) | |
| `EXT_BYPASSER_PATH` | API path for the resolver (usually `/v1`) | `/v1` |
| `EXT_BYPASSER_TIMEOUT` | Timeout for page loading (in milliseconds) | `60000` |
#### Important
This feature follows the same configuration of the built-in Cloudflare bypasser, so you should turn on the `USE_CF_BYPASS` configuration to enable it.
#### To use the External Cloudflare resolver variant:
1. Get the extbp-specific docker-compose file:
```bash
curl -O https://raw.githubusercontent.com/calibrain/calibre-web-automated-book-downloader/refs/heads/main/docker-compose.extbp.yml
```
2. Start the service using this file:
```bash
docker compose -f docker-compose.extbp.yml up -d
```
#### Compatibility:
This feature is designed to work with any resolver that implements the `FlareSolverr` API schema, including `ByParr` and similar projects.
#### Internal vs External Bypasser
The **internal bypasser** (default) is custom-designed for this application's specific needs. It handles session management, cookie persistence, and retry logic optimized for book downloading workflows. For most users, this provides the most reliable experience out of the box.
The **external bypasser** is better suited if you:
- Already run FlareSolverr/ByParr for other services and want to consolidate
- Need to share bypass infrastructure across multiple applications
- Want to offload browser automation to a dedicated, more powerful container
If you're unsure which to use, start with the default internal bypasser.
## 🏗️ Architecture
The application consists of a Flask backend with a React-based frontend:
### Backend
- **Flask Application**: Python-based backend (`app.py`, `backend.py`) providing REST API and WebSocket support
- **Download Manager**: Handles book search, download requests, and queue management (`downloader.py`, `book_manager.py`)
- **Network Layer**: Cloudflare bypass and proxy support (`cloudflare_bypasser.py`, `network.py`)
### Frontend
- **React + TypeScript**: Modern web interface built with Vite (`src/frontend`)
- **Real-time Updates**: WebSocket integration for live download status
- **Responsive UI**: TailwindCSS-based design for mobile and desktop
For frontend development, use the provided Makefile:
### Standard
```bash
make install # Install dependencies
make dev # Start development server
make build # Build for production
```
If you run the docker compose file, the frontend will be built and served automatically. But if you run the frontend dev server it will supercede the docker compose frontend.
## 🏥 Health Monitoring
Built-in health checks monitor:
- Web interface availability
- Download service status
- Cloudflare bypass service connection
Checks run every 30 seconds with a 30-second timeout and 3 retries.
You can enable by adding this to your compose :
```
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
CMD curl -s http://localhost:8084/api/status || exit 1
docker compose up -d
```
## 📝 Logging
The full-featured image with built-in Cloudflare bypass.
Logs are available in:
#### 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
docker compose -f docker-compose.tor.yml up -d
```
- Container: `/var/logs/cwa-book-downloader.log`
- Docker logs: Access via `docker logs`
**Notes:**
- Requires `NET_ADMIN` and `NET_RAW` capabilities
- Timezone is auto-detected from Tor exit node
- Custom DNS/proxy settings are ignored when Tor is active
## 🤝 Contributing
### Lite
A smaller image without the built-in Cloudflare bypasser. Ideal for:
Contributions are welcome! Feel free to submit a Pull Request.
- **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
- **Audiobooks** - Using Shelfmark exclusively for audiobooks
## 📄 License
```bash
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/stable/docker-compose.lite.yml
docker compose -f docker-compose.lite.yml up -d
```
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
If you need Cloudflare bypass with the Lite image, configure an external resolver (FlareSolverr/ByParr) in Settings under the Cloudflare tab.
## ⚠️ Important Disclaimers
## 🔐 Authentication
Authentication is optional but recommended for shared or exposed instances. Enable in Settings.
**Alternative**: 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
```
## Health Monitoring
The application exposes a health endpoint at `/api/health` (no authentication required). Add a health check to your compose:
```yaml
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8084/api/health"]
interval: 30s
timeout: 30s
retries: 3
```
## Logging
Logs are available via:
- `docker logs <container-name>`
- `/var/log/shelfmark/` inside the container (when `ENABLE_LOGGING=true`)
Log level is configurable via Settings or `LOG_LEVEL` environment variable.
## Development
```bash
# Frontend development
make install # Install dependencies
make dev # Start Vite dev server (localhost:5173)
make build # Production build
make typecheck # TypeScript checks
# Backend (Docker)
make up # Start backend via docker-compose.dev.yml
make down # Stop services
make refresh # Rebuild and restart
make restart # Restart container
```
The frontend dev server proxies to the backend on port 8084.
### 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 (Anna's Archive → Libgen → Welib) │
├─────────────────────────────────────────────────────────────┤
│ 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
### Copyright Notice
While this tool can access various sources including those that might contain copyrighted material (e.g., Anna's Archive), it is designed for legitimate use only. Users are responsible for:
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
### Duplicate Downloads Warning
### Library Integration
Please note that the current version:
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.
- Does not check for existing files in the download directory
- Does not verify if books already exist in your Calibre database
- Exercise caution when requesting multiple books to avoid duplicates
## 💬 Support
For issues or questions, please file an issue on the GitHub repository.
## Support
For issues or questions, please [file an issue](https://github.com/calibrain/shelfmark/issues) on GitHub.
+3
View File
@@ -12,3 +12,6 @@ gevent-websocket
psutil
emoji
rarfile
qbittorrent-api
transmission-rpc
deluge-client
@@ -1,4 +1,4 @@
pyvirtualdisplay
pyautogui
seleniumbase>=4.41.1
seleniumbase>=4.45.6
python-xlib
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Fix permissions on all configured directories.
This script is called by the entrypoint to ensure all user-configured
directories have correct ownership. It reads directory paths from:
- CONFIG_DIR environment variable
- Config files in CONFIG_DIR/plugins/
Outputs directory paths that need permission fixing (one per line).
The entrypoint handles the actual chown operations.
"""
import json
import os
import sys
from pathlib import Path
def get_directories_from_config() -> set[str]:
"""Extract all directory paths from config files."""
directories = set()
config_dir = Path(os.getenv("CONFIG_DIR", "/config"))
plugins_dir = config_dir / "plugins"
if not plugins_dir.exists():
return directories
# Keys that contain directory paths
directory_keys = {
# Main destinations
"DESTINATION",
"DESTINATION_AUDIOBOOK",
# Content type routing directories
"AA_CONTENT_TYPE_DIR_FICTION",
"AA_CONTENT_TYPE_DIR_NON_FICTION",
"AA_CONTENT_TYPE_DIR_UNKNOWN",
"AA_CONTENT_TYPE_DIR_MAGAZINE",
"AA_CONTENT_TYPE_DIR_COMIC",
"AA_CONTENT_TYPE_DIR_STANDARDS",
"AA_CONTENT_TYPE_DIR_MUSICAL_SCORE",
"AA_CONTENT_TYPE_DIR_OTHER",
# Legacy keys (in case of old configs)
"INGEST_DIR",
"INGEST_DIR_AUDIOBOOK",
"INGEST_DIR_BOOK_FICTION",
"INGEST_DIR_BOOK_NON_FICTION",
"INGEST_DIR_BOOK_UNKNOWN",
"INGEST_DIR_MAGAZINE",
"INGEST_DIR_COMIC_BOOK",
"INGEST_DIR_STANDARDS_DOCUMENT",
"INGEST_DIR_MUSICAL_SCORE",
"INGEST_DIR_OTHER",
"LIBRARY_PATH",
"LIBRARY_PATH_AUDIOBOOK",
}
# Read all JSON config files
for config_file in plugins_dir.glob("*.json"):
try:
with open(config_file, "r") as f:
config = json.load(f)
for key in directory_keys:
if key in config:
value = config[key]
if value and isinstance(value, str) and value.startswith("/"):
directories.add(value)
except (json.JSONDecodeError, OSError):
continue
return directories
def main():
"""Output all configured directories that exist."""
directories = get_directories_from_config()
# Filter to directories that actually exist
existing = []
for dir_path in directories:
path = Path(dir_path)
if path.exists() and path.is_dir():
existing.append(dir_path)
# Output one directory per line
for dir_path in sorted(existing):
print(dir_path)
if __name__ == "__main__":
main()
+431
View File
@@ -0,0 +1,431 @@
#!/usr/bin/env python3
"""
Test script for download client implementations.
Usage:
1. Start the test stack:
docker compose -f docker-compose.test-clients.yml up -d
2. Wait for containers to initialize (first run takes ~30s)
3. Run this script to verify clients are accessible:
python scripts/test_clients.py
4. Access cwabd at http://localhost:8084
- Go to Settings > Prowlarr > Download Clients
- Select a client from the dropdown
- Click "Test Connection" to verify
Web UIs:
- cwabd: http://localhost:8084
- qBittorrent: http://localhost:8080
- Transmission: http://localhost:9091
- Deluge: http://localhost:8112
- NZBGet: http://localhost:6789
- SABnzbd: http://localhost:8085
Prerequisites (for running this script locally):
pip install requests transmission-rpc deluge-client qbittorrent-api
First-Time Setup:
qBittorrent:
- Check container logs for temporary password: docker logs test-qbittorrent
- Login at http://localhost:8080, change password to something known
- Default username is 'admin'
Transmission:
- No setup needed, credentials pre-configured (admin/admin)
Deluge:
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
NZBGet:
- No setup needed, credentials pre-configured (admin/admin)
SABnzbd:
- Complete the setup wizard at http://localhost:8085
- API key will be auto-detected by this script
- In cwabd, copy API key from SABnzbd Config > General
"""
import sys
import time
# Test configuration - matches docker-compose.test-clients.yml
CONFIG = {
# Usenet clients
"nzbget": {
"url": "http://localhost:6789",
"username": "admin",
"password": "admin",
},
"sabnzbd": {
"url": "http://localhost:8085",
"api_key": None, # Will be read from config on first run
},
# Torrent clients
"qbittorrent": {
"url": "http://localhost:8080",
"username": "admin",
"password": "5NCngsHXm", # Temp password from: docker logs test-qbittorrent | grep password
},
"transmission": {
"url": "http://localhost:9091",
"username": "admin",
"password": "admin",
},
"deluge": {
"host": "localhost",
"port": 58846,
"username": "admin",
"password": "admin",
},
}
# Test magnet link (Ubuntu ISO - legal, small metadata)
TEST_MAGNET = "magnet:?xt=urn:btih:3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0&dn=ubuntu-22.04.3-live-server-amd64.iso"
def test_nzbget():
"""Test NZBGet connection."""
import requests
print("\n" + "=" * 50)
print("Testing NZBGet")
print("=" * 50)
url = CONFIG["nzbget"]["url"]
username = CONFIG["nzbget"]["username"]
password = CONFIG["nzbget"]["password"]
try:
# Test connection via JSON-RPC
rpc_url = f"{url}/jsonrpc"
response = requests.post(
rpc_url,
json={"method": "version", "params": []},
auth=(username, password),
timeout=10,
)
response.raise_for_status()
result = response.json()
version = result.get("result", "unknown")
print(f" Connected to NZBGet {version}")
# Test status
response = requests.post(
rpc_url,
json={"method": "status", "params": []},
auth=(username, password),
timeout=10,
)
status = response.json().get("result", {})
print(f" Server state: {'Paused' if status.get('ServerPaused') else 'Running'}")
print(f" Downloads in queue: {status.get('DownloadedSizeMB', 0)} MB downloaded")
print(" SUCCESS: NZBGet is working!")
return True
except requests.exceptions.ConnectionError:
print(" ERROR: Could not connect to NZBGet")
print(" Is the container running? docker ps | grep nzbget")
return False
except Exception as e:
print(f" ERROR: {e}")
return False
def test_sabnzbd():
"""Test SABnzbd connection."""
import requests
print("\n" + "=" * 50)
print("Testing SABnzbd")
print("=" * 50)
url = CONFIG["sabnzbd"]["url"]
api_key = CONFIG["sabnzbd"]["api_key"]
# Try to get API key from config if not set
if not api_key:
try:
import os
ini_path = ".local/test-clients/sabnzbd/config/sabnzbd.ini"
if os.path.exists(ini_path):
with open(ini_path) as f:
for line in f:
if line.startswith("api_key"):
api_key = line.split("=")[1].strip()
print(f" Found API key in config: {api_key[:8]}...")
break
except Exception as e:
print(f" Could not read API key from config: {e}")
if not api_key:
print(" ERROR: No API key configured")
print(" Please access http://localhost:8085 and complete initial setup")
print(" Then copy the API key from Config > General")
return False
try:
# Test connection
response = requests.get(
f"{url}/api",
params={"apikey": api_key, "mode": "version", "output": "json"},
timeout=10,
)
response.raise_for_status()
result = response.json()
version = result.get("version", "unknown")
print(f" Connected to SABnzbd {version}")
# Test queue status
response = requests.get(
f"{url}/api",
params={"apikey": api_key, "mode": "queue", "output": "json"},
timeout=10,
)
queue = response.json().get("queue", {})
print(f" Queue status: {queue.get('status', 'unknown')}")
print(f" Items in queue: {len(queue.get('slots', []))}")
print(" SUCCESS: SABnzbd is working!")
return True
except requests.exceptions.ConnectionError:
print(" ERROR: Could not connect to SABnzbd")
print(" Is the container running? docker ps | grep sabnzbd")
return False
except Exception as e:
print(f" ERROR: {e}")
return False
def test_qbittorrent():
"""Test qBittorrent connection."""
print("\n" + "=" * 50)
print("Testing qBittorrent")
print("=" * 50)
try:
import qbittorrentapi
url = CONFIG["qbittorrent"]["url"]
username = CONFIG["qbittorrent"]["username"]
password = CONFIG["qbittorrent"]["password"]
# Parse URL for host/port
from urllib.parse import urlparse
parsed = urlparse(url)
client = qbittorrentapi.Client(
host=parsed.hostname,
port=parsed.port or 8080,
username=username,
password=password,
)
# Test connection
client.auth_log_in()
version = client.app.version
print(f" Connected to qBittorrent {version}")
# Get torrent list
torrents = client.torrents_info()
print(f" Active torrents: {len(torrents)}")
# Test adding a torrent (then remove it)
print(" Testing add/remove torrent...")
result = client.torrents_add(urls=TEST_MAGNET, is_paused=True)
if result == "Ok.":
# Wait a moment for it to be added
time.sleep(1)
torrents = client.torrents_info()
if torrents:
test_torrent = torrents[-1] # Most recently added
print(f" Added test torrent: {test_torrent.name[:50]}...")
print(f" Status: {test_torrent.state}")
# Remove it
client.torrents_delete(torrent_hashes=test_torrent.hash, delete_files=True)
print(" Removed test torrent")
else:
print(f" Add result: {result}")
print(" SUCCESS: qBittorrent is working!")
return True
except ImportError:
print(" ERROR: qbittorrent-api not installed")
print(" Run: pip install qbittorrent-api")
return False
except Exception as e:
print(f" ERROR: {e}")
if "Forbidden" in str(e) or "401" in str(e):
print("\n Authentication failed. Check password:")
print(" 1. docker logs test-qbittorrent | grep password")
print(" 2. Login to http://localhost:8080 and set a known password")
return False
def test_transmission():
"""Test Transmission connection."""
print("\n" + "=" * 50)
print("Testing Transmission")
print("=" * 50)
try:
from transmission_rpc import Client
from urllib.parse import urlparse
url = CONFIG["transmission"]["url"]
parsed = urlparse(url)
client = Client(
host=parsed.hostname,
port=parsed.port or 9091,
username=CONFIG["transmission"]["username"],
password=CONFIG["transmission"]["password"],
)
# Test connection
session = client.get_session()
print(f" Connected to Transmission {session.version}")
# Get torrent list
torrents = client.get_torrents()
print(f" Active torrents: {len(torrents)}")
# Test adding a torrent (then remove it)
print(" Testing add/remove torrent...")
torrent = client.add_torrent(TEST_MAGNET, paused=True)
print(f" Added test torrent: {torrent.name[:50]}...")
# Get status
status = client.get_torrent(torrent.id)
print(f" Status: {status.status} ({status.percent_done * 100:.1f}%)")
# Remove it
client.remove_torrent(torrent.id, delete_data=True)
print(" Removed test torrent")
print(" SUCCESS: Transmission is working!")
return True
except ImportError:
print(" ERROR: transmission-rpc not installed")
print(" Run: pip install transmission-rpc")
return False
except Exception as e:
print(f" ERROR: {e}")
return False
def test_deluge():
"""Test Deluge connection."""
print("\n" + "=" * 50)
print("Testing Deluge")
print("=" * 50)
try:
from deluge_client import DelugeRPCClient
client = DelugeRPCClient(
host=CONFIG["deluge"]["host"],
port=CONFIG["deluge"]["port"],
username=CONFIG["deluge"]["username"],
password=CONFIG["deluge"]["password"],
)
# Test connection
client.connect()
version = client.call("daemon.info")
print(f" Connected to Deluge {version}")
# Get torrent list
torrents = client.call("core.get_torrents_status", {}, ["name"])
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})
if 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}")
# Remove it
client.call("core.remove_torrent", torrent_id, True)
print(" Removed test torrent")
else:
print(" WARNING: Could not add test torrent")
print(" SUCCESS: Deluge is working!")
return True
except ImportError:
print(" ERROR: deluge-client not installed")
print(" Run: pip install deluge-client")
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)")
return False
def main():
print("Download Client Test Suite")
print("=" * 50)
print("Make sure containers are running:")
print(" docker compose -f docker-compose.test-clients.yml up -d")
results = {}
# Test usenet clients
print("\n" + "=" * 50)
print("USENET CLIENTS")
print("=" * 50)
results["nzbget"] = test_nzbget()
results["sabnzbd"] = test_sabnzbd()
# Test torrent clients
print("\n" + "=" * 50)
print("TORRENT CLIENTS")
print("=" * 50)
results["qbittorrent"] = test_qbittorrent()
results["transmission"] = test_transmission()
results["deluge"] = test_deluge()
# Summary
print("\n" + "=" * 50)
print("SUMMARY")
print("=" * 50)
for client, success in results.items():
status = "PASS" if success else "FAIL"
print(f" {client}: {status}")
passed = sum(results.values())
total = len(results)
print(f"\n Total: {passed}/{total} passed")
return 0 if passed == total else 1
if __name__ == "__main__":
sys.exit(main())
+1
View File
@@ -0,0 +1 @@
"""Shelfmark - book search and download service."""
+8
View File
@@ -0,0 +1,8 @@
"""Package entry point for `python -m shelfmark`."""
from shelfmark.main import app, socketio
from shelfmark.config.env import FLASK_HOST, FLASK_PORT
from shelfmark.core.config import config
if __name__ == "__main__":
socketio.run(app, host=FLASK_HOST, port=FLASK_PORT, debug=config.get("DEBUG", False))
@@ -28,29 +28,17 @@ class WebSocketManager:
logger.info("WebSocket manager initialized")
def register_on_first_connect(self, callback: Callable[[], None]):
"""Register a callback to be called when the first client connects.
This is useful for warming up resources (like the Cloudflare bypasser)
when a user starts using the web UI.
"""
"""Register a callback for when the first client connects."""
self._on_first_connect_callbacks.append(callback)
logger.debug(f"Registered on_first_connect callback: {callback.__name__}")
def register_on_all_disconnect(self, callback: Callable[[], None]):
"""Register a callback to be called when all clients disconnect.
This can be used to trigger cleanup or resource release.
"""
"""Register a callback for when all clients disconnect."""
self._on_all_disconnect_callbacks.append(callback)
logger.debug(f"Registered on_all_disconnect callback: {callback.__name__}")
def request_warmup_on_next_connect(self):
"""Request that warmup callbacks be triggered on the next client connect.
This is used when resources (like the Cloudflare bypasser) shut down due to
inactivity while clients are still connected. The next connect event should
trigger warmup even though it's not technically the "first" connection.
"""
"""Request warmup callbacks on the next client connect (e.g., after idle shutdown)."""
with self._connection_lock:
self._needs_rewarm = True
logger.debug("Warmup requested for next client connect")
@@ -157,6 +145,30 @@ class WebSocketManager:
except Exception as e:
logger.error(f"Error broadcasting notification: {e}")
def broadcast_search_status(
self,
source: str,
provider: str,
book_id: str,
message: str,
phase: str = 'searching'
):
"""Broadcast search status update for a release source search."""
if not self.is_enabled():
return
try:
data = {
'source': source,
'provider': provider,
'book_id': book_id,
'message': message,
'phase': phase,
}
self.socketio.emit('search_status', data)
except Exception as e:
logger.error(f"Error broadcasting search status: {e}")
# Global WebSocket manager instance
ws_manager = WebSocketManager()
+5
View File
@@ -0,0 +1,5 @@
"""Cloudflare bypass utilities."""
class BypassCancelledException(Exception):
"""Raised when a bypass operation is cancelled."""
+126
View File
@@ -0,0 +1,126 @@
"""External Cloudflare bypasser using FlareSolverr."""
import random
import time
from threading import Event
from typing import TYPE_CHECKING, Optional
import requests
from shelfmark.bypass import BypassCancelledException
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
if TYPE_CHECKING:
from shelfmark.download import network
logger = setup_logger(__name__)
# Timeout constants (seconds)
CONNECT_TIMEOUT = 10
MAX_READ_TIMEOUT = 120
READ_TIMEOUT_BUFFER = 15
# Retry settings
MAX_RETRY = 5
BACKOFF_BASE = 1.0
BACKOFF_CAP = 10.0
def _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")
bypasser_path = config.get("EXT_BYPASSER_PATH", "/v1")
bypasser_timeout = config.get("EXT_BYPASSER_TIMEOUT", 60000)
if not bypasser_url or not bypasser_path:
logger.error("External bypasser not configured. Check EXT_BYPASSER_URL and EXT_BYPASSER_PATH.")
return None
read_timeout = min((bypasser_timeout / 1000) + READ_TIMEOUT_BUFFER, MAX_READ_TIMEOUT)
try:
response = requests.post(
f"{bypasser_url}{bypasser_path}",
headers={"Content-Type": "application/json"},
json={"cmd": "request.get", "url": target_url, "maxTimeout": bypasser_timeout},
timeout=(CONNECT_TIMEOUT, read_timeout)
)
response.raise_for_status()
result = response.json()
status = result.get('status', 'unknown')
message = result.get('message', '')
logger.debug(f"External bypasser response for '{target_url}': {status} - {message}")
if status != 'ok':
logger.warning(f"External bypasser failed for '{target_url}': {status} - {message}")
return None
solution = result.get('solution')
html = solution.get('response', '') if solution else ''
if not html:
logger.warning(f"External bypasser returned empty response for '{target_url}'")
return None
return html
except requests.exceptions.Timeout:
logger.warning(f"External bypasser timed out for '{target_url}' (connect: {CONNECT_TIMEOUT}s, read: {read_timeout:.0f}s)")
except requests.exceptions.RequestException as e:
logger.warning(f"External bypasser request failed for '{target_url}': {e}")
except (KeyError, TypeError, ValueError) as e:
logger.warning(f"External bypasser returned malformed response for '{target_url}': {e}")
return None
def _check_cancelled(cancel_flag: Optional[Event], context: str) -> None:
"""Check if operation was cancelled and raise exception if so."""
if cancel_flag and cancel_flag.is_set():
logger.info(f"External bypasser cancelled {context}")
raise BypassCancelledException("Bypass cancelled")
def _sleep_with_cancellation(seconds: float, cancel_flag: Optional[Event]) -> None:
"""Sleep for the specified duration, checking for cancellation each second."""
for _ in range(int(seconds)):
_check_cancelled(cancel_flag, "during backoff")
time.sleep(1)
remaining = seconds - int(seconds)
if remaining > 0:
time.sleep(remaining)
def get_bypassed_page(
url: str,
selector: Optional["network.AAMirrorSelector"] = None,
cancel_flag: Optional[Event] = None
) -> Optional[str]:
"""Fetch HTML via external bypasser with retries and mirror rotation."""
from shelfmark.download import network as network_module
sel = selector or network_module.AAMirrorSelector()
for attempt in range(1, MAX_RETRY + 1):
_check_cancelled(cancel_flag, "by user")
attempt_url = sel.rewrite(url)
result = _fetch_via_bypasser(attempt_url)
if result:
return result
if attempt == MAX_RETRY:
break
delay = min(BACKOFF_CAP, BACKOFF_BASE * (2 ** (attempt - 1))) + random.random()
logger.info(f"External bypasser attempt {attempt}/{MAX_RETRY} failed, retrying in {delay:.1f}s")
_sleep_with_cancellation(delay, cancel_flag)
new_base, action = sel.next_mirror_or_rotate_dns()
if action in ("mirror", "dns") and new_base:
logger.info(f"Rotated {action} for retry")
return None
+57
View File
@@ -0,0 +1,57 @@
"""Browser fingerprint profile management for bypass stealth."""
import random
from typing import Optional
from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
COMMON_RESOLUTIONS = [
(1920, 1080, 0.35),
(1366, 768, 0.18),
(1536, 864, 0.10),
(1440, 900, 0.08),
(1280, 720, 0.07),
(1600, 900, 0.06),
(1280, 800, 0.05),
(2560, 1440, 0.04),
(1680, 1050, 0.04),
(1920, 1200, 0.03),
]
# Current screen size (module-level singleton)
_current_screen_size: Optional[tuple[int, int]] = None
def get_screen_size() -> tuple[int, int]:
global _current_screen_size
if _current_screen_size is None:
_current_screen_size = _generate_screen_size()
logger.debug(f"Generated initial screen size: {_current_screen_size[0]}x{_current_screen_size[1]}")
return _current_screen_size
def rotate_screen_size() -> tuple[int, int]:
global _current_screen_size
old_size = _current_screen_size
_current_screen_size = _generate_screen_size()
width, height = _current_screen_size
if old_size:
logger.info(f"Rotated screen size: {old_size[0]}x{old_size[1]} -> {width}x{height}")
else:
logger.info(f"Generated screen size: {width}x{height}")
return _current_screen_size
def clear_screen_size() -> None:
global _current_screen_size
_current_screen_size = None
def _generate_screen_size() -> tuple[int, int]:
resolutions = [(w, h) for w, h, _ in COMMON_RESOLUTIONS]
weights = [weight for _, _, weight in COMMON_RESOLUTIONS]
return random.choices(resolutions, weights=weights)[0]
File diff suppressed because it is too large Load Diff
+153
View File
@@ -0,0 +1,153 @@
"""Bootstrap environment variables. No local dependencies - import first."""
import json
import os
import shutil
from pathlib import Path
def string_to_bool(s: str) -> bool:
"""Convert string to boolean."""
return s.lower() in ["true", "yes", "1", "y"]
def _read_debug_from_config() -> bool:
"""Read DEBUG from env var or config file (import-time safe)."""
env_debug = os.environ.get("DEBUG")
if env_debug is not None:
return string_to_bool(env_debug)
# Try to read from config file
config_dir = Path(os.getenv("CONFIG_DIR", "/config"))
config_file = config_dir / "plugins" / "advanced.json"
if config_file.exists():
try:
with open(config_file, "r") as f:
config = json.load(f)
if "DEBUG" in config:
return bool(config["DEBUG"])
except (json.JSONDecodeError, OSError):
pass
return False
def _is_sqlite_file(path: Path) -> bool:
"""Check if a file is a valid SQLite database by reading magic bytes."""
try:
with open(path, "rb") as f:
header = f.read(16)
return header[:16] == b"SQLite format 3\x00"
except (OSError, PermissionError):
return False
def _resolve_cwa_db_path() -> Path | None:
"""Resolve CWA database path from env var or default location."""
env_path = os.getenv("CWA_DB_PATH")
if env_path:
path = Path(env_path)
if path.exists() and path.is_file() and _is_sqlite_file(path):
return path
# Check default mount path
default_path = Path("/auth/app.db")
if default_path.exists() and default_path.is_file() and _is_sqlite_file(default_path):
return default_path
return None
def _is_config_dir_writable() -> bool:
"""Check if the config directory exists and is writable."""
try:
if not CONFIG_DIR.exists() or not CONFIG_DIR.is_dir():
return False
test_file = CONFIG_DIR / ".write_test"
test_file.touch()
test_file.unlink()
return True
except (OSError, PermissionError):
return False
def is_covers_cache_enabled() -> bool:
"""Check if cover caching is enabled (requires setting + writable config dir)."""
from shelfmark.core.config import config
setting_enabled = config.get("COVERS_CACHE_ENABLED", True)
return setting_enabled and _is_config_dir_writable()
# =============================================================================
# Bootstrap paths - needed before settings registry is available
# =============================================================================
CONFIG_DIR = Path(os.getenv("CONFIG_DIR", "/config"))
LOG_ROOT = Path(os.getenv("LOG_ROOT", "/var/log/"))
LOG_DIR = LOG_ROOT / "shelfmark"
LOG_FILE = LOG_DIR / "shelfmark.log"
TMP_DIR = Path(os.getenv("TMP_DIR", "/tmp/shelfmark"))
INGEST_DIR = Path(os.getenv("INGEST_DIR", "/books"))
# =============================================================================
# Logger configuration - needed before settings registry is available
# =============================================================================
DEBUG = _read_debug_from_config()
LOG_LEVEL = "DEBUG" if DEBUG else "INFO"
ENABLE_LOGGING = string_to_bool(os.getenv("ENABLE_LOGGING", "true"))
# =============================================================================
# Flask configuration - needed before app starts
# =============================================================================
FLASK_HOST = os.getenv("FLASK_HOST", "0.0.0.0")
FLASK_PORT = int(os.getenv("FLASK_PORT", "8084"))
# =============================================================================
# Authentication
# =============================================================================
SESSION_COOKIE_SECURE_ENV = os.getenv("SESSION_COOKIE_SECURE", "false")
CWA_DB_PATH = _resolve_cwa_db_path()
# =============================================================================
# Version information from Docker build
# =============================================================================
BUILD_VERSION = os.getenv("BUILD_VERSION", "N/A")
RELEASE_VERSION = os.getenv("RELEASE_VERSION", "N/A")
# =============================================================================
# Capability detection - runtime checks, not user-configurable
# =============================================================================
DOCKERMODE = string_to_bool(os.getenv("DOCKERMODE", "false"))
TOR_VARIANT_AVAILABLE = shutil.which("tor") is not None
USING_TOR = string_to_bool(os.getenv("USING_TOR", "false"))
# =============================================================================
# Debug/development settings
# =============================================================================
# Debug: skip specific download sources for testing fallback chains
# Comma-separated values: aa-fast, aa-slow-nowait, aa-slow-wait, libgen, zlib, welib
_DEBUG_SKIP_SOURCES_RAW = os.getenv("DEBUG_SKIP_SOURCES", "").strip().lower()
DEBUG_SKIP_SOURCES = set(s.strip() for s in _DEBUG_SKIP_SOURCES_RAW.split(",") if s.strip())
# =============================================================================
# Legacy migration support - will be removed in future version
# =============================================================================
# Legacy welib settings - replaced by SOURCE_PRIORITY OrderableListField
# Kept for migration: if set, used to build initial SOURCE_PRIORITY config
_LEGACY_PRIORITIZE_WELIB = string_to_bool(os.getenv("PRIORITIZE_WELIB", "false"))
_LEGACY_ALLOW_USE_WELIB = string_to_bool(os.getenv("ALLOW_USE_WELIB", "true"))
@@ -4,8 +4,8 @@ from typing import Any, Dict
from werkzeug.security import generate_password_hash
from cwa_book_downloader.core.logger import setup_logger
from cwa_book_downloader.core.settings_registry import (
from shelfmark.core.logger import setup_logger
from shelfmark.core.settings_registry import (
register_settings,
register_on_save,
load_config_file,
@@ -20,15 +20,14 @@ 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
try:
config = load_config_file("security")
config.pop("BUILTIN_USERNAME", None)
config.pop("BUILTIN_PASSWORD_HASH", None)
# Save the cleared config
from cwa_book_downloader.core.settings_registry import _get_config_file_path, _ensure_config_dir
import json
_ensure_config_dir("security")
config_path = _get_config_file_path("security")
with open(config_path, 'w') as f:
@@ -50,6 +49,7 @@ def _on_save_security(values: Dict[str, Any]) -> Dict[str, Any]:
- 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.
@@ -63,6 +63,13 @@ def _on_save_security(values: Dict[str, Any]) -> Dict[str, Any]:
# 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,
@@ -93,10 +100,9 @@ def _on_save_security(values: Dict[str, Any]) -> Dict[str, Any]:
@register_settings("security", "Security", icon="shield", order=5)
def security_settings():
"""Security and authentication settings."""
from cwa_book_downloader.config.env import CWA_DB_PATH
import os
from shelfmark.config.env import CWA_DB_PATH
cwa_db_available = CWA_DB_PATH and os.path.exists(CWA_DB_PATH)
cwa_db_available = CWA_DB_PATH is not None and CWA_DB_PATH.exists()
fields = [
TextField(
@@ -134,14 +140,22 @@ def security_settings():
key="USE_CWA_AUTH",
label="Use Calibre-Web Database",
description=(
"Authenticate using your existing Calibre-Web users instead of the credentials above."
if cwa_db_available
else "Authenticate using your existing Calibre-Web users. Set the CWA_DB_PATH environment variable to your Calibre-Web app.db file to enable this option."
"Use your existing Calibre-Web user credentials for authentication."
),
default=False,
env_supported=False,
disabled=not cwa_db_available,
disabled_reason="Set the CWA_DB_PATH environment variable to your Calibre-Web app.db file path to enable this option.",
disabled_reason="Mount your Calibre-Web app.db to /auth/app.db in docker compose to enable.",
),
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},
),
]
@@ -4,28 +4,16 @@ import os
from pathlib import Path
import json
from cwa_book_downloader.config import env
from cwa_book_downloader.core.logger import setup_logger
from shelfmark.config import env
from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
# Log configuration values at DEBUG level, filtering out module imports and functions
logger.debug("Environment configuration:")
for key, value in env.__dict__.items():
# Skip private attributes, modules, types, and callables (functions)
if key.startswith('_'):
continue
if isinstance(value, type) or callable(value):
continue
# Don't log module objects (they have __name__ attribute)
if hasattr(value, '__name__') and hasattr(value, '__file__'):
continue
# Redact sensitive values
if key == "AA_DONATOR_KEY" and isinstance(value, str) and value.strip():
value = "REDACTED"
if key == "HARDCOVER_API_KEY" and isinstance(value, str) and value.strip():
value = "REDACTED"
logger.debug(f" {key}: {value}")
# Log bootstrap configuration values at DEBUG level
logger.debug("Bootstrap configuration:")
for key in ['CONFIG_DIR', 'LOG_DIR', 'TMP_DIR', 'INGEST_DIR', 'DEBUG', 'DOCKERMODE']:
if hasattr(env, key):
logger.debug(f" {key}: {getattr(env, key)}")
# Load supported book languages from data file
# Path is relative to the package root, not this file
@@ -39,70 +27,30 @@ logger.debug(f"BASE_DIR: {BASE_DIR}")
if env.ENABLE_LOGGING:
env.LOG_DIR.mkdir(exist_ok=True)
# Create necessary directories
# Create staging directory (destination is created by orchestrator using config value)
env.TMP_DIR.mkdir(exist_ok=True)
env.INGEST_DIR.mkdir(exist_ok=True)
CROSS_FILE_SYSTEM = os.stat(env.TMP_DIR).st_dev != os.stat(env.INGEST_DIR).st_dev
logger.debug(f"STAT TMP_DIR: {os.stat(env.TMP_DIR)}")
logger.debug(f"STAT INGEST_DIR: {os.stat(env.INGEST_DIR)}")
logger.debug(f"CROSS_FILE_SYSTEM: {CROSS_FILE_SYSTEM}")
# DNS placeholders - actual values set by network.init() from config/ENV
CUSTOM_DNS: list[str] = []
DOH_SERVER: str = ""
# Warn about external bypasser DNS limitations
if env.USING_EXTERNAL_BYPASSER and env.USE_CF_BYPASS:
logger.warning(
"Using external bypasser (FlareSolverr). Note: FlareSolverr uses its own DNS resolution, "
"not this application's custom DNS settings. If you experience DNS-related blocks, "
"configure DNS at the Docker/system level for your FlareSolverr container, "
"or consider using the internal bypasser which integrates with the app's DNS system."
)
# Proxy settings
PROXIES = {}
if env.HTTP_PROXY:
PROXIES["http"] = env.HTTP_PROXY
if env.HTTPS_PROXY:
PROXIES["https"] = env.HTTPS_PROXY
logger.debug(f"PROXIES: {PROXIES}")
# Anna's Archive settings
AA_BASE_URL = env._AA_BASE_URL
AA_AVAILABLE_URLS = ["https://annas-archive.org", "https://annas-archive.se", "https://annas-archive.li"]
AA_AVAILABLE_URLS.extend(env._AA_ADDITIONAL_URLS.split(","))
AA_AVAILABLE_URLS = [url.strip() for url in AA_AVAILABLE_URLS if url.strip()]
# File format settings
SUPPORTED_FORMATS = env._SUPPORTED_FORMATS.split(",")
logger.debug(f"SUPPORTED_FORMATS: {SUPPORTED_FORMATS}")
# Complex language processing logic kept in config.py
BOOK_LANGUAGE = env._BOOK_LANGUAGE.split(',')
BOOK_LANGUAGE = [l for l in BOOK_LANGUAGE if l in [lang['code'] for lang in _SUPPORTED_BOOK_LANGUAGE]]
if len(BOOK_LANGUAGE) == 0:
BOOK_LANGUAGE = ['en']
# Custom script settings with validation logic
CUSTOM_SCRIPT = env._CUSTOM_SCRIPT
if CUSTOM_SCRIPT:
if not os.path.exists(CUSTOM_SCRIPT):
logger.warn(f"CUSTOM_SCRIPT {CUSTOM_SCRIPT} does not exist")
CUSTOM_SCRIPT = ""
elif not os.access(CUSTOM_SCRIPT, os.X_OK):
logger.warn(f"CUSTOM_SCRIPT {CUSTOM_SCRIPT} is not executable")
CUSTOM_SCRIPT = ""
# Debugging settings
if not env.USING_EXTERNAL_BYPASSER:
# Virtual display settings for debugging internal cloudflare bypasser
VIRTUAL_SCREEN_SIZE = (1024, 768)
RECORDING_DIR = env.LOG_DIR / "recording"
# Recording directory for debugging internal cloudflare bypasser
RECORDING_DIR = env.LOG_DIR / "recording"
from cwa_book_downloader.core.settings_registry import (
def _log_external_bypasser_warning() -> None:
"""Log warning about external bypasser DNS limitations (called after config is available)."""
from shelfmark.core.config import config
if config.get("USING_EXTERNAL_BYPASSER", False) and config.get("USE_CF_BYPASS", True):
logger.warning(
"Using external bypasser (FlareSolverr). Note: FlareSolverr uses its own DNS resolution, "
"not this application's custom DNS settings. If you experience DNS-related blocks, "
"configure DNS at the Docker/system level for your FlareSolverr container, "
"or consider using the internal bypasser which integrates with the app's DNS system."
)
from shelfmark.core.settings_registry import (
register_settings,
register_group,
TextField,
@@ -160,10 +108,17 @@ _FORMAT_OPTIONS = [
{"value": "rar", "label": "RAR"},
]
_AUDIOBOOK_FORMAT_OPTIONS = [
{"value": "m4b", "label": "M4B"},
{"value": "mp3", "label": "MP3"},
{"value": "zip", "label": "ZIP"},
{"value": "rar", "label": "RAR"},
]
def _get_metadata_provider_options():
"""Build metadata provider options dynamically from enabled providers only."""
from cwa_book_downloader.metadata_providers import list_providers, is_provider_enabled
from shelfmark.metadata_providers import list_providers, is_provider_enabled
options = []
for provider in list_providers():
@@ -180,22 +135,93 @@ def _get_metadata_provider_options():
return options
def _get_metadata_provider_options_with_none():
"""Build metadata provider options with a 'Use main provider' option first."""
return [{"value": "", "label": "Use book provider"}] + _get_metadata_provider_options()
def _get_release_source_options():
"""Build release source options dynamically from registered sources."""
from cwa_book_downloader.release_sources import list_available_sources
from shelfmark.release_sources import list_available_sources
return [
{"value": source["name"], "label": source["display_name"]}
for source in list_available_sources()
if source.get("can_be_default", True)
]
_LANGUAGE_OPTIONS = [{"value": lang["code"], "label": lang["language"]} for lang in _SUPPORTED_BOOK_LANGUAGE]
def _get_aa_base_url_options():
"""Build AA URL options dynamically, including additional mirrors from config."""
from shelfmark.core.mirrors import DEFAULT_AA_MIRRORS, get_aa_mirrors
options = [{"value": "auto", "label": "Auto (Recommended)"}]
# Get all mirrors (defaults + custom)
all_mirrors = get_aa_mirrors()
for url in all_mirrors:
domain = url.replace("https://", "").replace("http://", "")
is_custom = url not in DEFAULT_AA_MIRRORS
label = f"{domain} (custom)" if is_custom else domain
options.append({"value": url, "label": label})
return options
def _get_zlib_mirror_options():
"""Build Z-Library mirror options for SelectField."""
from shelfmark.core.mirrors import DEFAULT_ZLIB_MIRRORS
from shelfmark.core.config import config
options = []
# Add default mirrors
for url in DEFAULT_ZLIB_MIRRORS:
domain = url.replace("https://", "").replace("http://", "")
options.append({"value": url, "label": domain})
# 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 DEFAULT_ZLIB_MIRRORS:
domain = url.replace("https://", "").replace("http://", "").split("/")[0]
options.append({"value": url, "label": f"{domain} (custom)"})
return options
def _get_welib_mirror_options():
"""Build Welib mirror options for SelectField."""
from shelfmark.core.mirrors import DEFAULT_WELIB_MIRRORS
from shelfmark.core.config import config
options = []
# Add default mirrors
for url in DEFAULT_WELIB_MIRRORS:
domain = url.replace("https://", "").replace("http://", "")
options.append({"value": url, "label": domain})
# 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 DEFAULT_WELIB_MIRRORS:
domain = url.replace("https://", "").replace("http://", "").split("/")[0]
options.append({"value": url, "label": f"{domain} (custom)"})
return options
def _clear_covers_cache(current_values: dict) -> dict:
"""Clear the cover image cache."""
try:
from cwa_book_downloader.core.image_cache import get_image_cache, reset_image_cache
from shelfmark.core.image_cache import get_image_cache, reset_image_cache
cache = get_image_cache()
count = cache.clear()
@@ -218,7 +244,7 @@ def _clear_covers_cache(current_values: dict) -> dict:
def _clear_metadata_cache(current_values: dict) -> dict:
"""Clear the in-memory metadata cache."""
try:
from cwa_book_downloader.core.cache import get_metadata_cache
from shelfmark.core.cache import get_metadata_cache
cache = get_metadata_cache()
stats_before = cache.stats()
@@ -246,10 +272,43 @@ def general_settings():
description="Adds a navigation button to your book manager instance (Calibre-Web Automated, Booklore, etc).",
placeholder="http://calibre-web:8083",
),
HeadingField(
key="search_defaults_heading",
title="Default Search Filters",
description="Default filters applied to searches. Can be overridden using advanced search options.",
),
MultiSelectField(
key="SUPPORTED_FORMATS",
label="Supported Book Formats",
description="Book formats to include in search results. ZIP/RAR archives are extracted automatically and book files are used if found.",
options=_FORMAT_OPTIONS,
default=["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"],
),
MultiSelectField(
key="SUPPORTED_AUDIOBOOK_FORMATS",
label="Supported Audiobook Formats",
description="Audiobook formats to include in search results. ZIP/RAR archives are extracted automatically and audiobook files are used if found.",
options=_AUDIOBOOK_FORMAT_OPTIONS,
default=["m4b", "mp3"],
),
MultiSelectField(
key="BOOK_LANGUAGE",
label="Default Book Languages",
description="Default language filter for searches.",
options=_LANGUAGE_OPTIONS,
default=["en"],
),
]
@register_settings("search_mode", "Search Mode", icon="search", order=1)
def search_mode_settings():
"""Configure how you search for and download books."""
return [
HeadingField(
key="search_mode_heading",
title="Search Mode",
description="Direct searches Anna's Archive and downloads immediately. Universal searches book metadata first, letting you choose from multiple release sources including Anna's Archive and Prowlarr.",
description="Direct mode searches Anna's Archive and downloads immediately. Universal mode searches book metadata first, letting you choose from multiple release sources including Anna's Archive and Prowlarr.",
),
SelectField(
key="SEARCH_MODE",
@@ -264,7 +323,7 @@ def general_settings():
{
"value": "universal",
"label": "Universal",
"description": "Metadata-based search with downloads from all sources.",
"description": "Metadata-based search with downloads from all sources. Book and Audiobook support.",
},
],
default="direct",
@@ -278,14 +337,28 @@ def general_settings():
env_supported=False, # UI-only setting
show_when={"field": "SEARCH_MODE", "value": "direct"},
),
HeadingField(
key="universal_mode_heading",
title="Universal Mode Settings",
description="Configure metadata providers and release sources for Universal search mode.",
show_when={"field": "SEARCH_MODE", "value": "universal"},
),
SelectField(
key="METADATA_PROVIDER",
label="Metadata Provider",
label="Book Metadata Provider",
description="Choose which metadata provider to use for book searches.",
options=_get_metadata_provider_options, # Callable - evaluated lazily to avoid circular imports
default="openlibrary",
show_when={"field": "SEARCH_MODE", "value": "universal"},
),
SelectField(
key="METADATA_PROVIDER_AUDIOBOOK",
label="Audiobook Metadata Provider",
description="Metadata provider for audiobook searches. Uses the book provider if not set.",
options=_get_metadata_provider_options_with_none, # Callable - includes "Use main provider" option
default="",
show_when={"field": "SEARCH_MODE", "value": "universal"},
),
SelectField(
key="DEFAULT_RELEASE_SOURCE",
label="Default Release Source",
@@ -295,25 +368,6 @@ def general_settings():
env_supported=False, # UI-only setting, not configurable via ENV
show_when={"field": "SEARCH_MODE", "value": "universal"},
),
HeadingField(
key="search_defaults_heading",
title="Default Search Options",
description="Default filters applied to searches. Can be overridden using advanced search options.",
),
MultiSelectField(
key="SUPPORTED_FORMATS",
label="Supported Formats",
description="Book formats to include in search results. ZIP/RAR archives are extracted automatically and book files are used if found.",
options=_FORMAT_OPTIONS,
default=["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"],
),
MultiSelectField(
key="BOOK_LANGUAGE",
label="Default Book Languages",
description="Default language filter for searches.",
options=_LANGUAGE_OPTIONS,
default=["en"],
),
]
@@ -324,9 +378,9 @@ def network_settings():
tor_available = env.TOR_VARIANT_AVAILABLE
tor_enabled = env.USING_TOR
# When Tor is enabled (only possible in Tor variant), DNS/proxy settings are overridden
# The Tor variant uses iptables to force ALL traffic through Tor - it cannot be disabled
tor_overrides_network = tor_available # If Tor variant, network settings are always managed by Tor
# When Tor is enabled, DNS/proxy settings are overridden by iptables rules
# Tor uses iptables to force ALL traffic through Tor
tor_overrides_network = tor_enabled # Only override when Tor is actually active
return [
SelectField(
@@ -383,16 +437,16 @@ def network_settings():
key="USING_TOR",
label="Tor Routing",
description=(
"All traffic is routed through Tor in this container variant. This cannot be changed."
if tor_available
else "Tor routing is not available in this container variant."
"All traffic is routed through Tor. Requires container restart to change."
if tor_enabled
else "Route all traffic through Tor for enhanced privacy."
),
default=tor_available, # Reflects actual state: True if Tor variant, False otherwise
disabled=True, # Always disabled - Tor state is determined by container variant
default=tor_enabled, # Reflects actual state from env var
disabled=True, # Tor state requires container restart
disabled_reason=(
"Tor routing is always active in the Tor container variant."
if tor_available
else "Requires the Tor container variant (calibre-web-automated-book-downloader-tor)."
"Tor routing is active. Set USING_TOR=false and restart to disable."
if tor_enabled
else "Set USING_TOR=true env var and restart with NET_ADMIN/NET_RAW capabilities."
),
),
SelectField(
@@ -446,18 +500,129 @@ def network_settings():
def download_settings():
"""Configure download behavior and file locations."""
return [
# === BOOKS SECTION ===
# Visible for ALL modes (Direct + Universal)
HeadingField(
key="books_heading",
title="Books",
description="Configure where ebooks, comics, and magazines are saved.",
),
TextField(
key="INGEST_DIR",
label="Download Directory",
key="DESTINATION",
label="Destination",
description="Directory where downloaded files are saved.",
default="/cwa-book-ingest",
default="/books",
required=True,
env_var="INGEST_DIR", # Legacy env var name for backwards compatibility
),
SelectField(
key="FILE_ORGANIZATION",
label="File Organization",
description="Choose how downloaded book files are named and organized. ",
options=[
{
"value": "none",
"label": "None",
"description": "Keep original filename from source"
},
{
"value": "rename",
"label": "Rename",
"description": "Rename files using a template"
},
{
"value": "organize",
"label": "Organize",
"description": "Create folders and rename files using a template. Do not use with ingest folders."
},
],
default="rename",
),
# Rename mode template - filename only
TextField(
key="TEMPLATE_RENAME",
label="Naming Template",
description="Variables: {Author}, {Title}, {Year}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}",
default="{Author} - {Title} ({Year})",
placeholder="{Author} - {Title} ({Year})",
show_when={"field": "FILE_ORGANIZATION", "value": "rename"},
),
# Organize mode template - folders allowed
TextField(
key="TEMPLATE_ORGANIZE",
label="Path Template",
description="Use / to create folders. Variables: {Author}, {Title}, {Year}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}",
default="{Author}/{Title} ({Year})",
placeholder="{Author}/{Series/}{Title} ({Year})",
show_when={"field": "FILE_ORGANIZATION", "value": "organize"},
),
CheckboxField(
key="USE_BOOK_TITLE",
label="Use Book Info as Filename",
description="Save files using Author, Title and Year instead of ID. May cause issues with special characters.",
key="HARDLINK_TORRENTS",
label="Hardlink Book Torrents",
description="Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder.",
default=False,
universal_only=True,
),
# === AUDIOBOOKS SECTION ===
# Universal mode only
HeadingField(
key="audiobooks_heading",
title="Audiobooks",
description="Configure where audiobooks are saved.",
universal_only=True,
),
TextField(
key="DESTINATION_AUDIOBOOK",
label="Destination",
description="Leave empty to use Books destination.",
placeholder="/audiobooks",
universal_only=True,
),
SelectField(
key="FILE_ORGANIZATION_AUDIOBOOK",
label="File Organization",
description="Choose how downloaded audiobook files are named and organized.",
options=[
{"value": "none", "label": "None", "description": "Keep original filename from source"},
{"value": "rename", "label": "Rename", "description": "Rename files using a template"},
{"value": "organize", "label": "Organize", "description": "Create folders and rename files using a template. Recommended for Audiobookshelf. Do not use with ingest folders."},
],
default="rename",
universal_only=True,
),
# Rename mode template - filename only
TextField(
key="TEMPLATE_AUDIOBOOK_RENAME",
label="Naming Template",
description="Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}",
default="{Author} - {Title}",
placeholder="{Author} - {Title}{ - Part }{PartNumber}",
show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "rename"},
universal_only=True,
),
# Organize mode template - folders allowed
TextField(
key="TEMPLATE_AUDIOBOOK_ORGANIZE",
label="Path Template",
description="Use / to create folders. Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}",
default="{Author}/{Title}",
placeholder="{Author}/{Series/}{Title}{ - Part }{PartNumber}",
show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "organize"},
universal_only=True,
),
CheckboxField(
key="HARDLINK_TORRENTS_AUDIOBOOK",
label="Hardlink Audiobook Torrents",
description="Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder.",
default=True,
universal_only=True,
),
# === OPTIONS SECTION ===
HeadingField(
key="options_heading",
title="Options",
),
CheckboxField(
key="AUTO_OPEN_DOWNLOADS_SIDEBAR",
@@ -490,168 +655,121 @@ def download_settings():
min_value=60,
max_value=86400,
),
CheckboxField(
key="USE_CONTENT_TYPE_DIRECTORIES",
label="Configure Content-Type Directories",
description="Show options to specify custom directories for each content type (fiction, non-fiction, comics, etc.). If a directory is set, that content type will be saved there instead of the default download directory.",
default=False,
env_supported=False, # UI-only toggle to show/hide directory fields
),
HeadingField(
key="content_type_directories_heading",
title="Content-Type Directories",
description="Specify custom directories for each content type. Leave empty to use the default download directory.",
show_when={"field": "USE_CONTENT_TYPE_DIRECTORIES", "value": True},
),
TextField(
key="INGEST_DIR_BOOK_FICTION",
label="Fiction Books",
placeholder="/cwa-book-ingest/fiction",
show_when={"field": "USE_CONTENT_TYPE_DIRECTORIES", "value": True},
),
TextField(
key="INGEST_DIR_BOOK_NON_FICTION",
label="Non-Fiction Books",
placeholder="/cwa-book-ingest/non-fiction",
show_when={"field": "USE_CONTENT_TYPE_DIRECTORIES", "value": True},
),
TextField(
key="INGEST_DIR_BOOK_UNKNOWN",
label="Unknown Books",
placeholder="/cwa-book-ingest/unknown",
show_when={"field": "USE_CONTENT_TYPE_DIRECTORIES", "value": True},
),
TextField(
key="INGEST_DIR_MAGAZINE",
label="Magazines",
placeholder="/cwa-book-ingest/magazines",
show_when={"field": "USE_CONTENT_TYPE_DIRECTORIES", "value": True},
),
TextField(
key="INGEST_DIR_COMIC_BOOK",
label="Comic Books",
placeholder="/cwa-book-ingest/comics",
show_when={"field": "USE_CONTENT_TYPE_DIRECTORIES", "value": True},
),
TextField(
key="INGEST_DIR_AUDIOBOOK",
label="Audiobooks",
placeholder="/cwa-book-ingest/audiobooks",
show_when={"field": "USE_CONTENT_TYPE_DIRECTORIES", "value": True},
),
TextField(
key="INGEST_DIR_STANDARDS_DOCUMENT",
label="Standards Documents",
placeholder="/cwa-book-ingest/standards",
show_when={"field": "USE_CONTENT_TYPE_DIRECTORIES", "value": True},
),
TextField(
key="INGEST_DIR_MUSICAL_SCORE",
label="Musical Scores",
placeholder="/cwa-book-ingest/scores",
show_when={"field": "USE_CONTENT_TYPE_DIRECTORIES", "value": True},
),
TextField(
key="INGEST_DIR_OTHER",
label="Other",
placeholder="/cwa-book-ingest/other",
show_when={"field": "USE_CONTENT_TYPE_DIRECTORIES", "value": True},
),
]
def _get_source_priority_options():
"""Build source priority options with dynamic disabled states."""
from cwa_book_downloader.core.config import config
def _get_fast_source_options():
"""Fast download sources - display only, not configurable."""
from shelfmark.core.config import config
has_donator_key = bool(config.get("AA_DONATOR_KEY", ""))
use_cf_bypass = config.get("USE_CF_BYPASS", True)
using_external_bypasser = config.get("USING_EXTERNAL_BYPASSER", False)
has_internal_bypasser = use_cf_bypass and not using_external_bypasser
return [
{
"id": "aa-fast",
"label": "Anna's Archive (Fast)",
"description": "Fast downloads for donators",
"isPinned": True,
"isLocked": not has_donator_key,
"disabledReason": "Requires AA Donator Key" if not has_donator_key else None,
},
{
"id": "welib",
"label": "Welib",
"description": "Alternative mirror with good availability",
"isLocked": not has_internal_bypasser,
"disabledReason": "Requires internal bypasser" if not has_internal_bypasser else None,
"id": "libgen",
"label": "Library Genesis",
"description": "Instant downloads, no bypass needed",
"isPinned": True,
},
]
def _get_fast_source_defaults():
"""Default values for fast sources display."""
return [
{"id": "aa-fast", "enabled": True},
{"id": "libgen", "enabled": True},
]
def _get_slow_source_options():
"""Slow download sources - configurable order. All require bypasser."""
from shelfmark.core.config import config
bypass_enabled = config.get("USE_CF_BYPASS", True)
locked = not bypass_enabled
disabled_reason = "Requires Cloudflare bypass" if locked else None
return [
{
"id": "aa-slow-nowait",
"label": "Anna's Archive (Slowest, No Waitlist)",
"description": "Partner servers without countdown",
"description": "Partner servers",
"isLocked": locked,
"disabledReason": disabled_reason,
},
{
"id": "aa-slow-wait",
"label": "Anna's Archive (Slow, Waitlist)",
"label": "Anna's Archive (Slow with Waitlist)",
"description": "Partner servers with countdown timer",
"isLocked": locked,
"disabledReason": disabled_reason,
},
{
"id": "libgen",
"label": "Libgen",
"description": "Library Genesis mirrors",
"id": "welib",
"label": "Welib",
"description": "Alternative mirror",
"isLocked": locked,
"disabledReason": disabled_reason,
},
{
"id": "zlib",
"label": "Z-Library",
"description": "Z-Library mirrors (requires Cloudflare bypass)",
"isLocked": not has_internal_bypasser,
"disabledReason": "Requires internal bypasser" if not has_internal_bypasser else None,
"description": "Alternative mirror",
"isLocked": locked,
"disabledReason": disabled_reason,
},
]
def _get_default_source_priority():
"""Default source priority order, respecting legacy env vars.
def _get_slow_source_defaults():
"""Default source priority order for slow sources."""
from shelfmark.config.env import _LEGACY_ALLOW_USE_WELIB
ALLOW_USE_WELIB (default true) controls whether welib is enabled.
PRIORITIZE_WELIB (default false) controls whether welib is moved to position 1.
"""
from cwa_book_downloader.config.env import _LEGACY_PRIORITIZE_WELIB, _LEGACY_ALLOW_USE_WELIB
welib_entry = {"id": "welib", "enabled": _LEGACY_ALLOW_USE_WELIB}
priority = [
{"id": "aa-fast", "enabled": True},
return [
{"id": "aa-slow-nowait", "enabled": True},
{"id": "aa-slow-wait", "enabled": True},
{"id": "libgen", "enabled": True},
{"id": "welib", "enabled": _LEGACY_ALLOW_USE_WELIB},
{"id": "zlib", "enabled": True},
]
if _LEGACY_PRIORITIZE_WELIB:
priority.insert(1, welib_entry) # After aa-fast
else:
priority.append(welib_entry) # Before zlib
# Z-Library last - it's quite brittle
priority.append({"id": "zlib", "enabled": True})
return priority
@register_settings("download_sources", "Download Sources", icon="download", order=21, group="direct_download")
def download_source_settings():
"""Settings for download source behavior."""
return [
PasswordField(
key="AA_DONATOR_KEY",
label="Anna's Archive Donator Key",
description="Enables fast downloads from Anna's Archive.",
),
HeadingField(
key="source_priority_heading",
title="Source Priority",
description="Configure which download sources to use and in what order.",
description="Sources are tried in order until a download succeeds.",
),
OrderableListField(
key="FAST_SOURCES_DISPLAY",
label="Fast downloads",
description="Always tried first, no waiting or bypass required.",
options=_get_fast_source_options,
default=_get_fast_source_defaults(),
env_supported=False,
),
OrderableListField(
key="SOURCE_PRIORITY",
label="Download Source Order",
description="Drag to reorder. Sources are tried from top to bottom until a download succeeds.",
options=_get_source_priority_options,
default=_get_default_source_priority(),
label="Slow downloads",
description="Fallback sources, may have waiting. Requires bypasser. Drag to reorder.",
options=_get_slow_source_options,
default=_get_slow_source_defaults(),
),
NumberField(
key="MAX_RETRY",
@@ -670,32 +788,63 @@ def download_source_settings():
max_value=60,
),
HeadingField(
key="aa_settings_heading",
title="Anna's Archive",
description="Configure Anna's Archive mirror and donator settings.",
key="content_type_routing_heading",
title="Content-Type Routing",
description="Route downloads to different folders based on content type. Only applies to Anna's Archive downloads.",
),
SelectField(
key="AA_BASE_URL",
label="Anna's Archive URL",
description="Primary Anna's Archive mirror to use. 'auto' selects automatically.",
options=[
{"value": "auto", "label": "Auto (Recommended)"},
{"value": "https://annas-archive.org", "label": "annas-archive.org"},
{"value": "https://annas-archive.se", "label": "annas-archive.se"},
{"value": "https://annas-archive.li", "label": "annas-archive.li"},
],
default="auto",
CheckboxField(
key="AA_CONTENT_TYPE_ROUTING",
label="Enable Content-Type Routing",
description="Override destination based on Anna's Archive content type metadata.",
default=False,
),
TextField(
key="AA_ADDITIONAL_URLS",
label="Additional AA Mirrors",
description="Comma-separated list of additional Anna's Archive mirror URLs.",
placeholder="https://example.com,https://another.com",
key="AA_CONTENT_TYPE_DIR_FICTION",
label="Fiction Books",
placeholder="/books/fiction",
show_when={"field": "AA_CONTENT_TYPE_ROUTING", "value": True},
),
PasswordField(
key="AA_DONATOR_KEY",
label="Anna's Archive Donator Key",
description="Optional donator key for faster downloads from Anna's Archive.",
TextField(
key="AA_CONTENT_TYPE_DIR_NON_FICTION",
label="Non-Fiction Books",
placeholder="/books/non-fiction",
show_when={"field": "AA_CONTENT_TYPE_ROUTING", "value": True},
),
TextField(
key="AA_CONTENT_TYPE_DIR_UNKNOWN",
label="Unknown Books",
placeholder="/books/unknown",
show_when={"field": "AA_CONTENT_TYPE_ROUTING", "value": True},
),
TextField(
key="AA_CONTENT_TYPE_DIR_MAGAZINE",
label="Magazines",
placeholder="/books/magazines",
show_when={"field": "AA_CONTENT_TYPE_ROUTING", "value": True},
),
TextField(
key="AA_CONTENT_TYPE_DIR_COMIC",
label="Comic Books",
placeholder="/books/comics",
show_when={"field": "AA_CONTENT_TYPE_ROUTING", "value": True},
),
TextField(
key="AA_CONTENT_TYPE_DIR_STANDARDS",
label="Standards Documents",
placeholder="/books/standards",
show_when={"field": "AA_CONTENT_TYPE_ROUTING", "value": True},
),
TextField(
key="AA_CONTENT_TYPE_DIR_MUSICAL_SCORE",
label="Musical Scores",
placeholder="/books/scores",
show_when={"field": "AA_CONTENT_TYPE_ROUTING", "value": True},
),
TextField(
key="AA_CONTENT_TYPE_DIR_OTHER",
label="Other",
placeholder="/books/other",
show_when={"field": "AA_CONTENT_TYPE_ROUTING", "value": True},
),
]
@@ -711,20 +860,6 @@ def cloudflare_bypass_settings():
default=True,
requires_restart=True,
),
CheckboxField(
key="BYPASS_WARMUP_ON_CONNECT",
label="Warmup on Connect",
description="Pre-warm the bypasser when user connects to Web App UI",
default=True,
),
NumberField(
key="BYPASS_RELEASE_INACTIVE_MIN",
label="Release Inactive (minutes)",
description="Release bypasser resources after this many minutes of inactivity.",
default=5,
min_value=1,
max_value=60,
),
CheckboxField(
key="USING_EXTERNAL_BYPASSER",
label="Use External Bypasser",
@@ -763,6 +898,83 @@ def cloudflare_bypass_settings():
]
@register_settings("mirrors", "Mirrors", icon="globe", order=23, group="direct_download")
def mirror_settings():
"""Configure download source mirrors."""
from shelfmark.core.mirrors import DEFAULT_ZLIB_MIRRORS, DEFAULT_WELIB_MIRRORS
return [
# === ANNA'S ARCHIVE ===
HeadingField(
key="aa_mirrors_heading",
title="Anna's Archive",
description="Primary mirror with auto-probe on startup. Additional mirrors used as fallback.",
),
SelectField(
key="AA_BASE_URL",
label="Primary Mirror",
description="Select 'Auto' to probe mirrors on startup, or choose a specific mirror.",
options=_get_aa_base_url_options,
default="auto",
),
TextField(
key="AA_ADDITIONAL_URLS",
label="Additional Mirrors",
description="Comma-separated list of custom Anna's Archive mirror URLs.",
),
# === LIBGEN ===
HeadingField(
key="libgen_mirrors_heading",
title="LibGen",
description="All mirrors are tried during download until one succeeds. Defaults: libgen.gl, libgen.li, libgen.bz, libgen.la, libgen.vg",
),
TextField(
key="LIBGEN_ADDITIONAL_URLS",
label="Additional Mirrors",
description="Comma-separated list of custom LibGen mirrors to add to the defaults.",
),
# === Z-LIBRARY ===
HeadingField(
key="zlib_mirrors_heading",
title="Z-Library",
description="Z-Library requires Cloudflare bypass. Only the primary mirror is used.",
),
SelectField(
key="ZLIB_PRIMARY_URL",
label="Primary Mirror",
description="Z-Library mirror to use for downloads.",
options=_get_zlib_mirror_options,
default=DEFAULT_ZLIB_MIRRORS[0],
),
TextField(
key="ZLIB_ADDITIONAL_URLS",
label="Additional Mirrors",
description="Comma-separated list of custom Z-Library mirror URLs.",
),
# === WELIB ===
HeadingField(
key="welib_mirrors_heading",
title="Welib",
description="Welib requires Cloudflare bypass. Only the primary mirror is used.",
),
SelectField(
key="WELIB_PRIMARY_URL",
label="Primary Mirror",
description="Welib mirror to use for downloads.",
options=_get_welib_mirror_options,
default=DEFAULT_WELIB_MIRRORS[0],
),
TextField(
key="WELIB_ADDITIONAL_URLS",
label="Additional Mirrors",
description="Comma-separated list of custom Welib mirror URLs.",
),
]
@register_settings("advanced", "Advanced", icon="cog", order=15)
def advanced_settings():
"""Advanced settings for power users."""
+5
View File
@@ -0,0 +1,5 @@
"""Core module - shared models, queue, and utilities."""
from shelfmark.core.models import BookInfo, QueueItem, SearchFilters, QueueStatus
from shelfmark.core.queue import BookQueue, book_queue
from shelfmark.core.logger import setup_logger
@@ -6,7 +6,7 @@ from dataclasses import dataclass
from functools import wraps
from typing import Any, Callable, Dict, Optional, TypeVar
from cwa_book_downloader.core.logger import setup_logger
from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
@@ -24,24 +24,13 @@ class CacheService:
"""Thread-safe in-memory cache with TTL support."""
def __init__(self, max_size: int = 1000):
"""Initialize cache service.
Args:
max_size: Maximum number of entries before oldest are evicted.
"""
"""Initialize cache with max_size entries before eviction."""
self._cache: Dict[str, CacheEntry] = {}
self._lock = threading.Lock()
self._max_size = max_size
def get(self, key: str) -> Optional[Any]:
"""Get cached value if not expired.
Args:
key: Cache key to retrieve.
Returns:
Cached value or None if not found/expired.
"""
"""Get cached value if not expired."""
with self._lock:
entry = self._cache.get(key)
if entry is None:
@@ -54,13 +43,7 @@ class CacheService:
return entry.value
def set(self, key: str, value: Any, ttl: int) -> None:
"""Cache value with TTL.
Args:
key: Cache key.
value: Value to cache.
ttl: Time to live in seconds.
"""
"""Cache value with TTL in seconds."""
with self._lock:
# Evict oldest entries if at capacity
if len(self._cache) >= self._max_size:
@@ -72,14 +55,7 @@ class CacheService:
)
def invalidate(self, key: str) -> bool:
"""Remove specific cache entry.
Args:
key: Cache key to remove.
Returns:
True if entry was removed, False if not found.
"""
"""Remove specific cache entry. Returns True if found."""
with self._lock:
if key in self._cache:
del self._cache[key]
@@ -92,11 +68,7 @@ class CacheService:
self._cache.clear()
def cleanup_expired(self) -> int:
"""Remove all expired entries.
Returns:
Number of entries removed.
"""
"""Remove all expired entries. Returns count removed."""
with self._lock:
now = time.time()
expired_keys = [
@@ -108,10 +80,7 @@ class CacheService:
return len(expired_keys)
def _evict_oldest(self) -> None:
"""Evict oldest entries (by expiration time) to make room.
Called with lock held.
"""
"""Evict ~10% of oldest entries. Called with lock held."""
if not self._cache:
return
@@ -126,11 +95,7 @@ class CacheService:
del self._cache[key]
def stats(self) -> Dict[str, int]:
"""Get cache statistics.
Returns:
Dict with size and max_size.
"""
"""Get cache statistics (size, max_size)."""
with self._lock:
return {
"size": len(self._cache),
@@ -148,15 +113,7 @@ def get_metadata_cache() -> CacheService:
def cache_key(*args, **kwargs) -> str:
"""Generate cache key from arguments.
Args:
*args: Positional arguments to include in key.
**kwargs: Keyword arguments to include in key.
Returns:
String cache key.
"""
"""Generate cache key from arguments."""
parts = [str(arg) for arg in args]
parts.extend(f"{k}={v}" for k, v in sorted(kwargs.items()))
return ":".join(parts)
@@ -168,23 +125,12 @@ def cacheable(
ttl_default: int = 300,
key_prefix: str = ""
):
"""Decorator for caching function results.
Args:
ttl: Static time to live in seconds (use this OR ttl_key, not both).
ttl_key: Config key to read TTL from (e.g., "METADATA_CACHE_SEARCH_TTL").
ttl_default: Default TTL if ttl_key not found in config.
key_prefix: Optional prefix for cache keys.
Examples:
@cacheable(ttl=300, key_prefix="hardcover:search") # Static TTL
@cacheable(ttl_key="METADATA_CACHE_SEARCH_TTL", key_prefix="hardcover:search") # Dynamic TTL
"""
"""Decorator for caching function results. Use ttl (static) or ttl_key (from config)."""
def decorator(func: Callable[..., T]) -> Callable[..., T]:
@wraps(func)
def wrapper(*args, **kwargs) -> T:
# Check if metadata caching is enabled
from cwa_book_downloader.core.config import config
from shelfmark.core.config import config
if not config.get("METADATA_CACHE_ENABLED", True):
# Caching disabled, execute function directly
@@ -211,11 +157,9 @@ def cacheable(
# Check cache
cached = _metadata_cache.get(key)
if cached is not None:
logger.debug(f"Cache hit: {key}")
return cached
# Execute function and cache result
logger.debug(f"Cache miss: {key}")
result = func(*args, **kwargs)
# Only cache non-None results
@@ -12,7 +12,7 @@ def _get_registry():
"""Lazy import of settings registry to avoid circular imports."""
global _registry_module
if _registry_module is None:
from cwa_book_downloader.core import settings_registry
from shelfmark.core import settings_registry
_registry_module = settings_registry
return _registry_module
@@ -21,7 +21,7 @@ def _get_env():
"""Lazy import of env module for fallback values."""
global _env_module
if _env_module is None:
from cwa_book_downloader.config import env
from shelfmark.config import env
_env_module = env
return _env_module
@@ -65,11 +65,12 @@ class Config:
def _load_settings(self) -> None:
"""Load all settings from the registry."""
# Ensure all plugin settings are registered before loading
# This handles cases where config is accessed before plugins are imported
# Ensure all settings modules are imported before loading
# This handles cases where config is accessed before settings are registered
try:
import cwa_book_downloader.release_sources # noqa: F401
import cwa_book_downloader.metadata_providers # noqa: F401
import shelfmark.config.settings # noqa: F401 - main app settings
import shelfmark.release_sources # noqa: F401 - plugin settings
import shelfmark.metadata_providers # noqa: F401 - plugin settings
except ImportError:
pass
@@ -10,7 +10,7 @@ from typing import Any, Dict, Optional, Tuple
import requests
from cwa_book_downloader.core.logger import setup_logger
from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
@@ -92,10 +92,13 @@ class ImageCacheService:
def _load_index(self) -> None:
"""Load cache index from disk."""
if not self.index_path.exists():
self._index = {}
return
try:
if self.index_path.exists():
with open(self.index_path, 'r') as f:
self._index = json.load(f)
with open(self.index_path, 'r') as f:
self._index = json.load(f)
except (json.JSONDecodeError, IOError):
self._index = {}
@@ -179,9 +182,7 @@ class ImageCacheService:
"""Check if a cache entry is expired."""
if self.ttl_seconds == 0:
return False
cached_at = entry.get('cached_at', 0)
return (time.time() - cached_at) > self.ttl_seconds
return (time.time() - entry.get('cached_at', 0)) > self.ttl_seconds
def _is_negative_expired(self, entry: Dict[str, Any]) -> bool:
"""Check if a negative cache entry is expired.
@@ -193,12 +194,8 @@ class ImageCacheService:
return False
cached_at = entry.get('cached_at', 0)
# Transient failures (timeouts, connection errors) use shorter TTL
if entry.get('transient', False):
return (time.time() - cached_at) > TRANSIENT_CACHE_TTL
return (time.time() - cached_at) > NEGATIVE_CACHE_TTL
ttl = TRANSIENT_CACHE_TTL if entry.get('transient', False) else NEGATIVE_CACHE_TTL
return (time.time() - cached_at) > ttl
def _calculate_total_size(self) -> int:
"""Calculate total size of cached images."""
@@ -255,14 +252,13 @@ class ImageCacheService:
with self._lock:
entry = self._index.get(cache_id)
# Try reloading from disk if not found (handles multiprocess case)
if not entry:
# Try reloading from disk (handles multiprocess case)
self._load_index()
entry = self._index.get(cache_id)
if not entry:
self._misses += 1
return None
if not entry:
self._misses += 1
return None
# Check for negative cache (failed fetch)
if entry.get('negative', False):
@@ -526,10 +522,8 @@ class ImageCacheService:
self.put_negative(cache_id, transient=True)
return None
except requests.exceptions.HTTPError as e:
if e.response is not None and e.response.status_code == 404:
self.put_negative(cache_id)
else:
self.put_negative(cache_id, transient=True)
is_404 = e.response is not None and e.response.status_code == 404
self.put_negative(cache_id, transient=not is_404)
return None
except Exception:
return None
@@ -550,8 +544,8 @@ def get_image_cache() -> ImageCacheService:
if _instance is None:
with _instance_lock:
if _instance is None:
from cwa_book_downloader.core.config import config
from cwa_book_downloader.config.env import CONFIG_DIR
from shelfmark.core.config import config
from shelfmark.config.env import CONFIG_DIR
cache_dir = CONFIG_DIR / "covers"
max_size_mb = config.get("COVERS_CACHE_MAX_SIZE_MB", 500)
@@ -563,7 +557,7 @@ def get_image_cache() -> ImageCacheService:
max_size_mb=max_size_mb,
ttl_seconds=ttl_seconds,
)
logger.info(f"Initialized image cache: {cache_dir} (max {max_size_mb}MB, TTL {ttl_days} days)")
logger.debug(f"Initialized image cache: {cache_dir} (max {max_size_mb}MB, TTL {ttl_days} days)")
return _instance
@@ -6,7 +6,7 @@ from pathlib import Path
from logging.handlers import RotatingFileHandler
from typing import Any
from cwa_book_downloader.config.env import LOG_FILE, ENABLE_LOGGING, LOG_LEVEL
from shelfmark.config.env import LOG_FILE, ENABLE_LOGGING, LOG_LEVEL
class CustomLogger(logging.Logger):
@@ -40,11 +40,21 @@ class CustomLogger(logging.Logger):
def log_resource_usage(self):
import psutil
# Sum RSS of all processes for actual app memory
app_memory_mb = 0
for proc in psutil.process_iter(['memory_info']):
try:
if proc.info['memory_info']:
app_memory_mb += proc.info['memory_info'].rss / (1024 * 1024)
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
memory = psutil.virtual_memory()
system_used_mb = memory.used / (1024 * 1024)
available_mb = memory.available / (1024 * 1024)
memory_used_mb = memory.used / (1024 * 1024)
cpu_percent = psutil.cpu_percent()
self.debug(f"Container Memory: Available={available_mb:.2f} MB, Used={memory_used_mb:.2f} MB, CPU: {cpu_percent:.2f}%")
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}%")
def setup_logger(name: str, log_file: Path = LOG_FILE) -> CustomLogger:
@@ -62,17 +72,7 @@ def setup_logger(name: str, log_file: Path = LOG_FILE) -> CustomLogger:
# Create logger as CustomLogger instance
logger = CustomLogger(name)
log_level = logging.INFO
if LOG_LEVEL == "DEBUG":
log_level = logging.DEBUG
elif LOG_LEVEL == "INFO":
log_level = logging.INFO
elif LOG_LEVEL == "WARNING":
log_level = logging.WARNING
elif LOG_LEVEL == "ERROR":
log_level = logging.ERROR
elif LOG_LEVEL == "CRITICAL":
log_level = logging.CRITICAL
log_level = getattr(logging, LOG_LEVEL, logging.INFO)
logger.setLevel(log_level)
formatter = logging.Formatter(
+213
View File
@@ -0,0 +1,213 @@
"""Centralized mirror configuration for all download sources."""
from typing import List
# Lazy import to avoid circular imports
_config_module = None
def _get_config():
"""Lazy import of config module to avoid circular imports."""
global _config_module
if _config_module is None:
from shelfmark.core.config import config
_config_module = config
return _config_module
# Default mirror lists (hardcoded fallbacks)
DEFAULT_AA_MIRRORS = [
"https://annas-archive.se",
"https://annas-archive.li",
"https://annas-archive.pm",
"https://annas-archive.in",
]
DEFAULT_LIBGEN_MIRRORS = [
"https://libgen.gl",
"https://libgen.li",
"https://libgen.bz",
"https://libgen.la",
"https://libgen.vg",
]
DEFAULT_ZLIB_MIRRORS = [
"https://z-lib.fm",
"https://z-lib.gs",
"https://z-lib.id",
"https://z-library.sk",
"https://zlibrary-global.se",
]
DEFAULT_WELIB_MIRRORS = [
"https://welib.org",
]
def get_aa_mirrors() -> List[str]:
"""
Get Anna's Archive mirrors from config + defaults.
Returns:
List of AA mirror URLs, starting with defaults then custom additions.
"""
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)
return mirrors
def get_libgen_mirrors() -> List[str]:
"""
Get LibGen mirrors: defaults + any additional from config.
Returns:
List of LibGen mirror URLs (defaults first, then custom additions).
"""
mirrors = list(DEFAULT_LIBGEN_MIRRORS)
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)
return mirrors
def get_zlib_mirrors() -> List[str]:
"""
Get Z-Library mirrors, with primary first.
Returns:
List of Z-Library mirror URLs, primary first.
"""
config = _get_config()
primary = config.get("ZLIB_PRIMARY_URL", DEFAULT_ZLIB_MIRRORS[0])
mirrors = [primary]
# Add other defaults (excluding primary)
for url in DEFAULT_ZLIB_MIRRORS:
if url != primary:
mirrors.append(url)
# 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)
return mirrors
def get_zlib_primary_url() -> str:
"""
Get the primary Z-Library mirror URL.
Returns:
Primary Z-Library mirror URL.
"""
config = _get_config()
return config.get("ZLIB_PRIMARY_URL", DEFAULT_ZLIB_MIRRORS[0])
def get_zlib_url_template() -> str:
"""
Get Z-Library URL template using configured primary mirror.
Returns:
URL template with {md5} placeholder.
"""
primary = get_zlib_primary_url()
return f"{primary}/md5/{{md5}}"
def get_welib_mirrors() -> List[str]:
"""
Get Welib mirrors, with primary first.
Returns:
List of Welib mirror URLs, primary first.
"""
config = _get_config()
primary = config.get("WELIB_PRIMARY_URL", DEFAULT_WELIB_MIRRORS[0])
mirrors = [primary]
# Add other defaults (excluding primary)
for url in DEFAULT_WELIB_MIRRORS:
if url != primary:
mirrors.append(url)
# 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)
return mirrors
def get_welib_primary_url() -> str:
"""
Get the primary Welib mirror URL.
Returns:
Primary Welib mirror URL.
"""
config = _get_config()
return config.get("WELIB_PRIMARY_URL", DEFAULT_WELIB_MIRRORS[0])
def get_welib_url_template() -> str:
"""
Get Welib URL template using configured primary mirror.
Returns:
URL template with {md5} placeholder.
"""
primary = get_welib_primary_url()
return f"{primary}/md5/{{md5}}"
def get_zlib_cookie_domains() -> set:
"""
Get set of Z-Library domains that need full cookie handling.
Used by internal_bypasser for CF bypass cookie management.
Returns:
Set of domain strings (without protocol).
"""
domains = set()
# Add all default domains
for url in DEFAULT_ZLIB_MIRRORS:
domain = url.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]
domains.add(domain)
return domains
@@ -14,17 +14,6 @@ def build_filename(
year: Optional[str] = None,
fmt: Optional[str] = None,
) -> str:
"""Build sanitized filename: 'Author - Title (Year).format'
Args:
title: Book title (required)
author: Book author
year: Publication year
fmt: File format/extension
Returns:
Sanitized filename safe for filesystem use
"""
parts = []
if author:
parts.append(author)
@@ -54,6 +43,11 @@ class QueueStatus(str, Enum):
CANCELLED = "cancelled"
class SearchMode(str, Enum):
DIRECT = "direct"
UNIVERSAL = "universal"
@dataclass
class QueueItem:
"""Queue item with priority and metadata."""
@@ -70,23 +64,30 @@ class QueueItem:
@dataclass
class DownloadTask:
"""Source-agnostic download task for the queue.
This replaces BookInfo in the queue, providing a unified interface
for both Direct Download and Universal modes. The handler uses task_id
to fetch whatever source-specific data it needs internally.
"""
task_id: str # Unique ID (e.g., AA MD5 hash, Prowlarr GUID)
source: str # Handler name ("direct_download", "prowlarr")
title: str # Display title for queue sidebar
# Display info for queue sidebar
author: Optional[str] = None
year: Optional[str] = None
format: Optional[str] = None
size: Optional[str] = None
preview: Optional[str] = None
content_type: Optional[str] = None # "book (fiction)", "audiobook", "magazine", etc.
# Series info (for library naming templates)
series_name: Optional[str] = None
series_position: Optional[float] = None # Float for novellas (e.g., 1.5)
subtitle: Optional[str] = None # Book subtitle for naming templates
# Hardlinking support
original_download_path: Optional[str] = None # Path in download client (for hardlinking)
# Search mode - determines post-download processing behavior
# See SearchMode enum for behavioral differences
search_mode: Optional[SearchMode] = None
# Runtime state
priority: int = 0
added_time: float = field(default_factory=time.time)
@@ -105,7 +106,7 @@ class DownloadTask:
"""Build sanitized filename from task metadata."""
if self.download_path:
return Path(self.download_path).name
return build_filename(self.title, self.author, fmt=self.format)
return build_filename(self.title, self.author, self.year, self.format)
@dataclass
@@ -130,6 +131,7 @@ class BookInfo:
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'
@@ -144,12 +146,14 @@ class BookInfo:
"""
# Resolve format if needed
if not self.format:
for url in (self.download_urls[0] if self.download_urls else None, fallback_url):
if url:
ext = url.split(".")[-1].lower()
if ext and len(ext) <= 5 and ext.isalnum():
self.format = ext
break
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)
+197
View File
@@ -0,0 +1,197 @@
"""Template-based naming for library organization."""
import os
import re
from pathlib import Path
from typing import Dict, Optional, Union
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
)
# Characters that are invalid in filenames on various filesystems
INVALID_CHARS = re.compile(r'[\\:*?"<>|]')
def _sanitize(name: str, max_length: int = 245) -> str:
"""Sanitize a string for filesystem use."""
if not name:
return ""
sanitized = INVALID_CHARS.sub('_', name)
sanitized = re.sub(r'^[\s.]+|[\s.]+$', '', sanitized) # Strip whitespace and dots
sanitized = re.sub(r'_+', '_', sanitized) # Collapse underscores
return sanitized[:max_length]
def sanitize_filename(name: str, max_length: int = 245) -> str:
"""Sanitize a string for use as a filename or path component."""
return _sanitize(name, max_length)
# Alias for backwards compatibility
sanitize_path_component = sanitize_filename
def format_series_position(position: Optional[Union[int, float]]) -> str:
if position is None:
return ""
# Display as integer if whole number
if isinstance(position, float) and position.is_integer():
return str(int(position))
return str(position)
# Pads numbers to 9 digits for natural sorting (e.g., "Part 2" -> "Part 000000002")
PAD_NUMBERS_PATTERN = re.compile(r'\d+')
def natural_sort_key(path: Union[str, Path]) -> str:
"""Generate a sort key with padded numbers for natural sorting."""
filename = Path(path).name.lower()
return PAD_NUMBERS_PATTERN.sub(lambda m: m.group().zfill(9), filename)
def assign_part_numbers(
files: list[Path],
zero_pad_width: int = 2,
) -> list[tuple[Path, str]]:
"""Sort files naturally and assign sequential part numbers (1, 2, 3...)."""
if not files:
return []
sorted_files = sorted(files, key=natural_sort_key)
return [
(file_path, str(part_num).zfill(zero_pad_width))
for part_num, file_path in enumerate(sorted_files, start=1)
]
def parse_naming_template(
template: str,
metadata: Dict[str, Optional[Union[str, int, float]]],
) -> str:
if not template:
return ""
# 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)
# Get the value for this token
value = normalized.get(token_name)
# Special handling for series position
if token_name == 'seriesposition':
value = format_series_position(value)
# Convert to string
if value is None:
value = ""
else:
value = str(value).strip()
# If value is empty, return empty string (no prefix/suffix)
if not value:
return ""
# Sanitize the value
value = sanitize_filename(value)
return f"{prefix}{value}{suffix}"
# Replace all tokens
result = TOKEN_PATTERN.sub(replace_token, template)
# Clean up any double slashes that might result from empty tokens
result = re.sub(r'/+', '/', result)
# Remove leading/trailing slashes
result = result.strip('/')
# Clean up any orphaned separators (e.g., " - " at start/end, or " - - ")
result = re.sub(r'^[\s\-_.]+', '', result)
result = re.sub(r'[\s\-_.]+$', '', result)
result = re.sub(r'(\s*-\s*){2,}', ' - ', result)
# Clean up empty parentheses/brackets
result = re.sub(r'\(\s*\)', '', result)
result = re.sub(r'\[\s*\]', '', result)
# Final trim of any trailing separators left after cleanup
result = re.sub(r'[\s\-_.]+$', '', result)
return result
def build_library_path(
base_path: str,
template: str,
metadata: Dict[str, Optional[Union[str, int, float]]],
extension: Optional[str] = None,
) -> Path:
relative = parse_naming_template(template, metadata)
if not relative:
# Fallback to title if template produces empty result
title = metadata.get('Title') or metadata.get('title') or 'Unknown'
relative = sanitize_filename(str(title))
# Remove any path traversal attempts
relative = relative.replace('..', '')
base = Path(base_path).resolve()
full_path = (base / relative).resolve()
# Verify the path is within the base directory
try:
full_path.relative_to(base)
except ValueError:
raise ValueError(f"Path traversal detected: template would escape library directory")
if extension:
ext = extension.lstrip('.')
# Don't use with_suffix() - it replaces everything after the first dot
# e.g., "2.5 - Title" would become "2.epub" instead of "2.5 - Title.epub"
full_path = Path(f"{full_path}.{ext}")
return full_path
def same_filesystem(path1: Union[str, Path], path2: Union[str, Path]) -> bool:
"""Check if two paths are on the same filesystem."""
path1 = Path(path1)
path2 = Path(path2)
def get_device(p: Path) -> Optional[int]:
try:
while not p.exists():
p = p.parent
if p == p.parent:
break
return os.stat(p).st_dev
except (OSError, PermissionError) as e:
logger.debug(f"Cannot stat {p}: {e}")
return None
dev1 = get_device(path1)
dev2 = get_device(path2)
if dev1 is None or dev2 is None:
logger.warning(f"Cannot determine filesystem for hardlink check, falling back to copy")
return False
return dev1 == dev2
@@ -7,16 +7,12 @@ from pathlib import Path
from threading import Lock, Event
from typing import Dict, List, Optional, Tuple, Any
from cwa_book_downloader.core.config import config as app_config
from cwa_book_downloader.core.models import QueueStatus, QueueItem, DownloadTask
from shelfmark.core.config import config as app_config
from shelfmark.core.models import QueueStatus, QueueItem, DownloadTask
class BookQueue:
"""Thread-safe download queue manager with priority support and cancellation.
Stores DownloadTask objects which are source-agnostic download descriptors.
Works with both Direct Download and Universal modes.
"""
"""Thread-safe download queue manager with priority support and cancellation."""
def __init__(self) -> None:
self._queue: queue.PriorityQueue[QueueItem] = queue.PriorityQueue()
@@ -33,14 +29,7 @@ class BookQueue:
return timedelta(seconds=app_config.get("STATUS_TIMEOUT", 3600))
def add(self, task: DownloadTask) -> bool:
"""Add a download task to the queue.
Args:
task: The download task to queue (includes task_id, priority, etc.)
Returns:
True if added successfully, False if already exists
"""
"""Add a download task to the queue. Returns False if already exists."""
with self._lock:
task_id = task.task_id
@@ -59,11 +48,7 @@ class BookQueue:
return True
def get_next(self) -> Optional[Tuple[str, Event]]:
"""Get next task ID from queue with cancellation flag.
Returns:
Tuple of (task_id, cancel_flag) or None if queue is empty
"""
"""Get next task ID from queue with cancellation flag."""
# Use iterative approach to avoid stack overflow if many items are cancelled
while True:
try:
@@ -85,14 +70,7 @@ class BookQueue:
return None
def get_task(self, task_id: str) -> Optional[DownloadTask]:
"""Get a task by its ID.
Args:
task_id: The task identifier
Returns:
The DownloadTask if found, None otherwise
"""
"""Get a task by its ID."""
with self._lock:
return self._task_data.get(task_id)
@@ -171,14 +149,7 @@ 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.
Args:
task_id: Task identifier to cancel or clear
Returns:
bool: True if cancellation/clearing was successful
"""
"""Cancel a download or clear a completed/errored item."""
with self._lock:
current_status = self._status.get(task_id)
@@ -205,15 +176,7 @@ class BookQueue:
return False
def set_priority(self, task_id: str, new_priority: int) -> bool:
"""Change the priority of a queued task.
Args:
task_id: Task identifier
new_priority: New priority level (lower = higher priority)
Returns:
bool: True if priority was successfully changed
"""
"""Change the priority of a queued task (lower = higher priority)."""
with self._lock:
if task_id not in self._status or self._status[task_id] != QueueStatus.QUEUED:
return False
@@ -245,14 +208,7 @@ class BookQueue:
return found
def reorder_queue(self, task_priorities: Dict[str, int]) -> bool:
"""Bulk reorder queue by setting new priorities.
Args:
task_priorities: Dict mapping task_id to new priority
Returns:
bool: True if reordering was successful
"""
"""Bulk reorder queue by mapping task_id to new priority."""
with self._lock:
# Extract all items from queue
all_items = []
@@ -283,39 +239,18 @@ class BookQueue:
return list(self._active_downloads.keys())
def has_pending_work(self) -> bool:
"""Check if there are any active downloads or queued items.
This is useful for determining if the bypasser should stay active
even when the UI is closed.
Returns:
bool: True if there are active downloads or queued items
"""
"""Check if there are any active downloads or queued items."""
with self._lock:
# Check for active downloads
if self._active_downloads:
return True
# Check for queued items (excluding cancelled ones)
for task_id, status in self._status.items():
if status == QueueStatus.QUEUED:
return True
return False
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.
Returns:
int: Number of tasks removed
"""
"""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 = []
for task_id, status in self._status.items():
if status in [QueueStatus.COMPLETE, QueueStatus.DONE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.CANCELLED]:
to_remove.append(task_id)
to_remove = [task_id for task_id, status in self._status.items() if status in terminal_statuses]
removed_count = len(to_remove)
for task_id in to_remove:
self._status.pop(task_id, None)
self._status_timestamps.pop(task_id, None)
@@ -323,14 +258,13 @@ class BookQueue:
self._cancel_flags.pop(task_id, None)
self._active_downloads.pop(task_id, None)
return removed_count
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}
with self._lock:
current_time = datetime.now()
# Create a list of items to remove to avoid modifying dict during iteration
to_remove = []
for task_id, status in self._status.items():
@@ -338,28 +272,25 @@ class BookQueue:
if not task:
continue
path = task.download_path
if path and not Path(path).exists():
# Clear stale download paths
if task.download_path and not Path(task.download_path).exists():
task.download_path = None
path = None
# Check for completed downloads
if status == QueueStatus.AVAILABLE:
if not path:
self._update_status(task_id, QueueStatus.DONE)
# 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:
if status in [QueueStatus.COMPLETE, QueueStatus.DONE, QueueStatus.ERROR, QueueStatus.AVAILABLE, QueueStatus.CANCELLED]:
if status in terminal_statuses:
to_remove.append(task_id)
# Remove stale entries
for task_id in to_remove:
del self._status[task_id]
del self._status_timestamps[task_id]
if task_id in self._task_data:
del self._task_data[task_id]
self._status.pop(task_id, None)
self._status_timestamps.pop(task_id, None)
self._task_data.pop(task_id, None)
# Global instance of BookQueue
book_queue = BookQueue()
@@ -7,7 +7,7 @@ from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Type, Union
from threading import Lock
from cwa_book_downloader.core.logger import setup_logger
from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
@@ -24,9 +24,10 @@ class FieldBase:
env_supported: bool = True # Whether this setting can be set via ENV var (False = UI-only)
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"}
show_when: Optional[Dict[str, Any]] = None # Conditional visibility: {"field": "key", "value": "expected"} or {"field": "key", "notEmpty": True}
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)
def get_env_var_name(self) -> str:
"""Get the environment variable name for this field."""
@@ -78,25 +79,15 @@ class MultiSelectField(FieldBase):
# 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
default: List[str] = field(default_factory=list)
variant: str = "pills" # "pills" (default) or "dropdown" for checkbox dropdown style
@dataclass
class OrderableListField(FieldBase):
"""
Drag-and-drop reorderable list with enable/disable toggles.
A generic field for any ordered list of items where each item can be
enabled or disabled. Used for source priority, format preference, etc.
Options define the available items:
[{"id": "item1", "label": "Item 1", "description": "...",
"disabledReason": "...", "isLocked": False}, ...]
Value is stored as:
[{"id": "item1", "enabled": True}, {"id": "item2", "enabled": False}, ...]
"""
# Options can be a list or a callable that returns a list (for lazy evaluation)
# Each option: {id, label, description?, disabledReason?, isLocked?}
# Each option: {id, label, description?, disabledReason?, isLocked?, section?, isPinned?}
# - isLocked: toggle is disabled (can't enable/disable)
# - isPinned: can't be reordered (but toggle may still work if not also isLocked)
options: Any = field(default_factory=list)
# Default value: [{id, enabled}, ...] in priority order
default: List[Dict[str, Any]] = field(default_factory=list)
@@ -104,12 +95,6 @@ class OrderableListField(FieldBase):
@dataclass
class ActionButton:
"""
Button that triggers a callback function.
Used for actions like "Test Connection" that execute code
and return success/error status.
"""
key: str # Action identifier
label: str # Button text
description: str = "" # Help text
@@ -117,7 +102,7 @@ class ActionButton:
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"}
show_when: Optional[Dict[str, Any]] = None # Conditional visibility: {"field": "key", "value": "expected"} or {"field": "key", "notEmpty": True}
disabled_when: Optional[Dict[str, Any]] = None # Conditional disable: {"field": "key", "value": "expected", "reason": "..."}
def get_field_type(self) -> str:
@@ -137,7 +122,8 @@ class HeadingField:
description: str = "" # Description text (supports markdown-style links)
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"}
show_when: Optional[Dict[str, Any]] = None # Conditional visibility: {"field": "key", "value": "expected"} or {"field": "key", "notEmpty": True}
universal_only: bool = False # Only show in Universal search mode (hide in Direct mode)
def get_field_type(self) -> str:
return "HeadingField"
@@ -179,20 +165,6 @@ def register_group(
icon: Optional[str] = None,
order: int = 100
) -> None:
"""
Register a settings group.
Groups are collapsible containers for related settings tabs.
Args:
name: Internal name for the group (e.g., "direct_download")
display_name: Display name in UI (e.g., "Direct Download")
icon: Optional icon name for the UI
order: Sort order (lower numbers appear first)
Example:
register_group("direct_download", "Direct Download", icon="download", order=20)
"""
with _REGISTRY_LOCK:
group = SettingsGroup(
name=name,
@@ -211,25 +183,6 @@ def register_settings(
order: int = 100,
group: Optional[str] = None
):
"""
Decorator to register settings for a plugin/module.
The decorated function should return a list of SettingsField objects.
Args:
name: Internal name for the settings tab (e.g., "hardcover")
display_name: Display name in UI (e.g., "Hardcover")
icon: Optional icon name for the UI
order: Sort order (lower numbers appear first)
group: Optional group name this tab belongs to
Example:
@register_settings("hardcover", "Hardcover", icon="book", order=20, group="metadata_providers")
def hardcover_settings():
return [
PasswordField(key="HARDCOVER_API_KEY", label="API Key", required=True),
]
"""
def decorator(func: Callable[[], List[SettingsField]]):
with _REGISTRY_LOCK:
fields = func()
@@ -252,28 +205,6 @@ def register_on_save(
tab_name: str,
handler: Callable[[Dict[str, Any]], Dict[str, Any]]
) -> None:
"""
Register a custom on_save handler for a settings tab.
The handler is called before saving settings and can:
- Validate values (return {"error": True, "message": "..."})
- Transform values (e.g., hash passwords)
- Add computed values
Args:
tab_name: The settings tab name to register the handler for.
handler: Callable that takes values dict and returns:
{"error": bool, "message": str (if error), "values": dict}
Example:
def _on_save_security(values: Dict[str, Any]) -> Dict[str, Any]:
password = values.pop("password", "")
if password:
values["password_hash"] = hash_password(password)
return {"error": False, "values": values}
register_on_save("security", _on_save_security)
"""
with _REGISTRY_LOCK:
_ON_SAVE_HANDLERS[tab_name] = handler
logger.debug(f"Registered on_save handler for tab: {tab_name}")
@@ -301,18 +232,17 @@ def list_registered_settings() -> List[str]:
def _get_config_dir() -> Path:
"""Get the config directory path."""
from cwa_book_downloader.config.env import CONFIG_DIR
from shelfmark.config.env import CONFIG_DIR
return Path(CONFIG_DIR)
def _get_config_file_path(tab_name: str) -> Path:
"""Get the config file path for a settings tab."""
config_dir = _get_config_dir()
if tab_name == "general":
# Core settings tabs share the main settings.json file
if tab_name in ("general", "search_mode"):
return config_dir / "settings.json"
else:
plugins_dir = config_dir / "plugins"
return plugins_dir / f"{tab_name}.json"
return config_dir / "plugins" / f"{tab_name}.json"
def _ensure_config_dir(tab_name: str) -> None:
@@ -322,15 +252,6 @@ def _ensure_config_dir(tab_name: str) -> None:
def load_config_file(tab_name: str) -> Dict[str, Any]:
"""
Load settings from a config file.
Args:
tab_name: The settings tab name.
Returns:
Dict of setting key -> value from config file.
"""
config_path = _get_config_file_path(tab_name)
if not config_path.exists():
@@ -345,16 +266,6 @@ def load_config_file(tab_name: str) -> Dict[str, Any]:
def save_config_file(tab_name: str, values: Dict[str, Any]) -> bool:
"""
Save settings to a config file.
Args:
tab_name: The settings tab name.
values: Dict of setting key -> value to save.
Returns:
True if save succeeded, False otherwise.
"""
try:
_ensure_config_dir(tab_name)
config_path = _get_config_file_path(tab_name)
@@ -374,14 +285,6 @@ def save_config_file(tab_name: str, values: Dict[str, Any]) -> bool:
def sync_env_to_config() -> None:
"""
Sync environment variable values to config files.
This ensures that when ENV vars are set, their values are persisted to config.
When ENV vars are later removed, the config file retains the last known values.
Called once during application startup.
"""
for tab in get_all_settings_tabs():
values_to_sync = {}
@@ -408,20 +311,109 @@ def sync_env_to_config() -> None:
save_config_file(tab.name, values_to_sync)
logger.debug(f"Synced {len(values_to_sync)} ENV values to {tab.name} config: {list(values_to_sync.keys())}")
migrate_legacy_settings()
def migrate_legacy_settings() -> None:
"""Migrate legacy settings to new unified file destination format.
Maps old settings to new:
- PROCESSING_MODE + USE_BOOK_TITLE -> FILE_ORGANIZATION
- INGEST_DIR / LIBRARY_PATH -> DESTINATION
- LIBRARY_TEMPLATE -> TEMPLATE
- USE_CONTENT_TYPE_DIRECTORIES -> AA_CONTENT_TYPE_ROUTING
- INGEST_DIR_* -> AA_CONTENT_TYPE_DIR_*
- TORRENT_HARDLINK -> HARDLINK_TORRENTS / HARDLINK_TORRENTS_AUDIOBOOK
"""
# Load existing downloads config
downloads_config = load_config_file("downloads")
source_config = load_config_file("download_sources")
# Skip migration if already using new settings
if "FILE_ORGANIZATION" in downloads_config or "DESTINATION" in downloads_config:
return
migrated_downloads = {}
migrated_sources = {}
# === BOOKS MIGRATION ===
old_mode = downloads_config.get("PROCESSING_MODE", "ingest")
old_ingest_dir = downloads_config.get("INGEST_DIR", "/cwa-book-ingest")
old_library_path = downloads_config.get("LIBRARY_PATH", "")
old_use_book_title = downloads_config.get("USE_BOOK_TITLE", True)
old_library_template = downloads_config.get("LIBRARY_TEMPLATE", "{Author}/{Title}")
# Map PROCESSING_MODE + USE_BOOK_TITLE -> FILE_ORGANIZATION
if old_mode == "library":
migrated_downloads["FILE_ORGANIZATION"] = "organize"
migrated_downloads["DESTINATION"] = old_library_path or "/books"
migrated_downloads["TEMPLATE"] = old_library_template
else:
if old_use_book_title:
migrated_downloads["FILE_ORGANIZATION"] = "rename"
migrated_downloads["TEMPLATE"] = "{Author} - {Title} ({Year})"
else:
migrated_downloads["FILE_ORGANIZATION"] = "none"
migrated_downloads["DESTINATION"] = old_ingest_dir
# === AUDIOBOOKS MIGRATION ===
old_mode_ab = downloads_config.get("PROCESSING_MODE_AUDIOBOOK", "ingest")
old_ingest_dir_ab = downloads_config.get("INGEST_DIR_AUDIOBOOK", "")
old_library_path_ab = downloads_config.get("LIBRARY_PATH_AUDIOBOOK", "")
old_library_template_ab = downloads_config.get("LIBRARY_TEMPLATE_AUDIOBOOK", "{Author}/{Title}")
if old_mode_ab == "library":
migrated_downloads["FILE_ORGANIZATION_AUDIOBOOK"] = "organize"
migrated_downloads["DESTINATION_AUDIOBOOK"] = old_library_path_ab or ""
migrated_downloads["TEMPLATE_AUDIOBOOK"] = old_library_template_ab
else:
migrated_downloads["FILE_ORGANIZATION_AUDIOBOOK"] = "rename"
migrated_downloads["TEMPLATE_AUDIOBOOK"] = "{Author} - {Title}"
if old_ingest_dir_ab:
migrated_downloads["DESTINATION_AUDIOBOOK"] = old_ingest_dir_ab
# === HARDLINK MIGRATION ===
old_torrent_hardlink = downloads_config.get("TORRENT_HARDLINK")
if old_torrent_hardlink is not None:
# Books default to False (ingest folder use case)
# Audiobooks default to True (library folder use case)
# But if explicitly set, apply to both
migrated_downloads["HARDLINK_TORRENTS"] = old_torrent_hardlink
migrated_downloads["HARDLINK_TORRENTS_AUDIOBOOK"] = old_torrent_hardlink
# === CONTENT-TYPE ROUTING MIGRATION ===
old_use_content_type = downloads_config.get("USE_CONTENT_TYPE_DIRECTORIES", False)
if old_use_content_type:
migrated_sources["AA_CONTENT_TYPE_ROUTING"] = True
# Map old keys to new keys
content_type_mapping = {
"INGEST_DIR_BOOK_FICTION": "AA_CONTENT_TYPE_DIR_FICTION",
"INGEST_DIR_BOOK_NON_FICTION": "AA_CONTENT_TYPE_DIR_NON_FICTION",
"INGEST_DIR_BOOK_UNKNOWN": "AA_CONTENT_TYPE_DIR_UNKNOWN",
"INGEST_DIR_MAGAZINE": "AA_CONTENT_TYPE_DIR_MAGAZINE",
"INGEST_DIR_COMIC_BOOK": "AA_CONTENT_TYPE_DIR_COMIC",
"INGEST_DIR_STANDARDS_DOCUMENT": "AA_CONTENT_TYPE_DIR_STANDARDS",
"INGEST_DIR_MUSICAL_SCORE": "AA_CONTENT_TYPE_DIR_MUSICAL_SCORE",
"INGEST_DIR_OTHER": "AA_CONTENT_TYPE_DIR_OTHER",
}
for old_key, new_key in content_type_mapping.items():
old_value = downloads_config.get(old_key, "")
if old_value:
migrated_sources[new_key] = old_value
# Save migrated settings
if migrated_downloads:
save_config_file("downloads", migrated_downloads)
logger.info(f"Migrated download settings: {list(migrated_downloads.keys())}")
if migrated_sources:
save_config_file("download_sources", migrated_sources)
logger.info(f"Migrated content-type routing settings: {list(migrated_sources.keys())}")
def get_setting_value(field: SettingsField, tab_name: str) -> Any:
"""
Get the current value for a settings field.
Priority: env var > config file > default
Args:
field: The settings field.
tab_name: The settings tab name (for config file lookup).
Returns:
The resolved value.
"""
if isinstance(field, (ActionButton, HeadingField)):
return None # Actions and headings don't have values
@@ -470,12 +462,9 @@ def is_value_from_env(field: SettingsField) -> bool:
if isinstance(field, (ActionButton, HeadingField)):
return False
# UI-only settings never come from ENV (env_supported=False)
# Default to True for backwards compatibility
env_supported = getattr(field, 'env_supported', True)
if env_supported is False:
if not getattr(field, 'env_supported', True):
return False
env_var_name = field.get_env_var_name()
return env_var_name in os.environ
return field.get_env_var_name() in os.environ
def serialize_field(field: SettingsField, tab_name: str, include_value: bool = True) -> Dict[str, Any]:
@@ -503,6 +492,8 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T
result["linkText"] = field.link_text or field.link_url
if field.show_when:
result["showWhen"] = field.show_when
if field.universal_only:
result["universalOnly"] = True
return result
result = {
@@ -516,15 +507,13 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T
"requiresRestart": getattr(field, 'requires_restart', False),
}
# Add conditional visibility if specified
show_when = getattr(field, 'show_when', None)
if show_when:
result["showWhen"] = show_when
# Add conditional disable if specified
disabled_when = getattr(field, 'disabled_when', None)
if disabled_when:
result["disabledWhen"] = disabled_when
# Add optional properties if set
if getattr(field, 'show_when', None):
result["showWhen"] = field.show_when
if getattr(field, 'disabled_when', None):
result["disabledWhen"] = field.disabled_when
if getattr(field, 'universal_only', False):
result["universalOnly"] = True
# Add type-specific properties
if isinstance(field, TextField):
@@ -537,10 +526,17 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T
result["min"] = field.min_value
result["max"] = field.max_value
result["step"] = field.step
elif isinstance(field, (SelectField, MultiSelectField)):
elif isinstance(field, SelectField):
# Support callable options for lazy evaluation (avoids circular imports)
options = field.options() if callable(field.options) else field.options
result["options"] = options
if field.default is not None:
result["default"] = field.default
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, OrderableListField):
# Support callable options for lazy evaluation (avoids circular imports)
options = field.options() if callable(field.options) else field.options
@@ -640,7 +636,7 @@ def _sync_metadata_provider_selection() -> None:
the first enabled provider if the current selection is invalid.
"""
try:
from cwa_book_downloader.metadata_providers import sync_metadata_provider_selection
from shelfmark.metadata_providers import sync_metadata_provider_selection
sync_metadata_provider_selection()
except ImportError:
pass # Metadata providers module not available
@@ -654,7 +650,7 @@ def _apply_dns_settings(config) -> None:
a container restart.
"""
try:
from cwa_book_downloader.download import network
from shelfmark.download import network
provider = config.get("CUSTOM_DNS", "auto")
use_doh = config.get("USE_DOH", False)
@@ -674,19 +670,6 @@ def _apply_dns_settings(config) -> None:
def update_settings(tab_name: str, values: Dict[str, Any]) -> Dict[str, Any]:
"""
Update settings for a tab.
Only updates values that are not set via environment variables.
Args:
tab_name: The settings tab name.
values: Dict of key -> value to update.
Returns:
Dict with "success" (bool), "message" (str), "updated" (list of keys),
and "requiresRestart" (bool) indicating if any changed setting requires restart.
"""
tab = get_settings_tab(tab_name)
if not tab:
return {"success": False, "message": f"Unknown settings tab: {tab_name}", "updated": [], "requiresRestart": False}
@@ -753,7 +736,7 @@ def update_settings(tab_name: str, values: Dict[str, Any]) -> Dict[str, Any]:
if save_config_file(tab_name, values_to_save):
# Refresh the config singleton so live settings take effect immediately
try:
from cwa_book_downloader.core.config import config
from shelfmark.core.config import config
config.refresh()
except ImportError:
pass # Config module not yet available during initial setup
+127
View File
@@ -0,0 +1,127 @@
"""Shared utility functions for the Shelfmark."""
import base64
from pathlib import Path
from typing import Optional
def is_audiobook(content_type: Optional[str]) -> bool:
"""Check if content type indicates an audiobook."""
return bool(content_type and "audiobook" in content_type.lower())
CONTENT_TYPES = [
"book (fiction)",
"book (non-fiction)",
"book (unknown)",
"magazine",
"comic book",
"audiobook",
"standards document",
"musical score",
"other",
]
# Maps AA content types to their config keys for content-type routing
# Used when AA_CONTENT_TYPE_ROUTING is enabled
_AA_CONTENT_TYPE_TO_CONFIG_KEY = {
"book (fiction)": "AA_CONTENT_TYPE_DIR_FICTION",
"book (non-fiction)": "AA_CONTENT_TYPE_DIR_NON_FICTION",
"book (unknown)": "AA_CONTENT_TYPE_DIR_UNKNOWN",
"magazine": "AA_CONTENT_TYPE_DIR_MAGAZINE",
"comic book": "AA_CONTENT_TYPE_DIR_COMIC",
"audiobook": "AA_CONTENT_TYPE_DIR_AUDIOBOOK",
"standards document": "AA_CONTENT_TYPE_DIR_STANDARDS",
"musical score": "AA_CONTENT_TYPE_DIR_MUSICAL_SCORE",
"other": "AA_CONTENT_TYPE_DIR_OTHER",
}
# Legacy mapping - kept for backwards compatibility during migration
_LEGACY_CONTENT_TYPE_TO_CONFIG_KEY = {
"book (fiction)": "INGEST_DIR_BOOK_FICTION",
"book (non-fiction)": "INGEST_DIR_BOOK_NON_FICTION",
"book (unknown)": "INGEST_DIR_BOOK_UNKNOWN",
"magazine": "INGEST_DIR_MAGAZINE",
"comic book": "INGEST_DIR_COMIC_BOOK",
"audiobook": "INGEST_DIR_AUDIOBOOK",
"standards document": "INGEST_DIR_STANDARDS_DOCUMENT",
"musical score": "INGEST_DIR_MUSICAL_SCORE",
"other": "INGEST_DIR_OTHER",
}
def get_destination(is_audiobook: bool = False) -> 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", "")
if audiobook_dest:
return Path(audiobook_dest)
# 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)
def get_aa_content_type_dir(content_type: Optional[str] = None) -> Optional[Path]:
"""Get override directory for AA content-type routing if configured."""
from shelfmark.core.config import config
# Check if content-type routing is enabled (new or legacy setting)
if not config.get("AA_CONTENT_TYPE_ROUTING", False) and not config.get("USE_CONTENT_TYPE_DIRECTORIES", False):
return None
if not content_type:
return None
content_type_lower = content_type.lower().strip()
# Try new AA-specific config keys first, then legacy keys
for mapping in (_AA_CONTENT_TYPE_TO_CONFIG_KEY, _LEGACY_CONTENT_TYPE_TO_CONFIG_KEY):
config_key = mapping.get(content_type_lower)
if config_key:
custom_dir = config.get(config_key, "")
if custom_dir:
return Path(custom_dir)
return None
def get_ingest_dir(content_type: Optional[str] = None) -> Path:
"""DEPRECATED: Use get_destination() and get_aa_content_type_dir() instead."""
from shelfmark.core.config import config
# Check new DESTINATION setting first, then legacy INGEST_DIR
default_ingest_dir = Path(config.get("DESTINATION", "") or config.get("INGEST_DIR", "/books"))
if not content_type:
return default_ingest_dir
# Check for content-type override
override_dir = get_aa_content_type_dir(content_type)
if override_dir:
return override_dir
return default_ingest_dir
def transform_cover_url(cover_url: Optional[str], cache_id: str) -> Optional[str]:
"""Transform external cover URL to local proxy URL when caching is enabled."""
if not cover_url:
return cover_url
# Skip if already a local URL (starts with /)
if cover_url.startswith('/'):
return cover_url
# Check if cover caching is enabled
from shelfmark.config.env import is_covers_cache_enabled
if not is_covers_cache_enabled():
return cover_url
# Encode the original URL and create a proxy URL
encoded_url = base64.urlsafe_b64encode(cover_url.encode()).decode()
return f"/api/covers/{cache_id}?url={encoded_url}"
+449
View File
@@ -0,0 +1,449 @@
"""Archive extraction utilities for downloaded book archives."""
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.core.utils import is_audiobook as check_audiobook
from shelfmark.download.fs import atomic_write, atomic_move
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
RAR_AVAILABLE = True
except ImportError:
RAR_AVAILABLE = False
logger.warning("rarfile not installed - RAR extraction disabled")
class ArchiveExtractionError(Exception):
"""Raised when archive extraction fails."""
pass
class PasswordProtectedError(ArchiveExtractionError):
"""Raised when archive requires a password."""
pass
class CorruptedArchiveError(ArchiveExtractionError):
"""Raised when archive is corrupted."""
pass
def is_archive(file_path: Path) -> bool:
"""Check if file is a supported archive format."""
suffix = file_path.suffix.lower().lstrip(".")
return suffix in ("zip", "rar")
def _is_supported_file(file_path: Path, content_type: Optional[str] = None) -> bool:
"""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()
else:
supported_formats = _get_supported_formats()
return ext in supported_formats
# All known ebook extensions (superset of what user might enable)
ALL_EBOOK_EXTENSIONS = {'.pdf', '.epub', '.mobi', '.azw', '.azw3', '.fb2', '.djvu', '.cbz', '.cbr', '.doc', '.docx', '.rtf', '.txt'}
# All known audio extensions (superset of what user might enable for audiobooks)
ALL_AUDIO_EXTENSIONS = {'.m4b', '.mp3', '.m4a', '.aac', '.flac', '.ogg', '.wma', '.wav', '.opus'}
def _filter_files(
extracted_files: List[Path],
content_type: Optional[str] = None,
) -> Tuple[List[Path], List[Path], List[Path]]:
"""Filter files by content type. Returns (matched, rejected_format, other)."""
is_audiobook = check_audiobook(content_type)
known_extensions = ALL_AUDIO_EXTENSIONS if is_audiobook else ALL_EBOOK_EXTENSIONS
matched_files = []
rejected_format_files = []
other_files = []
for file_path in extracted_files:
if _is_supported_file(file_path, content_type):
matched_files.append(file_path)
elif file_path.suffix.lower() in known_extensions:
rejected_format_files.append(file_path)
else:
other_files.append(file_path)
return matched_files, rejected_format_files, other_files
def extract_archive(
archive_path: Path,
output_dir: Path,
content_type: Optional[str] = None,
) -> Tuple[List[Path], List[str], List[Path]]:
"""Extract archive and filter by content type. Returns (matched, warnings, rejected)."""
suffix = archive_path.suffix.lower().lstrip(".")
if suffix == "zip":
extracted_files, warnings = _extract_zip(archive_path, output_dir)
elif suffix == "rar":
extracted_files, warnings = _extract_rar(archive_path, output_dir)
else:
raise ArchiveExtractionError(f"Unsupported archive format: {suffix}")
is_audiobook = check_audiobook(content_type)
file_type_label = "audiobook" if is_audiobook else "book"
# Filter files based on content type
matched_files, rejected_files, other_files = _filter_files(extracted_files, content_type)
# Delete rejected files (valid formats but not enabled by user)
for rejected_file in rejected_files:
try:
rejected_file.unlink()
logger.debug(f"Deleted rejected {file_type_label} file: {rejected_file.name}")
except OSError as e:
logger.warning(f"Failed to delete rejected {file_type_label} file {rejected_file}: {e}")
if rejected_files:
rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files))
warnings.append(f"Skipped {len(rejected_files)} {file_type_label}(s) with unsupported format: {', '.join(rejected_exts)}")
# Delete other files (images, html, etc)
for other_file in other_files:
try:
other_file.unlink()
logger.debug(f"Deleted non-{file_type_label} file: {other_file.name}")
except OSError as e:
logger.warning(f"Failed to delete non-{file_type_label} file {other_file}: {e}")
if other_files:
warnings.append(f"Skipped {len(other_files)} non-{file_type_label} file(s)")
return matched_files, warnings, rejected_files
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 = []
for info in archive.infolist():
if info.is_dir():
continue
# Use only filename, strip directory path (security: prevent path traversal)
filename = Path(info.filename).name
if not filename:
continue
# Security: reject filenames with null bytes or path separators
# Check both / and \ since archives may be created on different OSes
if "\x00" in filename or "/" in filename or "\\" in filename:
logger.warning(f"Skipping suspicious filename in archive: {info.filename!r}")
continue
# Extract to output_dir with flat structure
target_path = output_dir / filename
# Security: verify resolved path stays within output directory (defense-in-depth)
try:
target_path.resolve().relative_to(output_dir.resolve())
except ValueError:
logger.warning(f"Path traversal attempt blocked: {info.filename!r}")
continue
with archive.open(info) as src:
data = src.read()
final_path = atomic_write(target_path, data)
extracted_files.append(final_path)
logger.debug(f"Extracted: {filename}")
return extracted_files
def _extract_zip(archive_path: Path, output_dir: Path) -> Tuple[List[Path], List[str]]:
"""Extract files from a ZIP archive."""
try:
with zipfile.ZipFile(archive_path, "r") as zf:
# Check for password protection
for info in zf.infolist():
if info.flag_bits & 0x1: # Encrypted flag
raise PasswordProtectedError("ZIP archive is password protected")
# Test archive integrity
bad_file = zf.testzip()
if bad_file:
raise CorruptedArchiveError(f"Corrupted file in archive: {bad_file}")
return _extract_files_from_archive(zf, output_dir), []
except zipfile.BadZipFile as e:
raise CorruptedArchiveError(f"Invalid or corrupted ZIP: {e}")
except PermissionError as e:
raise ArchiveExtractionError(f"Permission denied: {e}")
def _extract_rar(archive_path: Path, output_dir: Path) -> Tuple[List[Path], List[str]]:
"""Extract files from a RAR archive."""
if not RAR_AVAILABLE:
raise ArchiveExtractionError("RAR extraction not available - rarfile library not installed")
try:
with rarfile.RarFile(archive_path, "r") as rf:
# Check for password protection
if rf.needs_password():
raise PasswordProtectedError("RAR archive is password protected")
# Test archive integrity
rf.testrar()
return _extract_files_from_archive(rf, output_dir), []
except rarfile.BadRarFile as e:
raise CorruptedArchiveError(f"Invalid or corrupted RAR: {e}")
except rarfile.RarCannotExec:
raise ArchiveExtractionError("unrar binary not found - install unrar package")
except PermissionError as e:
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}",
)
+191
View File
@@ -0,0 +1,191 @@
"""Atomic filesystem operations for concurrent-safe file handling.
These utilities handle file collisions atomically, avoiding TOCTOU race conditions
when multiple workers may try to write to the same path simultaneously.
"""
import errno
import os
import shutil
from pathlib import Path
from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
def atomic_write(dest_path: Path, data: bytes, max_attempts: int = 100) -> Path:
"""Write data to a file with atomic collision detection.
If the destination already exists, retries with counter suffix (_1, _2, etc.)
until a unique path is found.
Args:
dest_path: Desired destination path
data: Bytes to write
max_attempts: Maximum collision retries before raising error
Returns:
Path where file was actually written (may differ from dest_path)
Raises:
RuntimeError: If no unique path found after max_attempts
"""
base = dest_path.stem
ext = dest_path.suffix
parent = dest_path.parent
for attempt in range(max_attempts):
try_path = dest_path if attempt == 0 else parent / f"{base}_{attempt}{ext}"
try:
# O_CREAT | O_EXCL fails atomically if file exists
fd = os.open(str(try_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
try:
os.write(fd, data)
finally:
os.close(fd)
if attempt > 0:
logger.info(f"File collision resolved: {try_path.name}")
return try_path
except FileExistsError:
continue
raise RuntimeError(f"Could not write file after {max_attempts} attempts: {dest_path}")
def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) -> Path:
"""Move a file with collision detection.
Uses os.rename() for same-filesystem moves (atomic, triggers inotify events),
falls back to exclusive create + shutil.move for cross-filesystem moves.
Note: We use os.rename() instead of hardlink+unlink because os.rename()
triggers proper inotify IN_MOVED_TO events that file watchers (like Calibre's
auto-add) rely on to detect new files.
Args:
source_path: Source file to move
dest_path: Desired destination path
max_attempts: Maximum collision retries before raising error
Returns:
Path where file was actually moved (may differ from dest_path)
Raises:
RuntimeError: If no unique path found after max_attempts
"""
base = dest_path.stem
ext = dest_path.suffix
parent = dest_path.parent
for attempt in range(max_attempts):
try_path = dest_path if attempt == 0 else parent / f"{base}_{attempt}{ext}"
# Check for existing file (os.rename would overwrite on Unix)
if try_path.exists():
continue
try:
# os.rename is atomic on same filesystem and triggers inotify events
os.rename(str(source_path), str(try_path))
if attempt > 0:
logger.info(f"File collision resolved: {try_path.name}")
return try_path
except FileExistsError:
# Race condition: file created between exists() check and rename()
continue
except OSError as e:
# Cross-filesystem - fall back to exclusive create + move
if e.errno != errno.EXDEV:
raise
try:
fd = os.open(str(try_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.close(fd)
try:
shutil.move(str(source_path), str(try_path))
if attempt > 0:
logger.info(f"File collision resolved: {try_path.name}")
return try_path
except Exception:
try_path.unlink(missing_ok=True)
raise
except FileExistsError:
continue
raise RuntimeError(f"Could not move file after {max_attempts} attempts: {dest_path}")
def atomic_hardlink(source_path: Path, dest_path: Path, max_attempts: int = 100) -> Path:
"""Create a hardlink with atomic collision detection.
Args:
source_path: Source file to link from
dest_path: Desired destination path for the link
max_attempts: Maximum collision retries before raising error
Returns:
Path where link was actually created (may differ from dest_path)
Raises:
RuntimeError: If no unique path found after max_attempts
"""
base = dest_path.stem
ext = dest_path.suffix
parent = dest_path.parent
for attempt in range(max_attempts):
try_path = dest_path if attempt == 0 else parent / f"{base}_{attempt}{ext}"
try:
os.link(str(source_path), str(try_path))
if attempt > 0:
logger.info(f"File collision resolved: {try_path.name}")
return try_path
except FileExistsError:
continue
raise RuntimeError(f"Could not create hardlink after {max_attempts} attempts: {dest_path}")
def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) -> Path:
"""Copy a file with atomic collision detection.
Uses exclusive create to claim destination, then copies via temp file
to avoid partial files on failure.
Args:
source_path: Source file to copy
dest_path: Desired destination path
max_attempts: Maximum collision retries before raising error
Returns:
Path where file was actually copied (may differ from dest_path)
Raises:
RuntimeError: If no unique path found after max_attempts
"""
base = dest_path.stem
ext = dest_path.suffix
parent = dest_path.parent
for attempt in range(max_attempts):
try_path = dest_path if attempt == 0 else parent / f"{base}_{attempt}{ext}"
try:
# Atomically claim the destination by creating an exclusive file
fd = os.open(str(try_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.close(fd)
# Copy to temp file first, then replace to avoid partial files
temp_path = try_path.parent / f".{try_path.name}.tmp"
try:
shutil.copy2(str(source_path), str(temp_path))
temp_path.replace(try_path)
if attempt > 0:
logger.info(f"File collision resolved: {try_path.name}")
return try_path
except Exception:
try_path.unlink(missing_ok=True)
temp_path.unlink(missing_ok=True)
raise
except FileExistsError:
continue
raise RuntimeError(f"Could not copy file after {max_attempts} attempts: {dest_path}")
@@ -10,53 +10,105 @@ from urllib.parse import urlparse
import requests
from tqdm import tqdm
from cwa_book_downloader.download import network
from cwa_book_downloader.config.env import USE_CF_BYPASS, USING_EXTERNAL_BYPASSER
from cwa_book_downloader.core.config import config as app_config
from cwa_book_downloader.core.logger import setup_logger
# Import bypasser if enabled
if USE_CF_BYPASS:
if USING_EXTERNAL_BYPASSER:
from cwa_book_downloader.bypass.external_bypasser import get_bypassed_page
# External bypasser doesn't share cookies/UA
get_cf_cookies_for_domain = lambda domain: {}
get_cf_user_agent_for_domain = lambda domain: None
else:
from cwa_book_downloader.bypass.internal_bypasser import get_bypassed_page, get_cf_cookies_for_domain, get_cf_user_agent_for_domain
from shelfmark.download import network
from shelfmark.download.network import get_proxies
from shelfmark.core.config import config as app_config
from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
# Bypasser modules are imported lazily to support dynamic selection based on config
_internal_bypasser = None
_external_bypasser = None
def _get_internal_bypasser():
"""Lazy import of internal bypasser module."""
global _internal_bypasser
if _internal_bypasser is None:
try:
from shelfmark.bypass import internal_bypasser
_internal_bypasser = internal_bypasser
except ImportError as e:
raise RuntimeError(
f"Failed to import internal bypasser: {e}. "
"Check that all dependencies are installed. "
"You may need to disable CF bypass or use the external bypasser."
) from e
return _internal_bypasser
def _get_external_bypasser():
"""Lazy import of external bypasser module."""
global _external_bypasser
if _external_bypasser is None:
try:
from shelfmark.bypass import external_bypasser
_external_bypasser = external_bypasser
except ImportError as e:
raise RuntimeError(
f"Failed to import external bypasser: {e}. "
"Check that the external bypasser is properly configured."
) from e
return _external_bypasser
def _is_using_external_bypasser() -> bool:
"""Check if external bypasser is configured (reads from config, not just env)."""
return app_config.get("USING_EXTERNAL_BYPASSER", False)
def _is_cf_bypass_enabled() -> bool:
"""Check if Cloudflare bypass is enabled."""
return app_config.get("USE_CF_BYPASS", True)
def get_bypassed_page(url, selector=None, cancel_flag=None):
"""Wrapper that delegates to the appropriate bypasser based on config."""
if _is_using_external_bypasser():
return _get_external_bypasser().get_bypassed_page(url, selector, cancel_flag)
return _get_internal_bypasser().get_bypassed_page(url, selector, cancel_flag)
def get_cf_cookies_for_domain(domain):
"""Get CF cookies - only available with internal bypasser."""
if _is_using_external_bypasser():
logger.debug(f"External bypasser in use, CF cookies not available for {domain}")
return {}
return _get_internal_bypasser().get_cf_cookies_for_domain(domain)
def get_cf_user_agent_for_domain(domain):
"""Get CF user agent - only available with internal bypasser."""
if _is_using_external_bypasser():
logger.debug(f"External bypasser in use, CF user agent not available for {domain}")
return None
return _get_internal_bypasser().get_cf_user_agent_for_domain(domain)
def _apply_cf_bypass(url: str, headers: dict) -> dict:
"""Apply CF bypass cookies and user agent if available.
Modifies headers in-place with the stored user agent (if available).
Returns cookies dict to use with the request.
"""
if not _is_cf_bypass_enabled():
return {}
parsed = urlparse(url)
hostname = parsed.hostname or ""
cookies = get_cf_cookies_for_domain(hostname)
stored_ua = get_cf_user_agent_for_domain(hostname)
if stored_ua:
headers['User-Agent'] = stored_ua
return cookies
# Network settings
REQUEST_TIMEOUT = (5, 10) # (connect, read)
MAX_DOWNLOAD_RETRIES = 2
MAX_RESUME_ATTEMPTS = 3
def _get_proxies() -> dict:
"""Get current proxy configuration from config singleton."""
proxy_mode = app_config.get("PROXY_MODE", "none")
if proxy_mode == "socks5":
socks_proxy = app_config.get("SOCKS5_PROXY", "")
if socks_proxy:
return {"http": socks_proxy, "https": socks_proxy}
elif proxy_mode == "http":
proxies = {}
http_proxy = app_config.get("HTTP_PROXY", "")
https_proxy = app_config.get("HTTPS_PROXY", "")
if http_proxy:
proxies["http"] = http_proxy
if https_proxy:
proxies["https"] = https_proxy
elif http_proxy:
# Fallback: use HTTP proxy for HTTPS if HTTPS proxy not specified
proxies["https"] = http_proxy
return proxies
return {}
RETRYABLE_CODES = (429, 500, 502, 503, 504)
CONNECTION_ERRORS = (requests.exceptions.ConnectionError, requests.exceptions.Timeout,
requests.exceptions.SSLError, requests.exceptions.ChunkedEncodingError)
@@ -64,6 +116,9 @@ DOWNLOAD_HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
}
@@ -97,7 +152,7 @@ def _is_retryable_error(e: Exception) -> bool:
if isinstance(e, CONNECTION_ERRORS):
return True
status = _get_status_code(e)
return status in RETRYABLE_CODES if status else False
return status is not None and status in RETRYABLE_CODES
def _try_rotation(original_url: str, current_url: str, selector: network.AAMirrorSelector) -> Optional[str]:
@@ -120,6 +175,7 @@ def html_get_page(
use_bypasser: bool = False,
selector: Optional[network.AAMirrorSelector] = None,
cancel_flag: Optional[Event] = None,
status_callback: Optional[Callable[[str, Optional[str]], None]] = None,
) -> str:
"""Fetch HTML content from a URL with retry mechanism."""
retry = retry if retry is not None else app_config.MAX_RETRY
@@ -135,8 +191,10 @@ def html_get_page(
return ""
try:
if use_bypasser_now and USE_CF_BYPASS:
logger.info(f"GET (bypasser): {current_url}")
if use_bypasser_now and _is_cf_bypass_enabled():
logger.debug(f"GET (bypasser): {current_url}")
if status_callback:
status_callback("resolving", "Bypassing protection")
try:
result = get_bypassed_page(current_url, selector, cancel_flag)
return result or ""
@@ -144,18 +202,11 @@ def html_get_page(
logger.warning(f"Bypasser error: {type(e).__name__}: {e}")
return ""
logger.info(f"GET: {current_url}")
logger.debug(f"GET: {current_url}")
# Try with CF cookies/UA if available (from previous bypass)
cookies = {}
headers = {}
if USE_CF_BYPASS:
parsed = urlparse(current_url)
hostname = parsed.hostname or ""
cookies = get_cf_cookies_for_domain(hostname)
stored_ua = get_cf_user_agent_for_domain(hostname)
if stored_ua:
headers['User-Agent'] = stored_ua
response = requests.get(current_url, proxies=_get_proxies(), timeout=REQUEST_TIMEOUT, cookies=cookies, headers=headers)
cookies = _apply_cf_bypass(current_url, headers)
response = requests.get(current_url, proxies=get_proxies(), timeout=REQUEST_TIMEOUT, cookies=cookies, headers=headers)
response.raise_for_status()
time.sleep(1)
return response.text
@@ -165,7 +216,7 @@ def html_get_page(
# 403 = Cloudflare/DDoS-Guard protection
if status == 403:
if USE_CF_BYPASS and not use_bypasser_now:
if _is_cf_bypass_enabled() and not use_bypasser_now:
# Before switching to bypasser, check if cookies have become available
# (another concurrent download may have completed bypass and extracted cookies)
parsed = urlparse(current_url)
@@ -175,6 +226,8 @@ def html_get_page(
logger.debug(f"403 but cookies now available - retrying with cookies: {current_url}")
continue
logger.info(f"403 detected; switching to bypasser: {current_url}")
if status_callback:
status_callback("resolving", "Bypassing protection...")
use_bypasser_now = True
continue
logger.warning(f"403 error, giving up: {current_url}")
@@ -237,21 +290,8 @@ def download_url(
logger.info(f"Downloading: {current_url} (attempt {attempt + 1}/{MAX_DOWNLOAD_RETRIES})")
# Try with CF cookies/UA if available
cookies = {}
if USE_CF_BYPASS:
parsed = urlparse(current_url)
hostname = parsed.hostname or ""
cookies = get_cf_cookies_for_domain(hostname)
# Use stored UA - Cloudflare ties cf_clearance to the UA that solved the challenge
stored_ua = get_cf_user_agent_for_domain(hostname)
if stored_ua:
headers['User-Agent'] = stored_ua
logger.debug(f"Using stored UA for {hostname}")
else:
logger.debug(f"No stored UA available for {hostname}")
if cookies:
logger.debug(f"Using {len(cookies)} cookies for {hostname}: {list(cookies.keys())}")
response = requests.get(current_url, stream=True, proxies=_get_proxies(), timeout=REQUEST_TIMEOUT, cookies=cookies, headers=headers)
cookies = _apply_cf_bypass(current_url, headers)
response = requests.get(current_url, stream=True, proxies=get_proxies(), timeout=REQUEST_TIMEOUT, cookies=cookies, headers=headers)
response.raise_for_status()
if status_callback:
@@ -286,7 +326,7 @@ def download_url(
retryable = _is_retryable_error(e)
# Z-Library 403 - try refreshing cookies via bypasser once before giving up
if status == 403 and USE_CF_BYPASS and not zlib_cookie_refresh_attempted:
if status == 403 and _is_cf_bypass_enabled() and not zlib_cookie_refresh_attempted:
parsed = urlparse(current_url)
if parsed.hostname and 'z-lib' in parsed.hostname and referer:
zlib_cookie_refresh_attempted = True
@@ -309,14 +349,14 @@ def download_url(
if status == 429:
logger.info(f"Rate limited (429) - trying next source")
if status_callback:
status_callback("resolving", "Server busy, trying next...")
status_callback("resolving", "Server busy, trying next")
return None
# Timeout - don't retry, server likely overloaded
if isinstance(e, requests.exceptions.Timeout):
logger.warning(f"Timeout: {current_url} - skipping to next source")
if status_callback:
status_callback("resolving", "Server timed out, trying next...")
status_callback("resolving", "Server timed out, trying next")
return None
# Try to resume if we got some data
@@ -358,17 +398,10 @@ def _try_resume(
try:
# Try with CF cookies/UA if available
cookies = {}
resume_headers = {**(base_headers or DOWNLOAD_HEADERS), 'Range': f'bytes={start_byte}-'}
if USE_CF_BYPASS:
parsed = urlparse(url)
hostname = parsed.hostname or ""
cookies = get_cf_cookies_for_domain(hostname)
stored_ua = get_cf_user_agent_for_domain(hostname)
if stored_ua:
resume_headers['User-Agent'] = stored_ua
cookies = _apply_cf_bypass(url, resume_headers)
response = requests.get(
url, stream=True, proxies=_get_proxies(), timeout=REQUEST_TIMEOUT,
url, stream=True, proxies=get_proxies(), timeout=REQUEST_TIMEOUT,
headers=resume_headers, cookies=cookies
)
@@ -1,6 +1,5 @@
"""DNS rotation, mirror selection, and network utilities."""
import os
import requests
import urllib.request
from typing import Sequence, Tuple, Any, Union, cast, List, Optional, Callable
@@ -8,17 +7,14 @@ import socket
import dns.resolver
from socket import AddressFamily, SocketKind
import urllib.parse
import ssl
import ipaddress
from cwa_book_downloader.core.logger import setup_logger
from cwa_book_downloader.config.settings import AA_BASE_URL, AA_AVAILABLE_URLS
from cwa_book_downloader.config import settings as config
from cwa_book_downloader.core.config import config as app_config
from shelfmark.core.logger import setup_logger
from shelfmark.core.config import config as app_config
from datetime import datetime, timedelta
def _get_proxies() -> dict:
def get_proxies() -> dict:
"""Get current proxy configuration from config singleton."""
proxy_mode = app_config.get("PROXY_MODE", "none")
@@ -107,13 +103,6 @@ def _notify_dns_rotation(provider_name: str, servers: List[str], doh_url: str) -
except Exception as e:
logger.warning(f"DNS rotation callback {callback.__name__} failed: {e}")
def _agent_debug_log(code: str, source: str, reason: str, meta: Optional[dict] = None) -> None:
"""Lightweight debug hook for automated runs; safe no-op on failure."""
try:
logger.debug(f"[agent] code={code} source={source} reason={reason} meta={meta or {}}")
except Exception as exc:
# Avoid raising inside debug logger
logger.debug(f"[agent] log failed: {exc}")
def _load_state():
"""Return current in-memory network state (no disk persistence)."""
@@ -133,7 +122,8 @@ def _save_state(aa_url=None, dns_provider=None):
# AA URL failover state
_current_aa_url_index = 0
_aa_urls = AA_AVAILABLE_URLS.copy()
_aa_urls: List[str] = [] # Initialized lazily in _initialize_aa_state()
_aa_base_url: str = "" # Current active AA URL
def _ensure_initialized() -> None:
"""Lazy guard so runtime setup happens once and late calls still work."""
@@ -234,34 +224,17 @@ def _decode_port(port: Union[str, bytes, int, None]) -> int:
"""Convert port to integer, handling various input types."""
if port is None:
return 0
if isinstance(port, (str, bytes)):
return int(port)
return int(port)
def _is_local_address(host_str: str) -> bool:
"""Check if an address is local or private and should bypass custom DNS."""
# Localhost checks
if (host_str == 'localhost' or
host_str.startswith('127.') or
host_str == '::1' or
host_str == '0.0.0.0'):
if host_str == 'localhost':
return True
# IPv4 private ranges (RFC 1918)
if (host_str.startswith('10.') or
(host_str.startswith('172.') and
len(host_str.split('.')) > 1 and
16 <= int(host_str.split('.')[1]) <= 31) or
host_str.startswith('192.168.')):
return True
# IPv6 private ranges
if (host_str.startswith('fc') or
host_str.startswith('fd') or # Unique local addresses (fc00::/7)
host_str.startswith('fe80:')): # Link-local addresses (fe80::/10)
return True
return False
try:
addr = ipaddress.ip_address(host_str)
return addr.is_private or addr.is_loopback or addr.is_link_local
except ValueError:
return False
def _is_ip_address(host_str: str) -> bool:
"""Check if a string is a valid IP address (IPv4 or IPv6)."""
@@ -367,7 +340,7 @@ class DoHResolver:
response = self.session.get(
self.base_url,
params=params,
proxies=_get_proxies(),
proxies=get_proxies(),
timeout=10 # Increased from 5s to handle slow network conditions
)
response.raise_for_status()
@@ -642,8 +615,8 @@ def switch_dns_provider() -> bool:
name, servers, doh = DNS_PROVIDERS[_current_dns_index]
CUSTOM_DNS = servers
DOH_SERVER = doh
config.CUSTOM_DNS = servers
config.DOH_SERVER = doh
app_config.CUSTOM_DNS = servers
app_config.DOH_SERVER = doh
logger.warning(f"Switched DNS provider to: {name} (using DoH)")
_save_state(dns_provider=name)
@@ -672,20 +645,20 @@ def rotate_dns_and_reset_aa() -> bool:
"""
Switch DNS provider (auto mode) and reset AA URL list to the first entry.
Returns True if DNS switched; False if no providers left or not in auto mode.
Note: This function can be called during initialization, so we must NOT call
_ensure_initialized() here to avoid recursive init loops.
"""
if not rotate_dns_provider():
return False
# Reset AA URL to first available auto option if using auto AA
global AA_BASE_URL, _current_aa_url_index
if AA_BASE_URL == "auto" or AA_BASE_URL in _aa_urls:
global _aa_base_url, _current_aa_url_index
configured_url = app_config.get("AA_BASE_URL", "auto")
if configured_url == "auto" or _aa_base_url in _aa_urls:
_current_aa_url_index = 0
AA_BASE_URL = _aa_urls[0]
config.AA_BASE_URL = AA_BASE_URL
logger.info(f"After DNS switch, resetting AA URL to: {AA_BASE_URL}")
_save_state(aa_url=AA_BASE_URL)
_aa_base_url = _aa_urls[0] if _aa_urls else "https://annas-archive.se"
logger.info(f"After DNS switch, resetting AA URL to: {_aa_base_url}")
_save_state(aa_url=_aa_base_url)
return True
def set_dns_provider(provider: str, manual_servers: list[str] | None = None, use_doh: bool | None = None) -> bool:
@@ -715,8 +688,8 @@ def set_dns_provider(provider: str, manual_servers: list[str] | None = None, use
_dns_exhausted_logged = False
CUSTOM_DNS = []
DOH_SERVER = ""
config.CUSTOM_DNS = []
config.DOH_SERVER = ""
app_config.CUSTOM_DNS = []
app_config.DOH_SERVER = ""
# Restore original system getaddrinfo
socket.getaddrinfo = original_getaddrinfo
logger.info("DNS set to system mode (using OS default resolver)")
@@ -730,8 +703,8 @@ def set_dns_provider(provider: str, manual_servers: list[str] | None = None, use
_dns_exhausted_logged = False
CUSTOM_DNS = []
DOH_SERVER = ""
config.CUSTOM_DNS = []
config.DOH_SERVER = ""
app_config.CUSTOM_DNS = []
app_config.DOH_SERVER = ""
logger.info("DNS set to auto mode (system DNS, will rotate on failure with DoH)")
init_dns_resolvers()
_notify_dns_rotation("auto", [], "")
@@ -744,8 +717,8 @@ def set_dns_provider(provider: str, manual_servers: list[str] | None = None, use
_current_dns_index = -1 # Not using preset providers
CUSTOM_DNS = manual_servers
DOH_SERVER = "" # No DoH for manual servers
config.CUSTOM_DNS = manual_servers
config.DOH_SERVER = ""
app_config.CUSTOM_DNS = manual_servers
app_config.DOH_SERVER = ""
logger.info(f"DNS set to manual servers: {manual_servers}")
init_dns_resolvers()
_notify_dns_rotation("manual", manual_servers, "")
@@ -759,8 +732,8 @@ def set_dns_provider(provider: str, manual_servers: list[str] | None = None, use
CUSTOM_DNS = servers
# Only set DoH server if DoH is enabled
DOH_SERVER = doh if doh_enabled else ""
config.CUSTOM_DNS = servers
config.DOH_SERVER = DOH_SERVER
app_config.CUSTOM_DNS = servers
app_config.DOH_SERVER = DOH_SERVER
doh_status = "DoH enabled" if doh_enabled else "standard DNS"
logger.info(f"DNS set to: {name} ({doh_status})")
_save_state(dns_provider=name)
@@ -781,15 +754,15 @@ def init_dns_resolvers():
name, servers, doh = DNS_PROVIDERS[_current_dns_index]
CUSTOM_DNS = servers
DOH_SERVER = doh
config.CUSTOM_DNS = servers
config.DOH_SERVER = doh
app_config.CUSTOM_DNS = servers
app_config.DOH_SERVER = doh
logger.info(f"Using DNS provider: {name} (DoH enabled)")
else:
CUSTOM_DNS = []
DOH_SERVER = ""
config.CUSTOM_DNS = []
config.DOH_SERVER = ""
logger.info("Using system DNS (auto mode - will switch on failure)")
app_config.CUSTOM_DNS = []
app_config.DOH_SERVER = ""
logger.debug("Using system DNS (auto mode - will switch on failure)")
socket.getaddrinfo = cast(Any, create_system_failover_getaddrinfo())
return
@@ -837,35 +810,49 @@ def _looks_like_ip(s: str) -> bool:
# Simple heuristic: contains only digits, dots, and colons
return s.replace(".", "").replace(":", "").isdigit()
def _build_aa_urls() -> List[str]:
"""Build list of available AA URLs from centralized mirror config."""
from shelfmark.core.mirrors import get_aa_mirrors
return get_aa_mirrors()
def _initialize_aa_state() -> None:
"""Restore or probe AA URL state."""
global AA_BASE_URL, _current_aa_url_index
if AA_BASE_URL == "auto":
global _aa_base_url, _current_aa_url_index, _aa_urls
# Build URL list from config
_aa_urls = _build_aa_urls()
# Get configured base URL from config
configured_url = app_config.get("AA_BASE_URL", "auto")
if configured_url == "auto":
if state.get('aa_base_url') and state['aa_base_url'] in _aa_urls:
_current_aa_url_index = _aa_urls.index(state['aa_base_url'])
AA_BASE_URL = state['aa_base_url']
_aa_base_url = state['aa_base_url']
else:
logger.info(f"AA_BASE_URL: auto, checking available urls {_aa_urls}")
logger.debug(f"AA_BASE_URL: auto, checking available urls {_aa_urls}")
for i, url in enumerate(_aa_urls):
try:
response = requests.get(url, proxies=_get_proxies(), timeout=3)
response = requests.get(url, proxies=get_proxies(), timeout=3)
if response.status_code == 200:
_current_aa_url_index = i
AA_BASE_URL = url
_save_state(aa_url=AA_BASE_URL)
_aa_base_url = url
_save_state(aa_url=_aa_base_url)
break
except Exception:
pass
if AA_BASE_URL == "auto":
AA_BASE_URL = _aa_urls[0]
if not _aa_base_url or _aa_base_url == "auto":
_aa_base_url = _aa_urls[0]
_current_aa_url_index = 0
elif AA_BASE_URL not in _aa_urls:
logger.info(f"AA_BASE_URL set to custom value {AA_BASE_URL}; skipping auto-switch")
elif configured_url not in _aa_urls:
logger.info(f"AA_BASE_URL set to custom value {configured_url}; skipping auto-switch")
_aa_base_url = configured_url
else:
_current_aa_url_index = _aa_urls.index(AA_BASE_URL)
_current_aa_url_index = _aa_urls.index(configured_url)
_aa_base_url = configured_url
config.AA_BASE_URL = AA_BASE_URL
logger.info(f"AA_BASE_URL: {AA_BASE_URL}")
logger.info(f"AA_BASE_URL: {_aa_base_url}")
def init_dns(force: bool = False) -> None:
"""Initialize DNS state and resolvers using set_dns_provider() for consistency."""
@@ -939,7 +926,7 @@ def init(force: bool = False) -> None:
if _initialized and not force:
return
# Do the work first, then set flag to prevent race conditions
# where another thread sees _initialized=True but AA_BASE_URL is still "auto"
# where another thread sees _initialized=True but _aa_base_url is still empty
try:
init_dns(force=force)
init_aa(force=force)
@@ -952,7 +939,7 @@ def init(force: bool = False) -> None:
def get_aa_base_url():
"""Get current AA base URL."""
_ensure_initialized()
return AA_BASE_URL
return _aa_base_url
def get_available_aa_urls():
"""Get list of configured AA URLs (copy)."""
@@ -962,14 +949,13 @@ def get_available_aa_urls():
def set_aa_url_index(new_index: int) -> bool:
"""Set AA base URL by index in available list; returns True if applied."""
_ensure_initialized()
global AA_BASE_URL, _current_aa_url_index
global _aa_base_url, _current_aa_url_index
if new_index < 0 or new_index >= len(_aa_urls):
return False
_current_aa_url_index = new_index
AA_BASE_URL = _aa_urls[_current_aa_url_index]
config.AA_BASE_URL = AA_BASE_URL
logger.info(f"Set AA URL to: {AA_BASE_URL}")
_save_state(aa_url=AA_BASE_URL)
_aa_base_url = _aa_urls[_current_aa_url_index]
logger.info(f"Set AA URL to: {_aa_base_url}")
_save_state(aa_url=_aa_base_url)
return True
class AAMirrorSelector:
File diff suppressed because it is too large Load Diff
+210 -197
View File
@@ -16,17 +16,17 @@ from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.security import check_password_hash
from werkzeug.wrappers import Response
from cwa_book_downloader.download import orchestrator as backend
from cwa_book_downloader.release_sources.direct_download import SearchUnavailable
from cwa_book_downloader.config.settings import _SUPPORTED_BOOK_LANGUAGE
from cwa_book_downloader.config.env import (
from shelfmark.download import orchestrator as backend
from shelfmark.release_sources.direct_download import SearchUnavailable
from shelfmark.config.settings import _SUPPORTED_BOOK_LANGUAGE
from shelfmark.config.env import (
BUILD_VERSION, CWA_DB_PATH, DEBUG, FLASK_HOST, FLASK_PORT,
RELEASE_VERSION, USING_EXTERNAL_BYPASSER,
RELEASE_VERSION,
)
from cwa_book_downloader.core.config import config as app_config
from cwa_book_downloader.core.logger import setup_logger
from cwa_book_downloader.core.models import SearchFilters
from cwa_book_downloader.api.websocket import ws_manager
from shelfmark.core.config import config as app_config
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import SearchFilters
from shelfmark.api.websocket import ws_manager
logger = setup_logger(__name__)
@@ -71,8 +71,8 @@ logger.info(f"Flask-SocketIO initialized with async_mode='{async_mode}'")
# This prevents a race condition where the download loop could try to process
# a queued task before its handler (e.g., prowlarr) is registered.
try:
import cwa_book_downloader.metadata_providers # noqa: F401
import cwa_book_downloader.release_sources # noqa: F401
import shelfmark.metadata_providers # noqa: F401
import shelfmark.release_sources # noqa: F401
logger.debug("Plugin modules loaded successfully")
except ImportError as e:
logger.warning(f"Failed to import plugin modules: {e}")
@@ -100,35 +100,32 @@ def cleanup_old_lockouts() -> None:
def is_account_locked(username: str) -> bool:
"""Check if an account is currently locked due to failed login attempts."""
cleanup_old_lockouts()
if username not in failed_login_attempts:
return False
lockout_until = failed_login_attempts[username].get('lockout_until')
if lockout_until and datetime.now() < lockout_until:
return True
return False
return lockout_until is not None and datetime.now() < lockout_until
def record_failed_login(username: str, ip_address: str) -> bool:
"""
Record a failed login attempt and lock account if threshold is reached.
"""Record a failed login attempt and lock account if threshold is reached.
Returns True if account is now locked, False otherwise.
"""
if username not in failed_login_attempts:
failed_login_attempts[username] = {'count': 0}
failed_login_attempts[username]['count'] += 1
count = failed_login_attempts[username]['count']
logger.warning(f"Failed login attempt {count}/{MAX_LOGIN_ATTEMPTS} for user '{username}' from IP {ip_address}")
if count >= MAX_LOGIN_ATTEMPTS:
lockout_until = datetime.now() + timedelta(minutes=LOCKOUT_DURATION_MINUTES)
failed_login_attempts[username]['lockout_until'] = lockout_until
logger.warning(f"Account locked for user '{username}' until {lockout_until.strftime('%Y-%m-%d %H:%M:%S')} due to {count} failed login attempts")
return True
return False
def clear_failed_logins(username: str) -> None:
@@ -138,35 +135,36 @@ def clear_failed_logins(username: str) -> None:
logger.debug(f"Cleared failed login attempts for user: {username}")
def get_client_ip() -> str:
"""Extract client IP address from request, handling reverse proxy forwarding."""
ip_address = request.headers.get('X-Forwarded-For', request.remote_addr) or 'unknown'
# X-Forwarded-For can contain multiple IPs, take the first one
if ',' in ip_address:
ip_address = ip_address.split(',')[0].strip()
return ip_address
def get_auth_mode() -> str:
"""Determine which authentication mode is active.
Priority:
1. CWA (if enabled in settings and DB path exists)
2. Built-in credentials (if configured)
3. No auth required or error -> "none"
"""
Determine which authentication mode is active.
from shelfmark.core.settings_registry import load_config_file
Priority order:
1. Built-in credentials (if configured) -> "builtin"
2. CWA database (if CWA_DB_PATH is set and exists) -> "cwa"
3. No auth required -> "none"
Returns:
str: "builtin", "cwa", or "none"
"""
from cwa_book_downloader.core.settings_registry import load_config_file
# Check for built-in credentials first (they take priority)
try:
security_config = load_config_file("security")
username = security_config.get("BUILTIN_USERNAME")
password_hash = security_config.get("BUILTIN_PASSWORD_HASH")
if username and password_hash:
# 1. Check for explicit CWA auth (CWA_DB_PATH is pre-validated at startup)
if security_config.get("USE_CWA_AUTH") and CWA_DB_PATH:
return "cwa"
# 2. Check for built-in credentials
if security_config.get("BUILTIN_USERNAME") and security_config.get("BUILTIN_PASSWORD_HASH"):
return "builtin"
except Exception:
pass # If config can't be loaded, fall through to other methods
pass
# Check for CWA database
if CWA_DB_PATH and os.path.isfile(CWA_DB_PATH):
return "cwa"
# No auth configured
return "none"
@@ -182,44 +180,34 @@ if DEBUG:
})
# Custom log filter to exclude routine status endpoint polling and WebSocket noise
class StatusEndpointFilter(logging.Filter):
"""Filter out routine status endpoint requests and WebSocket upgrade errors to reduce log noise."""
def filter(self, record):
if hasattr(record, 'getMessage'):
message = record.getMessage()
# Exclude GET /api/status requests (polling noise)
if 'GET /api/status' in message:
return False
# Exclude WebSocket upgrade errors (benign - falls back to polling)
if 'write() before start_response' in message:
return False
# Exclude the Error on request line that precedes WebSocket errors
if 'Error on request:' in message and record.levelno == logging.ERROR:
return False
return True
class LogNoiseFilter(logging.Filter):
"""Filter out routine status endpoint requests and WebSocket upgrade errors to reduce log noise.
class WebSocketErrorFilter(logging.Filter):
"""Filter out WebSocket upgrade errors that occur in Werkzeug dev server.
These errors are benign - Flask-SocketIO automatically falls back to polling transport.
WebSocket upgrade errors are benign - Flask-SocketIO automatically falls back to polling transport.
The error occurs because Werkzeug's built-in server doesn't fully support WebSocket upgrades.
"""
def filter(self, record):
# Filter out the AssertionError traceback for WebSocket upgrades
message = record.getMessage() if hasattr(record, 'getMessage') else str(record.msg)
# Exclude GET /api/status requests (polling noise)
if 'GET /api/status' in message:
return False
# Exclude WebSocket upgrade errors (benign - falls back to polling)
if 'write() before start_response' in message:
return False
# Exclude the Error on request line that precedes WebSocket errors
if record.levelno == logging.ERROR:
message = record.getMessage() if hasattr(record, 'getMessage') else str(record.msg)
# Filter out the full traceback that includes the WebSocket assertion error
if 'write() before start_response' in message:
if 'Error on request:' in message:
return False
# Also filter the "Error on request" header that precedes it
# Filter WebSocket-related AssertionError tracebacks
if hasattr(record, 'exc_info') and record.exc_info:
exc_type = record.exc_info[0]
exc_type, exc_value = record.exc_info[0], record.exc_info[1]
if exc_type and exc_type.__name__ == 'AssertionError':
# Check if it's the WebSocket-related assertion
exc_value = record.exc_info[1]
if exc_value and 'write() before start_response' in str(exc_value):
return False
return True
# Flask logger
@@ -229,17 +217,15 @@ app.logger.setLevel(logger.level)
werkzeug_logger = logging.getLogger('werkzeug')
werkzeug_logger.handlers = logger.handlers
werkzeug_logger.setLevel(logger.level)
# Add filters to suppress routine status endpoint polling logs and WebSocket upgrade errors
werkzeug_logger.addFilter(StatusEndpointFilter())
werkzeug_logger.addFilter(WebSocketErrorFilter())
# Add filter to suppress routine status endpoint polling logs and WebSocket upgrade errors
werkzeug_logger.addFilter(LogNoiseFilter())
# Set up authentication defaults
# The secret key will reset every time we restart, which will
# require users to authenticate again
from shelfmark.config.env import SESSION_COOKIE_SECURE_ENV, string_to_bool
# Session cookie security - set to 'true' if exclusively using HTTPS
session_cookie_secure_env = os.getenv('SESSION_COOKIE_SECURE', 'false').lower()
SESSION_COOKIE_SECURE = session_cookie_secure_env in ['true', 'yes', '1']
SESSION_COOKIE_SECURE = string_to_bool(SESSION_COOKIE_SECURE_ENV)
app.config.update(
SECRET_KEY = os.urandom(64),
@@ -249,7 +235,7 @@ app.config.update(
PERMANENT_SESSION_LIFETIME = 604800 # 7 days in seconds
)
logger.info(f"Session cookie secure setting: {SESSION_COOKIE_SECURE} (from env: {session_cookie_secure_env})")
logger.info(f"Session cookie secure setting: {SESSION_COOKIE_SECURE} (from env: {SESSION_COOKIE_SECURE_ENV})")
def login_required(f):
@wraps(f)
@@ -260,9 +246,9 @@ def login_required(f):
if auth_mode == "none":
return f(*args, **kwargs)
# If CWA mode and database path is invalid, return error
if auth_mode == "cwa" and CWA_DB_PATH and not os.path.isfile(CWA_DB_PATH):
logger.error(f"CWA_DB_PATH is set to {CWA_DB_PATH} but this is not a valid path")
# If CWA mode and database disappeared after startup, return error
if auth_mode == "cwa" and CWA_DB_PATH and not CWA_DB_PATH.exists():
logger.error(f"CWA database at {CWA_DB_PATH} is no longer accessible")
return jsonify({"error": "Internal Server Error"}), 500
# Check if user has a valid session
@@ -304,32 +290,25 @@ def favicon(_: Any = None) -> Response:
"""
return send_from_directory(FRONTEND_DIST, 'favicon.ico', mimetype='image/vnd.microsoft.icon')
# Register bypasser warmup callback for when first WebSocket client connects
# and shutdown callback for when all clients disconnect
if not USING_EXTERNAL_BYPASSER:
from cwa_book_downloader.bypass.internal_bypasser import warmup as bypasser_warmup, shutdown_if_idle as bypasser_shutdown
ws_manager.register_on_first_connect(bypasser_warmup)
ws_manager.register_on_all_disconnect(bypasser_shutdown)
logger.info("Registered Cloudflare bypasser warmup/shutdown on WebSocket connect/disconnect")
if DEBUG:
import subprocess
if USING_EXTERNAL_BYPASSER:
STOP_GUI = lambda: None
if app_config.get("USING_EXTERNAL_BYPASSER", False):
_stop_gui = lambda: None
else:
from cwa_book_downloader.bypass.internal_bypasser import _reset_driver as STOP_GUI
from shelfmark.bypass.internal_bypasser import _cleanup_orphan_processes as _stop_gui
@app.route('/api/debug', methods=['GET'])
@login_required
def debug() -> Union[Response, Tuple[Response, int]]:
"""
This will run the /app/genDebug.sh script, which will generate a debug zip with all the logs
The file will be named /tmp/cwa-book-downloader-debug.zip
The file will be named /tmp/shelfmark-debug.zip
And then return it to the user
"""
try:
# Run the debug script
logger.info("Debug endpoint called, stopping GUI and generating debug info...")
STOP_GUI()
_stop_gui()
time.sleep(1)
result = subprocess.run(['/app/genDebug.sh'], capture_output=True, text=True, check=True)
if result.returncode != 0:
@@ -339,9 +318,8 @@ if DEBUG:
if not os.path.exists(debug_file_path):
logger.error(f"Debug zip file not found at: {debug_file_path}")
return jsonify({"error": "Failed to generate debug information"}), 500
logger.info(f"Sending debug file: {debug_file_path}")
# Return the file to the user
return send_file(
debug_file_path,
mimetype='application/zip',
@@ -355,7 +333,6 @@ if DEBUG:
logger.error_trace(f"Debug endpoint error: {e}")
return jsonify({"error": str(e)}), 500
if DEBUG:
@app.route('/api/restart', methods=['GET'])
@login_required
def restart() -> Union[Response, Tuple[Response, int]]:
@@ -451,10 +428,10 @@ def api_download() -> Union[Response, Tuple[Response, int]]:
try:
priority = int(request.args.get('priority', 0))
success = backend.queue_book(book_id, priority)
success, error_msg = backend.queue_book(book_id, priority)
if success:
return jsonify({"status": "queued", "priority": priority})
return jsonify({"error": "Failed to queue book"}), 500
return jsonify({"error": error_msg or "Failed to queue book"}), 500
except Exception as e:
logger.error_trace(f"Download error: {e}")
return jsonify({"error": str(e)}), 500
@@ -489,22 +466,16 @@ def api_download_release() -> Union[Response, Tuple[Response, int]]:
return jsonify({"error": "source_id is required"}), 400
priority = data.get('priority', 0)
success = backend.queue_release(data, priority)
success, error_msg = backend.queue_release(data, priority)
if success:
return jsonify({"status": "queued", "priority": priority})
return jsonify({"error": "Failed to queue release"}), 500
return jsonify({"error": error_msg or "Failed to queue release"}), 500
except Exception as e:
logger.error_trace(f"Release download error: {e}")
return jsonify({"error": str(e)}), 500
def _is_settings_enabled() -> bool:
"""Check if the config directory is mounted and writable."""
from cwa_book_downloader.config.env import _is_config_dir_writable
return _is_config_dir_writable()
@app.route('/api/config', methods=['GET'])
@login_required
def api_config() -> Union[Response, Tuple[Response, int]]:
@@ -515,27 +486,29 @@ def api_config() -> Union[Response, Tuple[Response, int]]:
are reflected without requiring a container restart.
"""
try:
from cwa_book_downloader.metadata_providers import (
from shelfmark.metadata_providers import (
get_provider_sort_options,
get_provider_search_fields,
get_provider_default_sort,
)
from shelfmark.config.env import _is_config_dir_writable
config = {
"calibre_web_url": app_config.get("CALIBRE_WEB_URL", ""),
"debug": DEBUG,
"debug": app_config.get("DEBUG", False),
"build_version": BUILD_VERSION,
"release_version": RELEASE_VERSION,
"book_languages": _SUPPORTED_BOOK_LANGUAGE,
"default_language": app_config.BOOK_LANGUAGE,
"supported_formats": app_config.SUPPORTED_FORMATS,
"supported_audiobook_formats": app_config.SUPPORTED_AUDIOBOOK_FORMATS,
"search_mode": app_config.get("SEARCH_MODE", "direct"),
"metadata_sort_options": get_provider_sort_options(),
"metadata_search_fields": get_provider_search_fields(),
"default_release_source": app_config.get("DEFAULT_RELEASE_SOURCE", "direct_download"),
"auto_open_downloads_sidebar": app_config.get("AUTO_OPEN_DOWNLOADS_SIDEBAR", True),
"download_to_browser": app_config.get("DOWNLOAD_TO_BROWSER", False),
"settings_enabled": _is_settings_enabled(),
"settings_enabled": _is_config_dir_writable(),
# Default sort orders
"default_sort": app_config.get("AA_DEFAULT_SORT", "relevance"), # For direct mode (Anna's Archive)
"metadata_default_sort": get_provider_default_sort(), # For universal mode
@@ -550,11 +523,17 @@ def api_health() -> Union[Response, Tuple[Response, int]]:
"""
Health check endpoint for container orchestration.
No authentication required.
Returns:
flask.Response: JSON with status "ok".
flask.Response: JSON with status "ok" and optional degraded features.
"""
return jsonify({"status": "ok"})
response = {"status": "ok"}
# Report degraded features
if not backend.WEBSOCKET_AVAILABLE:
response["degraded"] = {"websocket": "WebSocket unavailable - real-time updates disabled"}
return jsonify(response)
@app.route('/api/status', methods=['GET'])
@login_required
@@ -625,8 +604,8 @@ def api_cover(cover_id: str) -> Union[Response, Tuple[Response, int]]:
"""
try:
import base64
from cwa_book_downloader.core.image_cache import get_image_cache
from cwa_book_downloader.config.env import is_covers_cache_enabled
from shelfmark.core.image_cache import get_image_cache
from shelfmark.config.env import is_covers_cache_enabled
# Check if caching is enabled
if not is_covers_cache_enabled():
@@ -848,9 +827,7 @@ def internal_error(error: Exception) -> Union[Response, Tuple[Response, int]]:
return jsonify({"error": "Internal server error"}), 500
def _failed_login_response(username: str, ip_address: str) -> Tuple[Response, int]:
"""
Handle a failed login attempt by recording it and returning the appropriate response.
"""
"""Handle a failed login attempt by recording it and returning the appropriate response."""
is_now_locked = record_failed_login(username, ip_address)
if is_now_locked:
@@ -882,15 +859,10 @@ def api_login() -> Union[Response, Tuple[Response, int]]:
Returns:
flask.Response: JSON with success status or error message.
"""
from cwa_book_downloader.core.settings_registry import load_config_file
from shelfmark.core.settings_registry import load_config_file
try:
# Get client IP address (handles reverse proxy forwarding)
ip_address = request.headers.get('X-Forwarded-For', request.remote_addr)
if ip_address and ',' in ip_address:
# X-Forwarded-For can contain multiple IPs, take the first one
ip_address = ip_address.split(',')[0].strip()
ip_address = get_client_ip()
data = request.get_json()
if not data:
return jsonify({"error": "No data provided"}), 400
@@ -944,9 +916,9 @@ def api_login() -> Union[Response, Tuple[Response, int]]:
# CWA database authentication mode
if auth_mode == "cwa":
# Validate CWA database path
if not os.path.isfile(CWA_DB_PATH):
logger.error(f"CWA_DB_PATH is set to {CWA_DB_PATH} but this is not a valid path")
# Verify database still exists (it was validated at startup)
if not CWA_DB_PATH or not CWA_DB_PATH.exists():
logger.error(f"CWA database at {CWA_DB_PATH} is no longer accessible")
return jsonify({"error": "Database configuration error"}), 500
try:
@@ -954,7 +926,7 @@ def api_login() -> Union[Response, Tuple[Response, int]]:
db_uri = f"file:{db_path}?mode=ro&immutable=1"
conn = sqlite3.connect(db_uri, uri=True)
cur = conn.cursor()
cur.execute("SELECT password FROM user WHERE name = ?", (username,))
cur.execute("SELECT password, role FROM user WHERE name = ?", (username,))
row = cur.fetchone()
conn.close()
@@ -962,11 +934,16 @@ def api_login() -> Union[Response, Tuple[Response, int]]:
if not row or not row[0] or not check_password_hash(row[0], password):
return _failed_login_response(username, ip_address)
# Check if user has admin role (ROLE_ADMIN = 1, bit flag)
user_role = row[1] if row[1] is not None else 0
is_admin = (user_role & 1) == 1
# Successful authentication - create session and clear failed attempts
session['user_id'] = username
session['is_admin'] = is_admin
session.permanent = remember_me
clear_failed_logins(username)
logger.info(f"Login successful for user '{username}' from IP {ip_address} (CWA auth, remember_me={remember_me})")
logger.info(f"Login successful for user '{username}' from IP {ip_address} (CWA auth, is_admin={is_admin}, remember_me={remember_me})")
return jsonify({"success": True})
except Exception as e:
@@ -989,11 +966,7 @@ def api_logout() -> Union[Response, Tuple[Response, int]]:
flask.Response: JSON with success status.
"""
try:
# Get client IP address (handles reverse proxy forwarding)
ip_address = request.headers.get('X-Forwarded-For', request.remote_addr)
if ip_address and ',' in ip_address:
ip_address = ip_address.split(',')[0].strip()
ip_address = get_client_ip()
username = session.get('user_id', 'unknown')
session.clear()
logger.info(f"Logout successful for user '{username}' from IP {ip_address}")
@@ -1009,32 +982,54 @@ def api_auth_check() -> Union[Response, Tuple[Response, int]]:
Returns:
flask.Response: JSON with authentication status, whether auth is required,
and which auth mode is active.
which auth mode is active, and whether user has admin privileges.
"""
from shelfmark.core.settings_registry import load_config_file
try:
auth_mode = get_auth_mode()
# If no authentication is configured, access is allowed
# If no authentication is configured, access is allowed (full admin)
if auth_mode == "none":
return jsonify({
"authenticated": True,
"auth_required": False,
"auth_mode": "none"
"auth_mode": "none",
"is_admin": True
})
# Check if user has a valid session
is_authenticated = 'user_id' in session
# Determine admin status for settings access
# - Built-in auth: single user is always admin
# - CWA auth: check RESTRICT_SETTINGS_TO_ADMIN setting
if auth_mode == "builtin":
is_admin = True
elif auth_mode == "cwa":
security_config = load_config_file("security")
restrict_to_admin = security_config.get("RESTRICT_SETTINGS_TO_ADMIN", False)
if restrict_to_admin:
is_admin = session.get('is_admin', False)
else:
# All authenticated CWA users can access settings
is_admin = True
else:
is_admin = False
return jsonify({
"authenticated": is_authenticated,
"auth_required": True,
"auth_mode": auth_mode
"auth_mode": auth_mode,
"is_admin": is_admin if is_authenticated else False
})
except Exception as e:
logger.error_trace(f"Auth check error: {e}")
return jsonify({
"authenticated": False,
"auth_required": True,
"auth_mode": "unknown"
"auth_mode": "unknown",
"is_admin": False
})
@@ -1048,7 +1043,7 @@ def api_metadata_providers() -> Union[Response, Tuple[Response, int]]:
flask.Response: JSON with list of providers and their status.
"""
try:
from cwa_book_downloader.metadata_providers import (
from shelfmark.metadata_providers import (
list_providers,
get_provider,
get_provider_kwargs,
@@ -1093,7 +1088,7 @@ def api_metadata_search() -> Union[Response, Tuple[Response, int]]:
Query Parameters:
query (str): Search query (required)
limit (int): Maximum number of results (default: 20, max: 50)
limit (int): Maximum number of results (default: 40, max: 100)
sort (str): Sort order - relevance, popularity, rating, newest, oldest (default: relevance)
[dynamic fields]: Provider-specific search fields passed as query params
@@ -1101,7 +1096,7 @@ def api_metadata_search() -> Union[Response, Tuple[Response, int]]:
flask.Response: JSON with list of books from metadata provider.
"""
try:
from cwa_book_downloader.metadata_providers import (
from shelfmark.metadata_providers import (
get_configured_provider,
MetadataSearchOptions,
SortOrder,
@@ -1111,11 +1106,17 @@ def api_metadata_search() -> Union[Response, Tuple[Response, int]]:
from dataclasses import asdict
query = request.args.get('query', '').strip()
content_type = request.args.get('content_type', 'ebook').strip()
try:
limit = min(int(request.args.get('limit', 20)), 50)
limit = min(int(request.args.get('limit', 40)), 100)
except ValueError:
limit = 20
limit = 40
try:
page = max(1, int(request.args.get('page', 1)))
except ValueError:
page = 1
# Parse sort parameter
sort_value = request.args.get('sort', 'relevance').lower()
@@ -1124,7 +1125,7 @@ def api_metadata_search() -> Union[Response, Tuple[Response, int]]:
except ValueError:
sort_order = SortOrder.RELEVANCE
provider = get_configured_provider()
provider = get_configured_provider(content_type=content_type)
if not provider:
return jsonify({
"error": "No metadata provider configured",
@@ -1160,27 +1161,26 @@ def api_metadata_search() -> Union[Response, Tuple[Response, int]]:
if not query and not fields:
return jsonify({"error": "Either 'query' or search field values are required"}), 400
options = MetadataSearchOptions(query=query, limit=limit, sort=sort_order, fields=fields)
books = provider.search(options)
options = MetadataSearchOptions(query=query, limit=limit, page=page, sort=sort_order, fields=fields)
search_result = provider.search_paginated(options)
# Convert BookMetadata objects to dicts
books_data = [asdict(book) for book in books]
books_data = [asdict(book) for book in search_result.books]
# Transform cover_url to local proxy URLs when caching is enabled
from cwa_book_downloader.config.env import is_covers_cache_enabled
if is_covers_cache_enabled():
import base64
for book_dict in books_data:
if book_dict.get('cover_url'):
# Encode original URL in the proxy request itself - no need for persistent mapping
cache_id = f"{book_dict['provider']}_{book_dict['provider_id']}"
encoded_url = base64.urlsafe_b64encode(book_dict['cover_url'].encode()).decode()
book_dict['cover_url'] = f"/api/covers/{cache_id}?url={encoded_url}"
from shelfmark.core.utils import transform_cover_url
for book_dict in books_data:
if book_dict.get('cover_url'):
cache_id = f"{book_dict['provider']}_{book_dict['provider_id']}"
book_dict['cover_url'] = transform_cover_url(book_dict['cover_url'], cache_id)
return jsonify({
"books": books_data,
"provider": provider.name,
"query": query
"query": query,
"page": search_result.page,
"total_found": search_result.total_found,
"has_more": search_result.has_more
})
except Exception as e:
logger.error_trace(f"Metadata search error: {e}")
@@ -1201,7 +1201,7 @@ def api_metadata_book(provider: str, book_id: str) -> Union[Response, Tuple[Resp
flask.Response: JSON with book details.
"""
try:
from cwa_book_downloader.metadata_providers import (
from shelfmark.metadata_providers import (
get_provider,
is_provider_registered,
get_provider_kwargs,
@@ -1225,12 +1225,10 @@ def api_metadata_book(provider: str, book_id: str) -> Union[Response, Tuple[Resp
book_dict = asdict(book)
# Transform cover_url to local proxy URL when caching is enabled
from cwa_book_downloader.config.env import is_covers_cache_enabled
if is_covers_cache_enabled() and book_dict.get('cover_url'):
import base64
from shelfmark.core.utils import transform_cover_url
if book_dict.get('cover_url'):
cache_id = f"{provider}_{book_id}"
encoded_url = base64.urlsafe_b64encode(book_dict['cover_url'].encode()).decode()
book_dict['cover_url'] = f"/api/covers/{cache_id}?url={encoded_url}"
book_dict['cover_url'] = transform_cover_url(book_dict['cover_url'], cache_id)
return jsonify(book_dict)
except ValueError as e:
@@ -1258,12 +1256,12 @@ def api_releases() -> Union[Response, Tuple[Response, int]]:
flask.Response: JSON with list of available releases.
"""
try:
from cwa_book_downloader.metadata_providers import (
from shelfmark.metadata_providers import (
get_provider,
is_provider_registered,
get_provider_kwargs,
)
from cwa_book_downloader.release_sources import get_source, list_available_sources, serialize_column_config
from shelfmark.release_sources import get_source, list_available_sources, serialize_column_config
from dataclasses import asdict
provider = request.args.get('provider', '').strip()
@@ -1272,6 +1270,12 @@ def api_releases() -> Union[Response, Tuple[Response, int]]:
# Accept title/author from frontend to avoid re-fetching metadata
title_param = request.args.get('title', '').strip()
author_param = request.args.get('author', '').strip()
expand_search = request.args.get('expand_search', '').lower() == 'true'
# Accept language codes for filtering (comma-separated)
languages_param = request.args.get('languages', '').strip()
languages = [lang.strip() for lang in languages_param.split(',') if lang.strip()] if languages_param else None
# Content type for audiobook vs ebook search
content_type = request.args.get('content_type', 'ebook').strip()
if not provider or not book_id:
return jsonify({"error": "Parameters 'provider' and 'book_id' are required"}), 400
@@ -1287,28 +1291,31 @@ def api_releases() -> Union[Response, Tuple[Response, int]]:
if not book:
return jsonify({"error": "Book not found in metadata provider"}), 404
# Override with frontend-provided title/author if available (these come from search results
# which may have more complete data than get_book returns)
# Override title from frontend if available (search results may have better data)
# Note: We intentionally DON'T override authors here - get_book() now returns
# filtered authors (primary authors only, excluding translators/narrators),
# which gives better release search results than the unfiltered search data
if title_param:
book.title = title_param
if author_param:
book.authors = [author_param] if author_param else []
# Determine which release sources to search
if source_filter:
sources_to_search = [source_filter]
else:
# Search all available sources
sources_to_search = [src["name"] for src in list_available_sources()]
# Search only enabled sources
sources_to_search = [src["name"] for src in list_available_sources() if src["enabled"]]
# Search each source for releases
all_releases = []
errors = []
source_instances = {} # Keep source instances for column config
for source_name in sources_to_search:
try:
source = get_source(source_name)
releases = source.search(book)
source_instances[source_name] = source
logger.debug(f"Searching {source_name} for '{book.title}' by {book.authors} (expand={expand_search}, content_type={content_type})")
releases = source.search(book, expand_search=expand_search, languages=languages, content_type=content_type)
all_releases.extend(releases)
except ValueError:
errors.append(f"Unknown source: {source_name}")
@@ -1320,29 +1327,35 @@ def api_releases() -> Union[Response, Tuple[Response, int]]:
releases_data = [asdict(release) for release in all_releases]
# Get column config from the first source searched
# (In the UI, releases are shown per-source tab anyway)
# Reuse the same instance to get any dynamic data (e.g., online_servers for IRC)
column_config = None
if sources_to_search:
if sources_to_search and sources_to_search[0] in source_instances:
try:
first_source = get_source(sources_to_search[0])
first_source = source_instances[sources_to_search[0]]
column_config = serialize_column_config(first_source.get_column_config())
except Exception as e:
logger.warning(f"Failed to get column config: {e}")
# Convert book to dict and transform cover_url
book_dict = asdict(book)
from cwa_book_downloader.config.env import is_covers_cache_enabled
if is_covers_cache_enabled() and book_dict.get('cover_url'):
import base64
from shelfmark.core.utils import transform_cover_url
if book_dict.get('cover_url'):
cache_id = f"{provider}_{book_id}"
encoded_url = base64.urlsafe_b64encode(book_dict['cover_url'].encode()).decode()
book_dict['cover_url'] = f"/api/covers/{cache_id}?url={encoded_url}"
book_dict['cover_url'] = transform_cover_url(book_dict['cover_url'], cache_id)
search_info = {}
for source_name, source_instance in source_instances.items():
if hasattr(source_instance, 'last_search_type') and source_instance.last_search_type:
search_info[source_name] = {
"search_type": source_instance.last_search_type
}
response = {
"releases": releases_data,
"book": book_dict,
"sources_searched": sources_to_search,
"column_config": column_config,
"search_info": search_info,
}
if errors:
@@ -1374,7 +1387,7 @@ def api_release_sources() -> Union[Response, Tuple[Response, int]]:
flask.Response: JSON list of available release sources.
"""
try:
from cwa_book_downloader.release_sources import list_available_sources
from shelfmark.release_sources import list_available_sources
sources = list_available_sources()
return jsonify(sources)
except Exception as e:
@@ -1392,12 +1405,12 @@ def api_settings_get_all() -> Union[Response, Tuple[Response, int]]:
flask.Response: JSON with all settings tabs.
"""
try:
from cwa_book_downloader.core.settings_registry import serialize_all_settings
from shelfmark.core.settings_registry import serialize_all_settings
# Ensure settings are registered by importing settings modules
# This triggers the @register_settings decorators
import cwa_book_downloader.config.settings # noqa: F401
import cwa_book_downloader.config.security # noqa: F401
import shelfmark.config.settings # noqa: F401
import shelfmark.config.security # noqa: F401
data = serialize_all_settings(include_values=True)
return jsonify(data)
@@ -1419,14 +1432,14 @@ def api_settings_get_tab(tab_name: str) -> Union[Response, Tuple[Response, int]]
flask.Response: JSON with tab settings and values.
"""
try:
from cwa_book_downloader.core.settings_registry import (
from shelfmark.core.settings_registry import (
get_settings_tab,
serialize_tab,
)
# Ensure settings are registered
import cwa_book_downloader.config.settings # noqa: F401
import cwa_book_downloader.config.security # noqa: F401
import shelfmark.config.settings # noqa: F401
import shelfmark.config.security # noqa: F401
tab = get_settings_tab(tab_name)
if not tab:
@@ -1454,14 +1467,14 @@ def api_settings_update_tab(tab_name: str) -> Union[Response, Tuple[Response, in
flask.Response: JSON with update result.
"""
try:
from cwa_book_downloader.core.settings_registry import (
from shelfmark.core.settings_registry import (
get_settings_tab,
update_settings,
)
# Ensure settings are registered
import cwa_book_downloader.config.settings # noqa: F401
import cwa_book_downloader.config.security # noqa: F401
import shelfmark.config.settings # noqa: F401
import shelfmark.config.security # noqa: F401
tab = get_settings_tab(tab_name)
if not tab:
@@ -1503,11 +1516,11 @@ def api_settings_execute_action(tab_name: str, action_key: str) -> Union[Respons
flask.Response: JSON with action result.
"""
try:
from cwa_book_downloader.core.settings_registry import execute_action
from shelfmark.core.settings_registry import execute_action
# Ensure settings are registered
import cwa_book_downloader.config.settings # noqa: F401
import cwa_book_downloader.config.security # noqa: F401
import shelfmark.config.settings # noqa: F401
import shelfmark.config.security # noqa: F401
# Get current form values if provided (for testing with unsaved values)
current_values = request.get_json(silent=True) or {}
@@ -64,7 +64,7 @@ class MetadataSearchOptions:
search_type: SearchType = SearchType.GENERAL # GENERAL, TITLE, AUTHOR, ISBN
language: str = None # ISO 639-1 code (e.g., "en")
sort: SortOrder = SortOrder.RELEVANCE
limit: int = 20
limit: int = 40
page: int = 1
```
@@ -117,7 +117,7 @@ class MetadataProvider(ABC):
### Provider Registration
```python
from cwa_book_downloader.metadata_providers import register_provider
from shelfmark.metadata_providers import register_provider
@register_provider("my_provider")
class MyProvider(MetadataProvider):
@@ -127,7 +127,7 @@ class MyProvider(MetadataProvider):
### Getting Providers
```python
from cwa_book_downloader.metadata_providers import (
from shelfmark.metadata_providers import (
get_provider,
get_configured_provider,
get_provider_kwargs,
@@ -155,7 +155,7 @@ exists = is_provider_registered("hardcover") # True
### Sort Options
```python
from cwa_book_downloader.metadata_providers import get_provider_sort_options
from shelfmark.metadata_providers import get_provider_sort_options
# Get sort options for a specific provider
options = get_provider_sort_options("hardcover")
@@ -167,12 +167,12 @@ options = get_provider_sort_options() # Uses METADATA_PROVIDER from config
## Creating a New Provider
1. Create a new file in `cwa_book_downloader/metadata_providers/` (e.g., `my_provider.py`)
1. Create a new file in `shelfmark/metadata_providers/` (e.g., `my_provider.py`)
2. Implement the provider:
```python
from cwa_book_downloader.metadata_providers import (
from shelfmark.metadata_providers import (
BookMetadata,
DisplayField,
MetadataProvider,
@@ -181,13 +181,13 @@ from cwa_book_downloader.metadata_providers import (
SortOrder,
register_provider,
)
from cwa_book_downloader.core.settings_registry import (
from shelfmark.core.settings_registry import (
register_settings,
HeadingField,
PasswordField,
ActionButton,
)
from cwa_book_downloader.core.config import config
from shelfmark.core.config import config
@register_provider("my_provider")
@@ -251,7 +251,7 @@ def my_provider_settings():
```python
try:
from cwa_book_downloader.metadata_providers import my_provider # noqa: F401
from shelfmark.metadata_providers import my_provider # noqa: F401
except ImportError:
pass # Provider is optional
```
@@ -273,8 +273,8 @@ def get_provider_kwargs(provider_name: str) -> Dict:
Providers should use the `@cacheable` decorator for API calls:
```python
from cwa_book_downloader.core.cache import cacheable
from cwa_book_downloader.config.env import (
from shelfmark.core.cache import cacheable
from shelfmark.config.env import (
METADATA_CACHE_SEARCH_TTL,
METADATA_CACHE_BOOK_TTL,
)
@@ -295,7 +295,7 @@ def get_book(self, book_id: str):
For providers with rate limits (like Open Library), implement a rate limiter:
```python
from cwa_book_downloader.metadata_providers.openlibrary import RateLimiter
from shelfmark.metadata_providers.openlibrary import RateLimiter
# 90 requests per 60 seconds
rate_limiter = RateLimiter(max_requests=90, window_seconds=60)
@@ -1,9 +1,9 @@
"""Metadata provider plugin system - base classes and registry."""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field, asdict
from dataclasses import dataclass, field
from enum import Enum
from typing import List, Optional, Dict, Type, Literal, Any, Union
from typing import Any, Dict, List, Optional, Type, Union
class SearchType(str, Enum):
@@ -21,6 +21,7 @@ class SortOrder(str, Enum):
RATING = "rating" # Highest rated first
NEWEST = "newest" # Most recently published first
OLDEST = "oldest" # Oldest published first
SERIES_ORDER = "series_order" # By series position (requires series field)
# Display labels for sort options
@@ -30,6 +31,7 @@ SORT_LABELS: Dict[SortOrder, str] = {
SortOrder.RATING: "Highest rated",
SortOrder.NEWEST: "Newest",
SortOrder.OLDEST: "Oldest",
SortOrder.SERIES_ORDER: "Series order",
}
@@ -77,26 +79,14 @@ class CheckboxSearchField:
SearchField = Union[TextSearchField, NumberSearchField, SelectSearchField, CheckboxSearchField]
def _get_field_type_name(search_field: SearchField) -> str:
"""Get the type name for a search field."""
return search_field.__class__.__name__
def serialize_search_field(search_field: SearchField) -> Dict[str, Any]:
"""Serialize a search field for API response.
Args:
search_field: The search field definition.
Returns:
Dict representation for frontend.
"""
"""Serialize a search field to dict for API response."""
result: Dict[str, Any] = {
"key": search_field.key,
"label": search_field.label,
"type": _get_field_type_name(search_field),
"placeholder": search_field.placeholder if hasattr(search_field, 'placeholder') else "",
"description": search_field.description if hasattr(search_field, 'description') else "",
"type": search_field.__class__.__name__,
"placeholder": getattr(search_field, 'placeholder', ''),
"description": getattr(search_field, 'description', ''),
}
# Add type-specific properties
@@ -114,27 +104,19 @@ def serialize_search_field(search_field: SearchField) -> Dict[str, Any]:
@dataclass
class MetadataSearchOptions:
"""Options for metadata search queries.
Provides an abstracted interface that works across all metadata providers.
Providers map these options to their specific API parameters.
"""
"""Options for metadata search queries across all providers."""
query: str
search_type: SearchType = SearchType.GENERAL
language: Optional[str] = None # ISO 639-1 code (e.g., "en", "fr")
sort: SortOrder = SortOrder.RELEVANCE
limit: int = 20
limit: int = 40
page: int = 1
fields: Dict[str, Any] = field(default_factory=dict) # Custom search field values
@dataclass
class DisplayField:
"""A display field for metadata cards.
Providers can populate these to show provider-specific metadata
like ratings, page counts, reader counts, etc.
"""
"""A display field for metadata cards (ratings, page counts, etc.)."""
label: str # e.g., "Rating", "Pages", "Readers"
value: str # e.g., "4.5", "496", "8,041"
icon: Optional[str] = None # Icon name: "star", "book", "users", "editions"
@@ -161,10 +143,29 @@ class BookMetadata:
language: Optional[str] = None
genres: List[str] = field(default_factory=list)
source_url: Optional[str] = None # Link to book on provider's site
subtitle: Optional[str] = None # Book subtitle, if any
# Provider-specific display fields for cards/lists
display_fields: List[DisplayField] = field(default_factory=list)
# Series info (if book is part of a series)
series_name: Optional[str] = None # Name of the series
series_position: Optional[float] = None # This book's position (e.g., 3, 1.5 for novellas)
series_count: Optional[int] = None # Total books in the series
# Alternative titles by language (for localized searches)
# Maps language code (e.g., "de", "German") to localized title
titles_by_language: Dict[str, str] = field(default_factory=dict)
@dataclass
class SearchResult:
"""Result from a metadata search with pagination info."""
books: List[BookMetadata]
page: int = 1
total_found: int = 0 # Total matching results (if known)
has_more: bool = False # True if more results available
class MetadataProvider(ABC):
"""Interface for metadata providers.
@@ -187,19 +188,7 @@ class MetadataProvider(ABC):
@abstractmethod
def search(self, options: MetadataSearchOptions) -> List[BookMetadata]:
"""Search for books using the provided options.
Args:
options: Search options including query, type, language, sort, pagination.
Returns:
List of BookMetadata matching the search criteria.
Note:
- If search_type is ISBN, this delegates to search_by_isbn()
- Unsupported sort orders fall back to RELEVANCE
- Language filtering is best-effort (not all providers support it)
"""
"""Search for books using the provided options."""
pass
@abstractmethod
@@ -217,6 +206,18 @@ class MetadataProvider(ABC):
"""Check if this provider is configured and available."""
pass
def search_paginated(self, options: MetadataSearchOptions) -> SearchResult:
"""Search with pagination info. Override for accurate pagination."""
books = self.search(options)
# Heuristic: if we got exactly limit results, there might be more
has_more = len(books) >= options.limit
return SearchResult(
books=books,
page=options.page,
total_found=0, # Unknown without provider-specific implementation
has_more=has_more
)
# Provider registry
_PROVIDERS: Dict[str, Type[MetadataProvider]] = {}
@@ -241,7 +242,7 @@ def register_provider_kwargs(name: str):
Example:
@register_provider_kwargs("hardcover")
def _hardcover_kwargs() -> Dict:
from cwa_book_downloader.core.config import config
from shelfmark.core.config import config
return {"api_key": config.get("HARDCOVER_API_KEY", "")}
"""
def decorator(fn):
@@ -266,18 +267,7 @@ def list_providers() -> List[dict]:
def get_provider_kwargs(provider_name: str) -> Dict:
"""Get provider-specific initialization kwargs based on configuration.
Looks up the provider's registered kwargs factory and calls it to get
the configuration. Each provider registers its own factory via
@register_provider_kwargs decorator.
Args:
provider_name: Name of the provider.
Returns:
Dict of kwargs to pass to provider constructor.
"""
"""Get provider-specific initialization kwargs from registered factory."""
factory = _PROVIDER_KWARGS_FACTORIES.get(provider_name)
if factory:
return factory()
@@ -285,30 +275,13 @@ def get_provider_kwargs(provider_name: str) -> Dict:
def is_provider_registered(provider_name: str) -> bool:
"""Check if a provider is registered.
Args:
provider_name: Name of the provider.
Returns:
True if provider is registered, False otherwise.
"""
"""Check if a provider is registered."""
return provider_name in _PROVIDERS
def is_provider_enabled(provider_name: str) -> bool:
"""Check if a provider is enabled in settings.
Each provider has an enabled flag (e.g., HARDCOVER_ENABLED, OPENLIBRARY_ENABLED)
that must be explicitly set to True for the provider to be used.
Args:
provider_name: Name of the provider.
Returns:
True if provider is enabled, False otherwise.
"""
from cwa_book_downloader.core.config import config as app_config
"""Check if a provider is enabled in settings."""
from shelfmark.core.config import config as app_config
# Refresh config to get latest settings
app_config.refresh()
@@ -319,33 +292,25 @@ def is_provider_enabled(provider_name: str) -> bool:
def get_enabled_providers() -> List[str]:
"""Get list of all enabled provider names.
Returns:
List of enabled provider names.
"""
enabled = []
for name in _PROVIDERS:
if is_provider_enabled(name):
enabled.append(name)
return enabled
"""Get list of all enabled provider names."""
return [name for name in _PROVIDERS if is_provider_enabled(name)]
def get_configured_provider() -> Optional[MetadataProvider]:
"""Get the currently configured metadata provider, if any.
Uses the METADATA_PROVIDER config setting to determine which provider
to instantiate. Returns None if no provider is configured or not enabled.
Returns:
MetadataProvider instance or None.
"""
from cwa_book_downloader.core.config import config as app_config
def get_configured_provider(content_type: str = "ebook") -> Optional[MetadataProvider]:
"""Get the currently configured metadata provider for the content type."""
from shelfmark.core.config import config as app_config
# Refresh config to ensure we have the latest saved settings
app_config.refresh()
metadata_provider = app_config.get("METADATA_PROVIDER", "")
# For audiobooks, try audiobook-specific provider first, then fall back to main provider
if content_type == "audiobook":
metadata_provider = app_config.get("METADATA_PROVIDER_AUDIOBOOK", "")
if not metadata_provider:
metadata_provider = app_config.get("METADATA_PROVIDER", "")
else:
metadata_provider = app_config.get("METADATA_PROVIDER", "")
if not metadata_provider:
return None
@@ -360,21 +325,17 @@ def get_configured_provider() -> Optional[MetadataProvider]:
return get_provider(metadata_provider, **kwargs)
def _get_configured_provider_name() -> str:
"""Get the currently configured metadata provider name from config."""
from shelfmark.core.config import config as app_config
app_config.refresh()
return app_config.get("METADATA_PROVIDER", "")
def get_provider_sort_options(provider_name: Optional[str] = None) -> List[Dict[str, str]]:
"""Get sort options for a metadata provider.
Returns a list of {value, label} dicts suitable for frontend dropdowns.
Args:
provider_name: Provider name. If None, uses configured provider.
Returns:
List of sort option dicts, or default [relevance] if provider not found.
"""
"""Get sort options for a metadata provider as {value, label} dicts."""
if provider_name is None:
from cwa_book_downloader.core.config import config as app_config
app_config.refresh()
provider_name = app_config.get("METADATA_PROVIDER", "")
provider_name = _get_configured_provider_name()
if provider_name and provider_name in _PROVIDERS:
provider_class = _PROVIDERS[provider_name]
@@ -389,20 +350,9 @@ def get_provider_sort_options(provider_name: Optional[str] = None) -> List[Dict[
def get_provider_search_fields(provider_name: Optional[str] = None) -> List[Dict[str, Any]]:
"""Get search fields for a metadata provider.
Returns a list of serialized search field dicts suitable for frontend rendering.
Args:
provider_name: Provider name. If None, uses configured provider.
Returns:
List of search field dicts, or empty list if provider not found.
"""
"""Get search fields for a metadata provider as serialized dicts."""
if provider_name is None:
from cwa_book_downloader.core.config import config as app_config
app_config.refresh()
provider_name = app_config.get("METADATA_PROVIDER", "")
provider_name = _get_configured_provider_name()
if provider_name and provider_name in _PROVIDERS:
provider_class = _PROVIDERS[provider_name]
@@ -414,21 +364,11 @@ def get_provider_search_fields(provider_name: Optional[str] = None) -> List[Dict
def get_provider_default_sort(provider_name: Optional[str] = None) -> str:
"""Get the default sort order for a metadata provider.
Reads from the provider-specific config setting (e.g., HARDCOVER_DEFAULT_SORT).
Args:
provider_name: Provider name. If None, uses configured provider.
Returns:
Default sort value string, or "relevance" if not configured.
"""
from cwa_book_downloader.core.config import config as app_config
"""Get the default sort order for a metadata provider."""
from shelfmark.core.config import config as app_config
if provider_name is None:
app_config.refresh()
provider_name = app_config.get("METADATA_PROVIDER", "")
provider_name = _get_configured_provider_name()
if not provider_name:
return "relevance"
@@ -445,8 +385,8 @@ def sync_metadata_provider_selection() -> None:
auto-select the first enabled provider. This should be called after
enabling/disabling a provider.
"""
from cwa_book_downloader.core.config import config as app_config
from cwa_book_downloader.core.settings_registry import save_config_file, load_config_file
from shelfmark.core.config import config as app_config
from shelfmark.core.settings_registry import save_config_file, load_config_file
app_config.refresh()
@@ -471,11 +411,16 @@ def sync_metadata_provider_selection() -> None:
# Import provider implementations to trigger registration
# These must be imported AFTER the base classes and registry are defined
try:
from cwa_book_downloader.metadata_providers import hardcover # noqa: F401, E402
from shelfmark.metadata_providers import hardcover # noqa: F401, E402
except ImportError:
pass # Hardcover provider is optional
try:
from cwa_book_downloader.metadata_providers import openlibrary # noqa: F401, E402
from shelfmark.metadata_providers import openlibrary # noqa: F401, E402
except ImportError:
pass # Open Library provider is optional
try:
from shelfmark.metadata_providers import googlebooks # noqa: F401, E402
except ImportError:
pass # Google Books provider is optional
+452
View File
@@ -0,0 +1,452 @@
"""Google Books metadata provider.
Uses the Google Books API v1 to search and retrieve book metadata.
Requires a free API key from Google Cloud Console (~1000 requests/day quota).
API Documentation: https://developers.google.com/books/docs/v1/using
"""
import requests
from typing import Any, Dict, List, Optional
from shelfmark.core.cache import cacheable
from shelfmark.core.logger import setup_logger
from shelfmark.core.settings_registry import (
register_settings,
CheckboxField,
PasswordField,
SelectField,
ActionButton,
HeadingField,
)
from shelfmark.core.config import config as app_config
from shelfmark.metadata_providers import (
BookMetadata,
DisplayField,
MetadataProvider,
MetadataSearchOptions,
SearchType,
SortOrder,
register_provider,
register_provider_kwargs,
TextSearchField,
)
logger = setup_logger(__name__)
GOOGLE_BOOKS_BASE_URL = "https://www.googleapis.com/books/v1"
# Sort mapping - Google only supports "relevance" and "newest"
SORT_MAPPING: Dict[SortOrder, Optional[str]] = {
SortOrder.RELEVANCE: None, # Default, no param needed
SortOrder.NEWEST: "newest",
# POPULARITY, RATING, OLDEST not supported - fall back to relevance
}
@register_provider_kwargs("googlebooks")
def _googlebooks_kwargs() -> Dict[str, Any]:
"""Provide Google Books-specific constructor kwargs."""
return {"api_key": app_config.get("GOOGLEBOOKS_API_KEY", "")}
@register_provider("googlebooks")
class GoogleBooksProvider(MetadataProvider):
"""Google Books metadata provider using REST API."""
name = "googlebooks"
display_name = "Google Books"
requires_auth = True
supported_sorts = [SortOrder.RELEVANCE, SortOrder.NEWEST]
search_fields = [
TextSearchField(
key="author",
label="Author",
description="Search by author name",
),
TextSearchField(
key="title",
label="Title",
description="Search by book title",
),
]
def __init__(self, api_key: Optional[str] = None):
"""Initialize provider with optional API key (falls back to config)."""
self.api_key = api_key or app_config.get("GOOGLEBOOKS_API_KEY", "")
self.session = requests.Session()
def is_available(self) -> bool:
"""Check if provider is configured with an API key."""
return bool(self.api_key)
def search(self, options: MetadataSearchOptions) -> List[BookMetadata]:
"""Search for books using Google Books API."""
if not self.api_key:
logger.warning("Google Books API key not configured")
return []
# Handle ISBN search separately
if options.search_type == SearchType.ISBN:
result = self.search_by_isbn(options.query)
return [result] if result else []
# Build cache key from all options
fields_key = ":".join(f"{k}={v}" for k, v in sorted(options.fields.items()))
cache_key = (
f"{options.query}:{options.search_type.value}:{options.sort.value}:"
f"{options.language}:{options.limit}:{options.page}:{fields_key}"
)
return self._search_cached(cache_key, options)
@cacheable(
ttl_key="METADATA_CACHE_SEARCH_TTL",
ttl_default=300,
key_prefix="googlebooks:search",
)
def _search_cached(
self, cache_key: str, options: MetadataSearchOptions
) -> List[BookMetadata]:
"""Cached search implementation."""
# Build query string with Google Books operators
author_value = options.fields.get("author", "").strip()
title_value = options.fields.get("title", "").strip()
query_parts = []
# Add field-specific operators
if title_value:
query_parts.append(f"intitle:{title_value}")
elif options.search_type == SearchType.TITLE:
query_parts.append(f"intitle:{options.query}")
if author_value:
query_parts.append(f"inauthor:{author_value}")
elif options.search_type == SearchType.AUTHOR:
query_parts.append(f"inauthor:{options.query}")
# Fall back to general search if no specific fields
if not query_parts:
query_parts.append(options.query)
query = "+".join(query_parts)
# Build request params
params: Dict[str, Any] = {
"q": query,
"maxResults": min(options.limit, 40), # Google max is 40
"startIndex": (options.page - 1) * options.limit,
"printType": "books", # Exclude magazines
}
# Map sort order (Google only supports relevance and newest)
sort = SORT_MAPPING.get(options.sort)
if sort: # Only add if not default (relevance)
params["orderBy"] = sort
# Add language filter if specified
if options.language:
params["langRestrict"] = options.language
try:
result = self._make_request("/volumes", params)
if not result:
return []
items = result.get("items", [])
books = []
for item in items:
book = self._parse_volume(item)
if book:
books.append(book)
logger.info(f"Google Books search '{query}' returned {len(books)} results")
return books
except Exception as e:
logger.error(f"Google Books search error: {e}")
return []
@cacheable(
ttl_key="METADATA_CACHE_BOOK_TTL",
ttl_default=600,
key_prefix="googlebooks:book",
)
def get_book(self, book_id: str) -> Optional[BookMetadata]:
"""Get book details by Google Books volume ID."""
try:
result = self._make_request(f"/volumes/{book_id}", {})
if not result:
return None
return self._parse_volume(result)
except Exception as e:
logger.error(f"Google Books get_book error: {e}")
return None
@cacheable(
ttl_key="METADATA_CACHE_BOOK_TTL",
ttl_default=600,
key_prefix="googlebooks:isbn",
)
def search_by_isbn(self, isbn: str) -> Optional[BookMetadata]:
"""Search for a book by ISBN-10 or ISBN-13."""
# Clean ISBN (remove hyphens and spaces)
clean_isbn = isbn.replace("-", "").replace(" ", "").strip()
# Use ISBN operator for precise lookup
params: Dict[str, Any] = {
"q": f"isbn:{clean_isbn}",
"maxResults": 1,
}
try:
result = self._make_request("/volumes", params)
if not result:
return None
items = result.get("items", [])
if not items:
logger.debug(f"No Google Books result for ISBN: {isbn}")
return None
return self._parse_volume(items[0])
except Exception as e:
logger.error(f"Google Books ISBN search error: {e}")
return None
def _make_request(
self, endpoint: str, params: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
"""Make authenticated API request to endpoint."""
if not self.api_key:
logger.warning("Google Books API key not configured")
return None
# Add API key to params
params["key"] = self.api_key
url = f"{GOOGLE_BOOKS_BASE_URL}{endpoint}"
try:
response = self.session.get(url, params=params, timeout=15)
response.raise_for_status()
return response.json()
except requests.Timeout:
logger.warning("Google Books API request timed out")
return None
except requests.HTTPError as e:
if e.response is not None:
if e.response.status_code == 403:
# Quota exceeded or invalid API key
logger.error(
"Google Books API: quota exceeded or invalid API key (HTTP 403)"
)
elif e.response.status_code == 400:
logger.warning(f"Google Books API: bad request - {e}")
elif e.response.status_code == 404:
logger.debug("Google Books: volume not found")
else:
logger.error(f"Google Books API HTTP error: {e}")
else:
logger.error(f"Google Books API HTTP error: {e}")
return None
except Exception as e:
logger.error(f"Google Books API request failed: {e}")
return None
def _parse_volume(self, volume: Dict[str, Any]) -> Optional[BookMetadata]:
"""Parse a volume object into BookMetadata."""
try:
volume_id = volume.get("id")
volume_info = volume.get("volumeInfo", {})
title = volume_info.get("title")
if not volume_id or not title:
return None
# Authors (list)
authors = volume_info.get("authors", [])
# ISBNs - extract from industryIdentifiers
isbn_10 = None
isbn_13 = None
for identifier in volume_info.get("industryIdentifiers", []):
id_type = identifier.get("type", "")
id_value = identifier.get("identifier", "")
if id_type == "ISBN_10" and not isbn_10:
isbn_10 = id_value
elif id_type == "ISBN_13" and not isbn_13:
isbn_13 = id_value
# Cover URL - prefer larger images
image_links = volume_info.get("imageLinks", {})
cover_url = (
image_links.get("large")
or image_links.get("medium")
or image_links.get("small")
or image_links.get("thumbnail")
or image_links.get("smallThumbnail")
)
# Remove edge=curl parameter and upgrade to https
if cover_url:
cover_url = cover_url.replace("&edge=curl", "").replace(
"http://", "https://"
)
# Publisher
publisher = volume_info.get("publisher")
# Publish year - extract from publishedDate (YYYY-MM-DD or YYYY)
publish_year = None
published_date = volume_info.get("publishedDate", "")
if published_date:
try:
publish_year = int(published_date[:4])
except (ValueError, TypeError):
pass
# Language
language = volume_info.get("language")
# Genres/categories (limit to 5)
genres = volume_info.get("categories", [])[:5]
# Description (may contain HTML - leave as-is for UI to sanitize)
description = volume_info.get("description")
# Source URL
source_url = volume_info.get("infoLink")
# Build display fields - rating only
display_fields: List[DisplayField] = []
average_rating = volume_info.get("averageRating")
ratings_count = volume_info.get("ratingsCount")
if average_rating is not None:
rating_str = f"{average_rating:.1f}"
if ratings_count:
rating_str += f" ({ratings_count:,})"
display_fields.append(
DisplayField(label="Rating", value=rating_str, icon="star")
)
return BookMetadata(
provider="googlebooks",
provider_id=volume_id,
title=title,
provider_display_name="Google Books",
authors=authors,
isbn_10=isbn_10,
isbn_13=isbn_13,
cover_url=cover_url,
description=description,
publisher=publisher,
publish_year=publish_year,
language=language,
genres=genres,
source_url=source_url,
display_fields=display_fields,
)
except Exception as e:
logger.debug(f"Failed to parse Google Books volume: {e}")
return None
def _test_googlebooks_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]:
"""Test the Google Books API connection using current form values."""
current_values = current_values or {}
# Use current form values first, fall back to saved config
api_key = current_values.get("GOOGLEBOOKS_API_KEY") or app_config.get("GOOGLEBOOKS_API_KEY", "")
if not api_key:
return {
"success": False,
"message": "API key is required",
}
try:
provider = GoogleBooksProvider(api_key=api_key)
# Simple test search
result = provider._make_request("/volumes", {"q": "test", "maxResults": 1})
if result is not None and "items" in result:
return {
"success": True,
"message": "Successfully connected to Google Books API",
}
elif result is not None:
return {
"success": True,
"message": "API connected but returned no results for test query",
}
else:
return {
"success": False,
"message": "API request failed - check your API key",
}
except Exception as e:
logger.exception("Google Books connection test failed")
return {"success": False, "message": f"Connection failed: {str(e)}"}
# Sort options for settings UI
_GOOGLEBOOKS_SORT_OPTIONS = [
{"value": "relevance", "label": "Most relevant"},
{"value": "newest", "label": "Newest"},
]
@register_settings(
"googlebooks", "Google Books", icon="book", order=53, group="metadata_providers"
)
def googlebooks_settings():
"""Google Books metadata provider settings."""
return [
HeadingField(
key="googlebooks_heading",
title="Google Books",
description=(
"Access Google's comprehensive book database. "
"Requires a free API key with ~1000 requests/day quota."
),
link_url="https://console.cloud.google.com/apis/library/books.googleapis.com",
link_text="Get API Key",
),
CheckboxField(
key="GOOGLEBOOKS_ENABLED",
label="Enable Google Books",
description="Enable Google Books as a metadata provider for book searches",
default=False,
),
PasswordField(
key="GOOGLEBOOKS_API_KEY",
label="API Key",
description=(
"Get your API key from Google Cloud Console "
"(APIs & Services > Credentials)"
),
required=True,
),
ActionButton(
key="test_connection",
label="Test Connection",
description="Verify your API key works",
style="primary",
callback=_test_googlebooks_connection,
),
SelectField(
key="GOOGLEBOOKS_DEFAULT_SORT",
label="Default Sort Order",
description="Default sort order for Google Books search results.",
options=_GOOGLEBOOKS_SORT_OPTIONS,
default="relevance",
),
]
@@ -1,11 +1,12 @@
"""Hardcover.app metadata provider. Requires API key."""
import requests
from datetime import datetime
from typing import Any, Dict, List, Optional
from cwa_book_downloader.core.cache import cacheable
from cwa_book_downloader.core.logger import setup_logger
from cwa_book_downloader.core.settings_registry import (
from shelfmark.core.cache import cacheable
from shelfmark.core.logger import setup_logger
from shelfmark.core.settings_registry import (
register_settings,
CheckboxField,
PasswordField,
@@ -13,12 +14,13 @@ from cwa_book_downloader.core.settings_registry import (
ActionButton,
HeadingField,
)
from cwa_book_downloader.core.config import config as app_config
from cwa_book_downloader.metadata_providers import (
from shelfmark.core.config import config as app_config
from shelfmark.metadata_providers import (
BookMetadata,
DisplayField,
MetadataProvider,
MetadataSearchOptions,
SearchResult,
SearchType,
SortOrder,
register_provider,
@@ -29,6 +31,7 @@ from cwa_book_downloader.metadata_providers import (
logger = setup_logger(__name__)
HARDCOVER_API_URL = "https://api.hardcover.app/v1/graphql"
HARDCOVER_PAGE_SIZE = 25 # Hardcover API returns max 25 results per page
# Mapping from abstract sort order to Hardcover sort parameter
@@ -51,28 +54,47 @@ SEARCH_TYPE_FIELDS: Dict[SearchType, str] = {
def _combine_headline_description(headline: Optional[str], description: Optional[str]) -> Optional[str]:
"""Combine headline (tagline) and description into a single description.
Hardcover stores a short 'headline' (tagline/promotional text) separately
from the main description. This combines them for display.
Args:
headline: Short promotional text or tagline.
description: Full book synopsis/description.
Returns:
Combined description with headline as the first line, or just one if only one exists.
"""
"""Combine headline (tagline) and description into a single description."""
if headline and description:
# Add headline as first paragraph, followed by description
return f"{headline}\n\n{description}"
elif headline:
return headline
elif description:
return description
return headline or description
def _extract_cover_url(data: Dict, *keys: str) -> Optional[str]:
"""Extract cover URL from data dict, trying multiple keys.
Handles both string URLs and dict with 'url' key.
"""
for key in keys:
value = data.get(key)
if value:
if isinstance(value, str):
return value
if isinstance(value, dict):
return value.get("url")
return None
def _extract_publish_year(data: Dict) -> Optional[int]:
"""Extract publish year from release_year or release_date fields."""
if data.get("release_year"):
try:
return int(data["release_year"])
except (ValueError, TypeError):
pass
if data.get("release_date"):
try:
return int(str(data["release_date"])[:4])
except (ValueError, TypeError):
pass
return None
def _build_source_url(slug: str) -> Optional[str]:
"""Build Hardcover source URL from book slug."""
return f"https://hardcover.app/books/{slug}" if slug else None
@register_provider_kwargs("hardcover")
def _hardcover_kwargs() -> Dict[str, Any]:
"""Provide Hardcover-specific constructor kwargs."""
@@ -92,6 +114,7 @@ class HardcoverProvider(MetadataProvider):
SortOrder.RATING,
SortOrder.NEWEST,
SortOrder.OLDEST,
SortOrder.SERIES_ORDER,
]
search_fields = [
TextSearchField(
@@ -104,15 +127,18 @@ class HardcoverProvider(MetadataProvider):
label="Title",
description="Search by book title",
),
TextSearchField(
key="series",
label="Series",
description="Search by series name",
),
]
def __init__(self, api_key: Optional[str] = None):
"""Initialize provider with API key.
Args:
api_key: Hardcover API key. If not provided, uses config singleton.
"""
self.api_key = api_key or app_config.get("HARDCOVER_API_KEY", "")
"""Initialize provider with optional API key (falls back to config)."""
raw_key = api_key or app_config.get("HARDCOVER_API_KEY", "")
# Strip "Bearer " prefix if user pasted the full auth header from Hardcover
self.api_key = raw_key.removeprefix("Bearer ").strip() if raw_key else ""
self.session = requests.Session()
if self.api_key:
self.session.headers.update({
@@ -124,87 +150,68 @@ class HardcoverProvider(MetadataProvider):
"""Check if provider is configured with an API key."""
return bool(self.api_key)
def search(self, options: MetadataSearchOptions) -> List[BookMetadata]:
"""Search for books using Hardcover's search API.
def _build_search_params(
self, default_query: str, author: str, title: str, series: str
) -> tuple[str, Optional[str], Optional[str]]:
"""Build search query, fields, and weights based on provided values.
Args:
options: Search options (query, type, sort, pagination, fields).
Returns:
List of BookMetadata objects.
Returns (query, fields, weights) tuple. Fields/weights are None for general search.
"""
if series and not author and not title:
return series, "series_names", "1"
if author and not title and not series:
return author, "author_names", "1"
if title and not author and not series:
return title, "title,alternative_titles", "5,1"
if author and title and not series:
return f"{title} {author}", "title,alternative_titles,author_names", "5,1,3"
if series:
query = " ".join(p for p in [series, title, author] if p)
return query, "series_names,title,alternative_titles,author_names", "5,3,1,2"
return default_query, None, None
def search(self, options: MetadataSearchOptions) -> List[BookMetadata]:
"""Search for books using Hardcover's search API."""
return self.search_paginated(options).books
def search_paginated(self, options: MetadataSearchOptions) -> SearchResult:
"""Search for books with pagination info."""
if not self.api_key:
logger.warning("Hardcover API key not configured")
return []
return SearchResult(books=[], page=options.page, total_found=0, has_more=False)
# Handle ISBN search separately
if options.search_type == SearchType.ISBN:
result = self.search_by_isbn(options.query)
return [result] if result else []
books = [result] if result else []
return SearchResult(books=books, page=1, total_found=len(books), has_more=False)
# Build cache key from options (include fields for cache differentiation)
# Build cache key from options (include fields and settings for cache differentiation)
fields_key = ":".join(f"{k}={v}" for k, v in sorted(options.fields.items()))
cache_key = f"{options.query}:{options.search_type.value}:{options.sort.value}:{options.limit}:{options.page}:{fields_key}"
exclude_compilations = app_config.get("HARDCOVER_EXCLUDE_COMPILATIONS", False)
exclude_unreleased = app_config.get("HARDCOVER_EXCLUDE_UNRELEASED", False)
cache_key = f"{options.query}:{options.search_type.value}:{options.sort.value}:{options.limit}:{options.page}:{fields_key}:excl_comp={exclude_compilations}:excl_unrel={exclude_unreleased}"
return self._search_cached(cache_key, options)
@cacheable(ttl_key="METADATA_CACHE_SEARCH_TTL", ttl_default=300, key_prefix="hardcover:search")
def _search_cached(self, cache_key: str, options: MetadataSearchOptions) -> List[BookMetadata]:
"""Cached search implementation.
Args:
cache_key: Cache key (used by decorator).
options: Search options.
Returns:
List of BookMetadata objects.
"""
def _search_cached(self, cache_key: str, options: MetadataSearchOptions) -> SearchResult:
"""Cached search implementation."""
# Determine query and fields based on custom search fields
# Field-first search: when a specific field has a value, search that field
# Note: Hardcover API requires 'weights' when using 'fields' parameter
author_value = options.fields.get("author", "").strip()
title_value = options.fields.get("title", "").strip()
series_value = options.fields.get("series", "").strip()
logger.debug(f"Field-first search check: author_value='{author_value}', title_value='{title_value}'")
# Build query and field configuration based on which fields are provided
query, search_fields, search_weights = self._build_search_params(
options.query, author_value, title_value, series_value
)
# Determine what to search and which fields to target
# Note: Hardcover API requires 'weights' when using 'fields' parameter
if author_value and not title_value:
# Author-only search: search author_names field with author query
query = author_value
search_fields = "author_names"
search_weights = "1"
logger.debug(f"Author-only search: query='{query}', fields='{search_fields}'")
elif title_value and not author_value:
# Title-only search: search title fields with title query
query = title_value
search_fields = "title,alternative_titles"
search_weights = "5,1"
logger.debug(f"Title-only search: query='{query}', fields='{search_fields}'")
elif author_value and title_value:
# Both provided: combine into query, search both fields
query = f"{title_value} {author_value}"
search_fields = "title,alternative_titles,author_names"
search_weights = "5,1,3"
logger.debug(f"Combined search: query='{query}', fields='{search_fields}'")
else:
# No custom fields: use general query with all default fields
query = options.query
search_fields = None
search_weights = None
logger.debug(f"General search: query='{query}', no field restriction")
# Build GraphQL query with optional fields/weights parameters
# Build GraphQL query - include fields/weights parameters only when needed
if search_fields:
graphql_query = """
query SearchBooks($query: String!, $limit: Int!, $page: Int!, $sort: String, $fields: String, $weights: String) {
search(
query: $query,
query_type: "Book",
per_page: $limit,
page: $page,
sort: $sort,
fields: $fields,
weights: $weights
) {
search(query: $query, query_type: "Book", per_page: $limit, page: $page, sort: $sort, fields: $fields, weights: $weights) {
results
}
}
@@ -212,13 +219,7 @@ class HardcoverProvider(MetadataProvider):
else:
graphql_query = """
query SearchBooks($query: String!, $limit: Int!, $page: Int!, $sort: String) {
search(
query: $query,
query_type: "Book",
per_page: $limit,
page: $page,
sort: $sort
) {
search(query: $query, query_type: "Book", per_page: $limit, page: $page, sort: $sort) {
results
}
}
@@ -238,73 +239,142 @@ class HardcoverProvider(MetadataProvider):
variables["fields"] = search_fields
variables["weights"] = search_weights
logger.debug(f"GraphQL variables: {variables}")
try:
result = self._execute_query(graphql_query, variables)
if not result:
logger.debug("Hardcover search: No result from API")
return []
return SearchResult(books=[], page=options.page, total_found=0, has_more=False)
search_data = result.get("search", {})
# Results is a Typesense response object with hits array
results_obj = search_data.get("results", {})
# Extract hits from Typesense response
results_obj = result.get("search", {}).get("results", {})
if isinstance(results_obj, dict):
hits = results_obj.get("hits", [])
found_count = results_obj.get("found", 0)
else:
hits = results_obj if isinstance(results_obj, list) else []
found_count = 0
# Parse the search results - each hit has a 'document' field
# Parse hits, filtering compilations and unreleased books if enabled
exclude_compilations = app_config.get("HARDCOVER_EXCLUDE_COMPILATIONS", False)
exclude_unreleased = app_config.get("HARDCOVER_EXCLUDE_UNRELEASED", False)
current_year = datetime.now().year
books = []
for hit in hits:
# Get the document from the hit
item = hit.get("document", hit) if isinstance(hit, dict) else hit
if isinstance(item, dict):
book = self._parse_search_result(item)
if book:
books.append(book)
if not isinstance(item, dict):
continue
if exclude_compilations and item.get("compilation"):
continue
if exclude_unreleased:
release_year = item.get("release_year")
if release_year is not None and release_year > current_year:
continue
book = self._parse_search_result(item)
if book:
books.append(book)
# If series order sort is selected and series field is provided,
# filter to exact matches and sort by position
if options.sort == SortOrder.SERIES_ORDER and series_value and books:
books = self._apply_series_ordering(books, series_value)
logger.info(f"Hardcover search '{query}' (fields={search_fields}) returned {len(books)} results")
return books
# Calculate if there are more results
results_so_far = (options.page - 1) * HARDCOVER_PAGE_SIZE + len(hits)
has_more = results_so_far < found_count
return SearchResult(
books=books,
page=options.page,
total_found=found_count,
has_more=has_more
)
except Exception as e:
logger.error(f"Hardcover search error: {e}")
return []
return SearchResult(books=[], page=options.page, total_found=0, has_more=False)
def _apply_series_ordering(self, books: List[BookMetadata], series_name: str) -> List[BookMetadata]:
"""Filter books to exact series match and sort by series position."""
series_name_lower = series_name.lower()
books_with_position = []
for book in books:
# Fetch full book details to get series info
full_book = self.get_book(book.provider_id)
if not full_book or not full_book.series_name:
continue
# Exact match on series name
if full_book.series_name.lower() != series_name_lower:
continue
# Merge series info into the search result book
book.series_name = full_book.series_name
book.series_position = full_book.series_position
book.series_count = full_book.series_count
# Also grab description if search didn't have it
if not book.description and full_book.description:
book.description = full_book.description
books_with_position.append(book)
# Sort by series position (books without position go last)
books_with_position.sort(key=lambda b: (b.series_position is None, b.series_position or 0))
logger.debug(f"Series ordering: filtered {len(books)} -> {len(books_with_position)} books for '{series_name}'")
return books_with_position
@cacheable(ttl_key="METADATA_CACHE_BOOK_TTL", ttl_default=600, key_prefix="hardcover:book")
def get_book(self, book_id: str) -> Optional[BookMetadata]:
"""Get book details by Hardcover ID.
Args:
book_id: Hardcover book ID.
Returns:
BookMetadata or None if not found.
"""
"""Get book details by Hardcover ID."""
if not self.api_key:
logger.warning("Hardcover API key not configured")
return None
# Query for specific book by ID
# Note: API has max depth of 3, so use cached_* fields instead of nested relationships
# Use contributions with filter to get only primary authors (not translators/narrators)
# Also include cached_contributors as fallback if contributions is empty
# Include featured_book_series for series info
# Include editions with titles and languages for localized search support
graphql_query = """
query GetBook($id: Int!) {
books(where: {id: {_eq: $id}}, limit: 1) {
id
title
subtitle
slug
release_date
headline
description
pages
cached_image
cached_contributors
cached_tags
cached_contributors
contributions(where: {contribution: {_eq: "Author"}}) {
author {
name
}
}
default_physical_edition {
isbn_10
isbn_13
}
featured_book_series {
position
series {
name
primary_books_count
}
}
editions(limit: 20, order_by: {users_count: desc}) {
title
language {
language
code2
code3
}
}
}
}
"""
@@ -330,14 +400,7 @@ class HardcoverProvider(MetadataProvider):
@cacheable(ttl_key="METADATA_CACHE_BOOK_TTL", ttl_default=600, key_prefix="hardcover:isbn")
def search_by_isbn(self, isbn: str) -> Optional[BookMetadata]:
"""Search for a book by ISBN.
Args:
isbn: ISBN-10 or ISBN-13.
Returns:
BookMetadata or None if not found.
"""
"""Search for a book by ISBN-10 or ISBN-13."""
if not self.api_key:
logger.warning("Hardcover API key not configured")
return None
@@ -346,7 +409,7 @@ class HardcoverProvider(MetadataProvider):
clean_isbn = isbn.replace("-", "").strip()
# Search for editions with matching ISBN
# Note: API has max depth of 3, so use cached_* fields instead of nested relationships
# Use contributions with filter to get only primary authors (not translators/narrators)
graphql_query = """
query SearchByISBN($isbn: String!) {
editions(
@@ -363,14 +426,19 @@ class HardcoverProvider(MetadataProvider):
book {
id
title
subtitle
slug
release_date
headline
description
pages
cached_image
cached_contributors
cached_tags
contributions(where: {contribution: {_eq: "Author"}}) {
author {
name
}
}
}
}
}
@@ -402,15 +470,7 @@ class HardcoverProvider(MetadataProvider):
return None
def _execute_query(self, query: str, variables: Dict[str, Any]) -> Optional[Dict]:
"""Execute a GraphQL query.
Args:
query: GraphQL query string.
variables: Query variables.
Returns:
Response data dict or None on error.
"""
"""Execute a GraphQL query and return data or None on error."""
try:
response = self.session.post(
HARDCOVER_API_URL,
@@ -441,14 +501,7 @@ class HardcoverProvider(MetadataProvider):
return None
def _parse_search_result(self, item: Dict) -> Optional[BookMetadata]:
"""Parse a search result item into BookMetadata.
Args:
item: Search result item dict.
Returns:
BookMetadata or None if parsing fails.
"""
"""Parse a search result item into BookMetadata."""
try:
book_id = item.get("id") or item.get("document", {}).get("id")
title = item.get("title") or item.get("document", {}).get("title")
@@ -456,37 +509,30 @@ class HardcoverProvider(MetadataProvider):
if not book_id or not title:
return None
# Extract authors from various possible fields
# Extract authors - use contribution_types to filter author_names if available
authors = []
if "author_names" in item:
authors = item["author_names"] if isinstance(item["author_names"], list) else [item["author_names"]]
elif "cached_contributors" in item:
for contrib in item.get("cached_contributors", []):
if isinstance(contrib, dict) and contrib.get("name"):
authors.append(contrib["name"])
elif isinstance(contrib, str):
authors.append(contrib)
# Get cover URL
cover_url = None
if "image" in item and item["image"]:
cover_url = item["image"] if isinstance(item["image"], str) else item["image"].get("url")
author_names = item.get("author_names", [])
if isinstance(author_names, str):
author_names = [author_names]
# Extract year - prefer release_year if available, fall back to release_date
publish_year = None
if "release_year" in item and item["release_year"]:
try:
publish_year = int(item["release_year"])
except (ValueError, TypeError):
pass
elif "release_date" in item and item["release_date"]:
try:
publish_year = int(str(item["release_date"])[:4])
except (ValueError, TypeError):
pass
contribution_types = item.get("contribution_types", [])
slug = item.get("slug", "")
source_url = f"https://hardcover.app/books/{slug}" if slug else None
# If we have parallel arrays, filter to only "Author" contributions
if contribution_types and len(contribution_types) == len(author_names):
for name, contrib_type in zip(author_names, contribution_types):
if contrib_type == "Author":
authors.append(name)
elif author_names:
# No contribution_types or length mismatch - use all names as fallback
authors = author_names
# Normalize whitespace in author names (some API data has multiple spaces)
authors = [" ".join(name.split()) for name in authors]
cover_url = _extract_cover_url(item, "image")
publish_year = _extract_publish_year(item)
source_url = _build_source_url(item.get("slug", ""))
# Build display fields from Hardcover-specific data
display_fields = []
@@ -510,10 +556,14 @@ class HardcoverProvider(MetadataProvider):
description = item.get("description")
full_description = _combine_headline_description(headline, description)
# Extract subtitle if available in search results
subtitle = item.get("subtitle")
return BookMetadata(
provider="hardcover",
provider_id=str(book_id),
title=title,
subtitle=subtitle,
provider_display_name="Hardcover",
authors=authors,
cover_url=cover_url,
@@ -528,48 +578,36 @@ class HardcoverProvider(MetadataProvider):
return None
def _parse_book(self, book: Dict) -> BookMetadata:
"""Parse a book object into BookMetadata.
Args:
book: Book data dict from GraphQL response.
Returns:
BookMetadata object.
"""
# Extract authors from cached_contributors (json array) or contributions relationship
"""Parse a book object into BookMetadata."""
# Extract authors - try contributions first (filtered), fall back to cached_contributors
authors = []
if book.get("cached_contributors"):
for contrib in book["cached_contributors"]:
if isinstance(contrib, dict) and contrib.get("name"):
authors.append(contrib["name"])
contributions = book.get("contributions") or []
cached_contributors = book.get("cached_contributors") or []
# Try contributions first (filtered to "Author" role only - cleaner data)
for contrib in contributions:
author = contrib.get("author", {})
if author and author.get("name"):
authors.append(author["name"])
# Fallback to cached_contributors if no authors found
if not authors:
for contrib in cached_contributors:
if isinstance(contrib, dict):
# Handle nested structure: {"author": {"name": "..."}, "contribution": ...}
if contrib.get("author", {}).get("name"):
authors.append(contrib["author"]["name"])
# Handle flat structure: {"name": "..."}
elif contrib.get("name"):
authors.append(contrib["name"])
elif isinstance(contrib, str):
authors.append(contrib)
elif book.get("contributions"):
# Fallback for contributions relationship (if used)
for contrib in book["contributions"]:
author = contrib.get("author", {})
if author and author.get("name"):
authors.append(author["name"])
# Get cover URL from cached_image (jsonb) or image relationship
cover_url = None
if book.get("cached_image"):
cached = book["cached_image"]
if isinstance(cached, dict):
cover_url = cached.get("url")
elif isinstance(cached, str):
cover_url = cached
elif book.get("image"):
img = book["image"]
cover_url = img if isinstance(img, str) else img.get("url")
# Normalize whitespace in author names (some API data has multiple spaces)
authors = [" ".join(name.split()) for name in authors]
# Extract year from release_date
publish_year = None
if book.get("release_date"):
try:
publish_year = int(str(book["release_date"])[:4])
except (ValueError, TypeError):
pass
cover_url = _extract_cover_url(book, "cached_image", "image")
publish_year = _extract_publish_year(book)
# Extract genres from cached_tags
genres = []
@@ -600,18 +638,54 @@ class HardcoverProvider(MetadataProvider):
if isbn_10 and isbn_13:
break
slug = book.get("slug", "")
source_url = f"https://hardcover.app/books/{slug}" if slug else None
source_url = _build_source_url(book.get("slug", ""))
# Combine headline and description if both present
headline = book.get("headline")
description = book.get("description")
full_description = _combine_headline_description(headline, description)
# Extract series info from featured_book_series
series_name = None
series_position = None
series_count = None
featured_series = book.get("featured_book_series")
if featured_series:
series_position = featured_series.get("position")
series_data = featured_series.get("series")
if series_data:
series_name = series_data.get("name")
series_count = series_data.get("primary_books_count")
# Extract titles by language from editions
# This allows searching with localized titles when language filter is active
titles_by_language: Dict[str, str] = {}
editions = book.get("editions", [])
for edition in editions:
edition_title = edition.get("title")
lang_data = edition.get("language")
if edition_title and lang_data:
# Store by various language identifiers for flexible matching
# Language name (e.g., "German", "English")
lang_name = lang_data.get("language")
# 2-letter code (e.g., "de", "en")
code2 = lang_data.get("code2")
# 3-letter code (e.g., "deu", "eng")
code3 = lang_data.get("code3")
# Store with all available keys (first title wins for each language)
if lang_name and lang_name not in titles_by_language:
titles_by_language[lang_name] = edition_title
if code2 and code2 not in titles_by_language:
titles_by_language[code2] = edition_title
if code3 and code3 not in titles_by_language:
titles_by_language[code3] = edition_title
return BookMetadata(
provider="hardcover",
provider_id=str(book["id"]),
title=book["title"],
subtitle=book.get("subtitle"),
provider_display_name="Hardcover",
authors=authors,
isbn_10=isbn_10,
@@ -621,30 +695,31 @@ class HardcoverProvider(MetadataProvider):
publish_year=publish_year,
genres=genres,
source_url=source_url,
series_name=series_name,
series_position=series_position,
series_count=series_count,
titles_by_language=titles_by_language,
)
def _test_hardcover_connection() -> Dict[str, Any]:
"""Test the Hardcover API connection."""
from cwa_book_downloader.core.config import config as app_config
from cwa_book_downloader.core.settings_registry import save_config_file, load_config_file
from cwa_book_downloader.metadata_providers import get_provider_kwargs
def _test_hardcover_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Test the Hardcover API connection using current form values."""
from shelfmark.core.config import config as app_config
# Refresh config to pick up any recently saved settings
app_config.refresh()
current_values = current_values or {}
kwargs = get_provider_kwargs("hardcover")
api_key = kwargs.get("api_key")
# Use current form values first, fall back to saved config
raw_key = current_values.get("HARDCOVER_API_KEY") or app_config.get("HARDCOVER_API_KEY", "")
# Strip "Bearer " prefix if user pasted the full auth header from Hardcover
api_key = raw_key.removeprefix("Bearer ").strip() if raw_key else ""
# Debug: log key info
key_len = len(api_key) if api_key else 0
key_preview = f"{api_key[:10]}...{api_key[-10:]}" if key_len > 20 else "(too short)"
logger.info(f"Hardcover test: key length={key_len}, preview={key_preview}")
logger.debug(f"Hardcover test: key length={key_len}")
if not api_key:
# Clear any stored username since there's no key
_save_connected_username(None)
return {"success": False, "message": "No API key configured. Save your key and try again."}
return {"success": False, "message": "API key is required"}
if key_len < 100:
return {"success": False, "message": f"API key seems too short ({key_len} chars). Expected 500+ chars."}
@@ -678,7 +753,7 @@ def _test_hardcover_connection() -> Dict[str, Any]:
def _save_connected_username(username: Optional[str]) -> None:
"""Save or clear the connected username in config."""
from cwa_book_downloader.core.settings_registry import save_config_file, load_config_file
from shelfmark.core.settings_registry import save_config_file, load_config_file
config = load_config_file("hardcover")
if username:
@@ -690,7 +765,7 @@ def _save_connected_username(username: Optional[str]) -> None:
def _get_connected_username() -> Optional[str]:
"""Get the stored connected username."""
from cwa_book_downloader.core.settings_registry import load_config_file
from shelfmark.core.settings_registry import load_config_file
config = load_config_file("hardcover")
return config.get("_connected_username")
@@ -749,4 +824,16 @@ def hardcover_settings():
default="relevance",
env_supported=False, # UI-only setting
),
CheckboxField(
key="HARDCOVER_EXCLUDE_COMPILATIONS",
label="Exclude Compilations",
description="Filter out compilations, anthologies, and omnibus editions from search results",
default=False,
),
CheckboxField(
key="HARDCOVER_EXCLUDE_UNRELEASED",
label="Exclude Unreleased Books",
description="Filter out books with a release year in the future",
default=False,
),
]
@@ -1,22 +1,23 @@
"""Open Library metadata provider. No API key required, rate limited."""
import re
import time
import threading
from collections import deque
from typing import Any, Deque, Dict, List, Optional, Union
from typing import Any, Deque, Dict, List, Optional
import requests
from cwa_book_downloader.core.cache import cacheable
from cwa_book_downloader.core.logger import setup_logger
from cwa_book_downloader.core.settings_registry import (
from shelfmark.core.cache import cacheable
from shelfmark.core.logger import setup_logger
from shelfmark.core.settings_registry import (
register_settings,
CheckboxField,
SelectField,
ActionButton,
HeadingField,
)
from cwa_book_downloader.metadata_providers import (
from shelfmark.metadata_providers import (
BookMetadata,
DisplayField,
MetadataProvider,
@@ -42,23 +43,14 @@ class RateLimiter:
"""Simple sliding window rate limiter."""
def __init__(self, max_requests: int, window_seconds: int):
"""Initialize rate limiter.
Args:
max_requests: Maximum requests allowed in the window.
window_seconds: Time window in seconds.
"""
"""Initialize rate limiter with max requests per time window."""
self.max_requests = max_requests
self.window_seconds = window_seconds
self.timestamps: Deque[float] = deque()
self.lock = threading.Lock()
def wait_if_needed(self) -> None:
"""Block until a request is allowed.
Thread-safe implementation that calculates wait time with lock held,
then sleeps without holding the lock to avoid blocking other threads.
"""
"""Block until a request is allowed (thread-safe)."""
wait_time = 0
# Calculate wait time with lock held
@@ -139,14 +131,7 @@ class OpenLibraryProvider(MetadataProvider):
return True
def search(self, options: MetadataSearchOptions) -> List[BookMetadata]:
"""Search for books using Open Library's search API.
Args:
options: Search options (query, type, sort, language, pagination, fields).
Returns:
List of BookMetadata objects.
"""
"""Search for books using Open Library's search API."""
# Handle ISBN search separately
if options.search_type == SearchType.ISBN:
result = self.search_by_isbn(options.query)
@@ -159,15 +144,7 @@ class OpenLibraryProvider(MetadataProvider):
@cacheable(ttl_key="METADATA_CACHE_SEARCH_TTL", ttl_default=300, key_prefix="openlibrary:search")
def _search_cached(self, cache_key: str, options: MetadataSearchOptions) -> List[BookMetadata]:
"""Cached search implementation.
Args:
cache_key: Cache key (used by decorator).
options: Search options.
Returns:
List of BookMetadata objects.
"""
"""Cached search implementation."""
_rate_limiter.wait_if_needed()
# Build query params
@@ -240,14 +217,7 @@ class OpenLibraryProvider(MetadataProvider):
@cacheable(ttl_key="METADATA_CACHE_BOOK_TTL", ttl_default=600, key_prefix="openlibrary:book")
def get_book(self, book_id: str) -> Optional[BookMetadata]:
"""Get book details by Open Library work ID.
Args:
book_id: Open Library work ID (e.g., "OL12345W").
Returns:
BookMetadata or None if not found.
"""
"""Get book details by Open Library work ID (e.g., 'OL12345W')."""
_rate_limiter.wait_if_needed()
# Normalize the book_id format
@@ -281,14 +251,7 @@ class OpenLibraryProvider(MetadataProvider):
@cacheable(ttl_key="METADATA_CACHE_BOOK_TTL", ttl_default=600, key_prefix="openlibrary:isbn")
def search_by_isbn(self, isbn: str) -> Optional[BookMetadata]:
"""Search for a book by ISBN.
Args:
isbn: ISBN-10 or ISBN-13.
Returns:
BookMetadata or None if not found.
"""
"""Search for a book by ISBN-10 or ISBN-13."""
# Clean ISBN
clean_isbn = isbn.replace("-", "").strip()
@@ -343,14 +306,7 @@ class OpenLibraryProvider(MetadataProvider):
return None
def _parse_search_doc(self, doc: dict) -> Optional[BookMetadata]:
"""Parse a search document into BookMetadata.
Args:
doc: Search result document from Open Library.
Returns:
BookMetadata or None if parsing fails.
"""
"""Parse a search document into BookMetadata."""
try:
# Extract work ID from key
key = doc.get("key", "")
@@ -364,17 +320,10 @@ class OpenLibraryProvider(MetadataProvider):
if not isinstance(authors, list):
authors = [authors] if authors else []
# Get ISBNs
# Get ISBNs - find first ISBN-10 and ISBN-13
isbns = doc.get("isbn", [])
isbn_10 = None
isbn_13 = None
for isbn in isbns:
if len(isbn) == 10 and not isbn_10:
isbn_10 = isbn
elif len(isbn) == 13 and not isbn_13:
isbn_13 = isbn
if isbn_10 and isbn_13:
break
isbn_10 = next((i for i in isbns if len(i) == 10), None)
isbn_13 = next((i for i in isbns if len(i) == 13), None)
# Get cover URL
cover_id = doc.get("cover_i")
@@ -426,15 +375,7 @@ class OpenLibraryProvider(MetadataProvider):
return None
def _parse_work(self, work: dict, work_id: str) -> Optional[BookMetadata]:
"""Parse a work object into BookMetadata.
Args:
work: Work data from Open Library API.
work_id: The work ID.
Returns:
BookMetadata or None if parsing fails.
"""
"""Parse a work object into BookMetadata."""
try:
title = work.get("title")
if not title:
@@ -484,15 +425,7 @@ class OpenLibraryProvider(MetadataProvider):
return None
def _parse_edition(self, edition: dict, isbn: str) -> Optional[BookMetadata]:
"""Parse an edition object into BookMetadata (fallback for ISBN lookup).
Args:
edition: Edition data from Open Library API.
isbn: The ISBN used for lookup.
Returns:
BookMetadata or None if parsing fails.
"""
"""Parse an edition object into BookMetadata (fallback for ISBN lookup)."""
try:
title = edition.get("title")
if not title:
@@ -524,7 +457,6 @@ class OpenLibraryProvider(MetadataProvider):
publish_date = edition.get("publish_date", "")
if publish_date:
# Try to extract year from various formats
import re
year_match = re.search(r'\b(19|20)\d{2}\b', publish_date)
if year_match:
publish_year = int(year_match.group())
@@ -547,14 +479,7 @@ class OpenLibraryProvider(MetadataProvider):
return None
def _get_author_name(self, author_key: str) -> Optional[str]:
"""Get author name from author key.
Args:
author_key: Open Library author key (e.g., "/authors/OL123A").
Returns:
Author name or None.
"""
"""Get author name from author key (e.g., '/authors/OL123A')."""
_rate_limiter.wait_if_needed()
try:
@@ -6,8 +6,8 @@ from enum import Enum
from threading import Event
from typing import List, Optional, Dict, Type, Callable, Literal, Any
from cwa_book_downloader.core.models import DownloadTask
from cwa_book_downloader.metadata_providers import BookMetadata
from shelfmark.core.models import DownloadTask
from shelfmark.metadata_providers import BookMetadata
class ReleaseProtocol(str, Enum):
@@ -34,18 +34,13 @@ class Release:
indexer: Optional[str] = None # Source name for display
seeders: Optional[int] = None # For torrents
peers: Optional[str] = None # For torrents: "seeders/leechers" display string
content_type: Optional[str] = None # "ebook" or "audiobook" - preserved from search
extra: Dict = field(default_factory=dict) # Source-specific metadata
@dataclass
class DownloadProgress:
"""Progress update structure.
DEPRECATED: This class is deprecated and will be removed.
The new DownloadHandler.download() uses simpler callbacks:
- progress_callback(float) for progress percentage
- status_callback(str, Optional[str]) for status and message
"""
"""DEPRECATED: Use progress_callback and status_callback instead."""
status: str # "queued", "resolving", "downloading", "complete", "failed"
progress: float # 0-100
status_message: Optional[str] = None
@@ -91,6 +86,8 @@ class ColumnSchema:
color_hint: Optional[ColumnColorHint] = None # For BADGE render type
fallback: str = "-" # Value to show when data is missing
uppercase: bool = False # Force uppercase display
sortable: bool = False # Show in sort dropdown (opt-in)
sort_key: Optional[str] = None # Field to sort by (defaults to `key` if None)
class LeadingCellType(str, Enum):
@@ -109,12 +106,23 @@ class LeadingCellConfig:
uppercase: bool = False # Force uppercase for badge text
@dataclass
class SourceActionButton:
"""Action button configuration for a release source."""
label: str # Button text (e.g., "Refresh search")
action: str = "expand" # Action type: "expand" triggers expand_search
@dataclass
class ReleaseColumnConfig:
"""Complete column configuration for a release source."""
columns: List[ColumnSchema]
grid_template: str = "minmax(0,2fr) 60px 80px 80px" # CSS grid-template-columns
leading_cell: Optional[LeadingCellConfig] = None # Defaults to thumbnail mode if None
online_servers: Optional[List[str]] = None # For IRC: list of currently online server nicks
cache_ttl_seconds: Optional[int] = None # How long to cache results (default: 5 min)
supported_filters: Optional[List[str]] = None # Which filters this source supports: ["format", "language"]
action_button: Optional[SourceActionButton] = None # Custom action button (replaces default expand search)
def serialize_column_config(config: ReleaseColumnConfig) -> Dict[str, Any]:
@@ -134,6 +142,8 @@ def serialize_column_config(config: ReleaseColumnConfig) -> Dict[str, Any]:
} if col.color_hint else None,
"fallback": col.fallback,
"uppercase": col.uppercase,
"sortable": col.sortable,
"sort_key": col.sort_key,
}
for col in config.columns
],
@@ -152,6 +162,25 @@ def serialize_column_config(config: ReleaseColumnConfig) -> Dict[str, Any]:
"uppercase": config.leading_cell.uppercase,
}
# Include online_servers if provided (e.g., for IRC source)
if config.online_servers is not None:
result["online_servers"] = config.online_servers
# Include cache TTL if specified (sources can request longer caching)
if config.cache_ttl_seconds is not None:
result["cache_ttl_seconds"] = config.cache_ttl_seconds
# Include supported filters (sources declare which filters they support)
if config.supported_filters is not None:
result["supported_filters"] = config.supported_filters
# Include action button if specified (replaces default expand search)
if config.action_button is not None:
result["action_button"] = {
"label": config.action_button.label,
"action": config.action_button.action,
}
return result
@@ -188,7 +217,8 @@ def _default_column_config() -> ReleaseColumnConfig:
hide_mobile=False, # Size shown on mobile
),
],
grid_template="minmax(0,2fr) 60px 80px 80px"
grid_template="minmax(0,2fr) 60px 80px 80px",
supported_filters=["format", "language"], # Default: both filters available
)
@@ -196,9 +226,17 @@ class ReleaseSource(ABC):
"""Interface for searching a release source."""
name: str # "direct", "prowlarr"
display_name: str # "Direct Download", "Prowlarr"
supported_content_types: List[str] = ["ebook", "audiobook"] # Content types this source supports
can_be_default: bool = True # Whether this source can be selected as default in settings
@abstractmethod
def search(self, book: BookMetadata) -> List[Release]:
def search(
self,
book: BookMetadata,
expand_search: bool = False,
languages: Optional[List[str]] = None,
content_type: str = "ebook"
) -> List[Release]:
"""Search for releases of a book."""
pass
@@ -209,41 +247,13 @@ class ReleaseSource(ABC):
@classmethod
def get_column_config(cls) -> ReleaseColumnConfig:
"""Get the column configuration for this source's release list UI.
Override this method in subclasses to provide custom columns.
Default implementation returns standard columns (language, format, size).
"""
"""Get column configuration for release list UI. Override for custom columns."""
return _default_column_config()
class DownloadHandler(ABC):
"""Interface for executing downloads from a source.
## Staging Architecture
Handlers are responsible for getting files into the STAGING directory (TMP_DIR).
The orchestrator handles all post-processing and moving to the INGEST directory.
This means handlers should:
1. Download/retrieve the file to the staging directory
2. Return the path to the staged file
3. NOT move files to the ingest folder (orchestrator does this)
Examples by source type:
- **Direct downloads**: Download directly to staging dir
- **Torrents**: Copy completed file from torrent client to staging (keep seeding)
- **Usenet**: Move completed file from NZB client to staging
Use the staging helpers from orchestrator:
- `get_staging_dir()` - Get the staging directory path
- `get_staging_path(task_id, ext)` - Get a staging path for a task
- `stage_file(source, task_id, copy=False)` - Stage a file (copy or move)
The orchestrator then handles:
- Archive extraction (RAR/ZIP)
- Custom script execution
- Moving to the final ingest folder
"""Interface for executing downloads. Handlers stage files to TMP_DIR;
orchestrator handles post-processing and move to INGEST_DIR.
"""
@abstractmethod
@@ -254,22 +264,7 @@ class DownloadHandler(ABC):
progress_callback: Callable[[float], None],
status_callback: Callable[[str, Optional[str]], None]
) -> Optional[str]:
"""
Execute download and return path to STAGED file.
Handlers should download/copy files to the staging directory (TMP_DIR),
NOT directly to the ingest folder. The orchestrator handles post-processing
(archive extraction, custom scripts) and final move to ingest.
Args:
task: The download task with task_id and display info
cancel_flag: Event to check for cancellation
progress_callback: Called with progress percentage (0-100)
status_callback: Called with (status, message) for status updates
Returns:
Path to staged file (in TMP_DIR) if successful, None otherwise
"""
"""Execute download and return path to staged file in TMP_DIR."""
pass
@abstractmethod
@@ -315,26 +310,29 @@ def get_handler(name: str) -> DownloadHandler:
def list_available_sources() -> List[dict]:
"""For frontend - list sources that are configured."""
return [
{"name": name, "display_name": src().display_name}
for name, src in _SOURCES.items()
if src().is_available()
]
"""List all registered sources with their availability status."""
result = []
for name, src_class in _SOURCES.items():
instance = src_class()
result.append({
"name": name,
"display_name": instance.display_name,
"enabled": instance.is_available(),
"supported_content_types": getattr(instance, 'supported_content_types', ["ebook", "audiobook"]),
"can_be_default": getattr(instance, 'can_be_default', True),
})
return result
def get_source_display_name(name: str) -> str:
"""Get display name for a source by its identifier.
Falls back to title-cased name if source not found.
"""
"""Get display name for a source by its identifier."""
if name in _SOURCES:
return _SOURCES[name]().display_name
# Fallback: convert snake_case to Title Case
return name.replace('_', ' ').title()
# Import source implementations to trigger registration
# These must be imported AFTER the base classes and registry are defined
from cwa_book_downloader.release_sources import direct_download # noqa: F401, E402
# from cwa_book_downloader.release_sources import prowlarr # noqa: F401, E402
from shelfmark.release_sources import direct_download # noqa: F401, E402
from shelfmark.release_sources import prowlarr # noqa: F401, E402
from shelfmark.release_sources import irc # noqa: F401, E402
@@ -2,7 +2,6 @@
import itertools
import json
import os
import re
import time
from pathlib import Path
@@ -10,16 +9,19 @@ from threading import Event
from typing import Callable, Dict, List, Optional
from urllib.parse import quote
import requests
from bs4 import BeautifulSoup, NavigableString, Tag
from cwa_book_downloader.download import http as downloader
from cwa_book_downloader.download import network
from cwa_book_downloader.config.env import DEBUG_SKIP_SOURCES, DOWNLOAD_PATHS, TMP_DIR
from cwa_book_downloader.core.config import config
from cwa_book_downloader.core.logger import setup_logger
from cwa_book_downloader.core.models import BookInfo, SearchFilters, DownloadTask
from cwa_book_downloader.metadata_providers import BookMetadata
from cwa_book_downloader.release_sources import (
from shelfmark.download import http as downloader
from shelfmark.download import network
from shelfmark.config.env import DEBUG_SKIP_SOURCES, TMP_DIR
from shelfmark.core.config import config
from shelfmark.core.utils import CONTENT_TYPES
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import BookInfo, SearchFilters, DownloadTask
from shelfmark.metadata_providers import BookMetadata
from shelfmark.release_sources import (
Release,
ReleaseProtocol,
ReleaseSource,
@@ -36,7 +38,7 @@ from cwa_book_downloader.release_sources import (
logger = setup_logger(__name__)
_aa_slow_rotation = itertools.count()
_url_source_types: dict[str, str] = {}
_url_source_types: Dict[str, str] = {}
if DEBUG_SKIP_SOURCES:
logger.warning("DEBUG_SKIP_SOURCES active: skipping sources %s", DEBUG_SKIP_SOURCES)
@@ -60,50 +62,75 @@ _CF_BYPASS_REQUIRED = frozenset({"aa-slow-nowait", "aa-slow-wait", "zlib", "weli
# Sources whose URLs come from AA page (multiple mirrors)
_AA_PAGE_SOURCES = frozenset({"aa-slow-nowait", "aa-slow-wait"})
# URL templates for sources that generate URLs from MD5 hash
_MD5_URL_TEMPLATES = {
"zlib": "https://z-lib.fm/md5/{md5}",
"libgen": "https://libgen.gl/ads.php?md5={md5}",
"welib": "https://welib.org/md5/{md5}",
}
def _get_md5_url_template(source_id: str) -> Optional[str]:
"""Get URL template for MD5-based sources from centralized config."""
from shelfmark.core import mirrors
def _get_source_priority() -> list[dict]:
"""Get the current source priority configuration."""
return config.get("SOURCE_PRIORITY") or []
if source_id == "zlib":
return mirrors.get_zlib_url_template()
elif source_id == "welib":
return mirrors.get_welib_url_template()
return None
def _get_libgen_domains() -> List[str]:
"""Get LibGen domains from centralized config."""
from shelfmark.core import mirrors
return mirrors.get_libgen_mirrors()
_LIBGEN_GET_PATTERNS = [
re.compile(r'<a\s+href=["\']([^"\']*get\.php\?md5=[^"\']+&key=[^"\']+)["\'][^>]*>\s*<h2[^>]*>GET</h2>\s*</a>', re.IGNORECASE),
re.compile(r'<a[^>]+href=["\']([^"\']*get\.php\?md5=[^"\']+&(?:amp;)?key=[^"\']+)["\']', re.IGNORECASE),
re.compile(r'<a\s+href=["\']([^"\']*get\.php[^"\']*)["\'][^>]*>[\s\S]*?<h2[^>]*>GET</h2>', re.IGNORECASE),
re.compile(r'href=["\']([^"\']*get\.php\?[^"\']*md5=[^"\']*&[^"\']*key=[^"\']+)["\']', re.IGNORECASE),
]
def _get_source_priority() -> List[Dict]:
"""Get the full source priority list.
Fast sources (AA Fast, LibGen) are hardcoded first.
Slow sources come from user config.
"""
# Fast sources - always first, hardcoded
fast_sources = []
# AA Fast only if donator key is set
if config.get("AA_DONATOR_KEY"):
fast_sources.append({"id": "aa-fast", "enabled": True})
# LibGen always available
fast_sources.append({"id": "libgen", "enabled": True})
# User's configured slow sources (config won't contain fast sources)
slow_sources = config.get("SOURCE_PRIORITY") or []
# Filter out any legacy fast source entries from old configs
slow_sources = [s for s in slow_sources if s["id"] not in ("aa-fast", "libgen")]
return fast_sources + slow_sources
def _is_source_enabled(source_id: str) -> bool:
"""Check if a source is enabled in the priority config."""
"""Check if a source is enabled in the priority config.
Returns False for unknown sources.
"""
for item in _get_source_priority():
if item["id"] == source_id:
return item.get("enabled", True)
return False # Unknown sources are disabled
return False
def _get_enabled_source_order() -> list[str]:
"""Get ordered list of enabled source IDs."""
return [
item["id"]
for item in _get_source_priority()
if item.get("enabled", True)
]
_SIZE_UNIT_PATTERN = re.compile(r'(kb|mb|gb|tb)', re.IGNORECASE)
def _get_source_position(source_id: str) -> int:
"""Get the position of a source in the priority list (lower = higher priority).
Returns a high number if source not found or disabled.
"""
priority = _get_source_priority()
for i, item in enumerate(priority):
if item["id"] == source_id and item.get("enabled", True):
return i
return 999 # Not found or disabled
def _normalize_size(size_str: str) -> str:
"""Normalize size string by uppercasing units (e.g., '5.2 mb' -> '5.2 MB')."""
return _SIZE_UNIT_PATTERN.sub(lambda m: m.group(1).upper(), size_str.strip())
class SearchUnavailable(Exception):
"""Raised when Anna's Archive cannot be reached via any mirror/DNS."""
pass
def search_books(query: str, filters: SearchFilters) -> List[BookInfo]:
@@ -145,11 +172,9 @@ def search_books(query: str, filters: SearchFilters) -> List[BookInfo]:
index = 1
for filter_type, filter_values in vars(filters).items():
if (filter_type == "author" or filter_type == "title") and filter_values:
if filter_type in ("author", "title") and filter_values:
for value in filter_values:
filters_query += (
f"&termtype_{index}={filter_type}&termval_{index}={quote(value)}"
)
filters_query += f"&termtype_{index}={filter_type}&termval_{index}={quote(value)}"
index += 1
selector = network.AAMirrorSelector()
@@ -200,11 +225,13 @@ def search_books(query: str, filters: SearchFilters) -> List[BookInfo]:
return books
def get_book_info(book_id: str) -> BookInfo:
def get_book_info(book_id: str, fetch_download_count: bool = True) -> BookInfo:
"""Get detailed information for a specific book.
Args:
book_id: Book identifier (MD5 hash)
fetch_download_count: Whether to fetch download count from summary API.
Only needed for display in DetailsModal, not for downloads.
Returns:
BookInfo: Detailed book information including download URLs
@@ -218,7 +245,7 @@ def get_book_info(book_id: str) -> BookInfo:
soup = BeautifulSoup(html, "html.parser")
return _parse_book_info_page(soup, book_id)
return _parse_book_info_page(soup, book_id, fetch_download_count)
def _parse_search_result_row(row: Tag) -> Optional[BookInfo]:
@@ -247,7 +274,7 @@ def _parse_search_result_row(row: Tag) -> Optional[BookInfo]:
return None
def _parse_book_info_page(soup: BeautifulSoup, book_id: str) -> BookInfo:
def _parse_book_info_page(soup: BeautifulSoup, book_id: str, fetch_download_count: bool = True) -> BookInfo:
"""Parse the book info page HTML into a BookInfo object."""
data = soup.select_one("body > main > div:nth-of-type(1)")
@@ -267,26 +294,25 @@ def _parse_book_info_page(soup: BeautifulSoup, book_id: str) -> BookInfo:
data = soup.find_all("div", {"class": "main-inner"})[0].find_next("div")
divs = list(data.children)
slow_urls_no_waitlist: list[str] = []
slow_urls_with_waitlist: list[str] = []
def _append_unique(lst: list[str], href: str) -> None:
if href and href not in lst:
lst.append(href)
slow_urls_no_waitlist: set[str] = set()
slow_urls_with_waitlist: set[str] = set()
for anchor in soup.find_all("a"):
try:
text = anchor.text.strip().lower()
href = anchor.get("href", "")
if not href:
continue
next_text = ""
if anchor.next and anchor.next.next:
next_text = getattr(anchor.next.next, 'text', str(anchor.next.next)).strip().lower()
if text.startswith("slow partner server") and "waitlist" in next_text:
if "no waitlist" in next_text:
_append_unique(slow_urls_no_waitlist, href)
slow_urls_no_waitlist.add(href)
else:
_append_unique(slow_urls_with_waitlist, href)
slow_urls_with_waitlist.add(href)
except Exception:
pass
@@ -326,11 +352,10 @@ def _parse_book_info_page(soup: BeautifulSoup, book_id: str) -> BookInfo:
for f in _details:
if format == "" and f.strip().lower() in config.SUPPORTED_FORMATS:
format = f.strip().lower()
if size == "" and any(u in f.strip().lower() for u in ["mb", "kb", "gb"]):
# Preserve original case but uppercase the unit (e.g., "5.2 mb" -> "5.2 MB")
size = re.sub(r'(kb|mb|gb|tb)', lambda m: m.group(1).upper(), f.strip(), flags=re.IGNORECASE)
if size == "" and any(u in f.strip().lower() for u in ("mb", "kb", "gb")):
size = _normalize_size(f)
if content == "":
for ct in DOWNLOAD_PATHS.keys():
for ct in CONTENT_TYPES:
if ct in f.strip().lower():
content = ct
break
@@ -340,10 +365,9 @@ def _parse_book_info_page(soup: BeautifulSoup, book_id: str) -> BookInfo:
if format == "" and stripped and " " not in stripped:
format = stripped
if size == "" and "." in stripped:
# Uppercase any size units
size = re.sub(r'(kb|mb|gb|tb)', lambda m: m.group(1).upper(), f.strip(), flags=re.IGNORECASE)
size = _normalize_size(f)
book_title = _find_in_divs(divs, "🔍")[0].strip("🔍").strip()
book_title = (_find_in_divs(divs, "🔍") or [""])[0].strip("🔍").strip()
# Extract basic information
description = _extract_book_description(soup)
@@ -353,8 +377,8 @@ def _parse_book_info_page(soup: BeautifulSoup, book_id: str) -> BookInfo:
preview=preview,
title=book_title,
content=content,
publisher=_find_in_divs(divs, "icon-[mdi--company]", is_class=True)[0],
author=_find_in_divs(divs, "icon-[mdi--user-edit]", is_class=True)[0],
publisher=(_find_in_divs(divs, "icon-[mdi--company]", is_class=True) or [""])[0],
author=(_find_in_divs(divs, "icon-[mdi--user-edit]", is_class=True) or [""])[0],
format=format,
size=size,
description=description,
@@ -363,6 +387,18 @@ def _parse_book_info_page(soup: BeautifulSoup, book_id: str) -> BookInfo:
# Extract additional metadata
info = _extract_book_metadata(original_divs[-6])
if fetch_download_count:
try:
summary_url = f"{network.get_aa_base_url()}/dyn/md5/summary/{book_id}"
summary_response = downloader.html_get_page(summary_url, selector=network.AAMirrorSelector())
if summary_response:
summary_data = json.loads(summary_response)
if "downloads_total" in summary_data:
info["Downloads"] = [str(summary_data["downloads_total"])]
except Exception as e:
logger.debug(f"Failed to fetch download count for {book_id}: {e}")
book_info.info = info
# Set language and year from metadata if available
@@ -371,6 +407,9 @@ def _parse_book_info_page(soup: BeautifulSoup, book_id: str) -> BookInfo:
if info.get("Year"):
book_info.year = info["Year"][0]
# Set source URL for linking back to Anna's Archive
book_info.source_url = f"{network.get_aa_base_url()}/md5/{book_id}"
return book_info
@@ -436,39 +475,24 @@ def _extract_book_description(soup: BeautifulSoup) -> Optional[str]:
def _extract_book_metadata(metadata_divs) -> Dict[str, List[str]]:
"""Extract metadata from book info divs."""
info: Dict[str, List[str]] = {}
info: Dict[str, set[str]] = {}
# Process the first set of metadata
sub_datas = metadata_divs.find_all("div")[0]
sub_datas = list(sub_datas.children)
for sub_data in sub_datas:
for sub_data in sub_datas.children:
if sub_data.text.strip() == "":
continue
sub_data = list(sub_data.children)
key = sub_data[0].text.strip()
value = sub_data[1].text.strip()
children = list(sub_data.children)
key = children[0].text.strip()
value = children[1].text.strip()
if key not in info:
info[key] = set()
info[key].add(value)
# make set into list
for key, value in info.items():
info[key] = list(value)
# Filter relevant metadata
relevant_prefixes = [
"ISBN-",
"ALTERNATIVE",
"ASIN",
"Goodreads",
"Language",
"Year",
]
relevant_prefixes = ("isbn-", "alternative", "asin", "goodreads", "language", "year")
return {
k.strip(): v
k.strip(): list(v)
for k, v in info.items()
if any(k.lower().startswith(prefix.lower()) for prefix in relevant_prefixes)
and "filename" not in k.lower()
if k.lower().startswith(relevant_prefixes) and "filename" not in k.lower()
}
@@ -494,41 +518,32 @@ def _get_source_info(link: str) -> tuple[str, str]:
return "unknown", "Mirror"
def _label_source(link: str) -> str:
"""Get lightweight source tag for logging/metrics."""
return _get_source_info(link)[0]
def _friendly_source_name(link: str) -> str:
"""Get user-friendly name for a download source."""
return _get_source_info(link)[1]
def _fetch_aa_page_urls(book_info: BookInfo, urls_by_source: dict[str, list[str]]) -> None:
def _group_urls_by_source(urls: List[str], urls_by_source: Dict[str, List[str]]) -> None:
"""Group URLs into urls_by_source dict by their source type."""
for url in urls:
source_type = _url_source_types.get(url)
if source_type:
urls_by_source.setdefault(source_type, []).append(url)
def _fetch_aa_page_urls(book_info: BookInfo, urls_by_source: Dict[str, List[str]]) -> None:
"""Fetch and parse AA page, populating urls_by_source dict.
Groups existing book_info.download_urls by source type. If book_info
has no URLs, fetches the AA page fresh.
"""
# If book_info already has URLs, group them by source type
if book_info.download_urls:
for url in book_info.download_urls:
source_type = _url_source_types.get(url)
if source_type:
if source_type not in urls_by_source:
urls_by_source[source_type] = []
urls_by_source[source_type].append(url)
_group_urls_by_source(book_info.download_urls, urls_by_source)
return
# Otherwise fetch the page fresh
try:
fresh_book_info = get_book_info(book_info.id)
for url in fresh_book_info.download_urls:
source_type = _url_source_types.get(url)
if source_type:
if source_type not in urls_by_source:
urls_by_source[source_type] = []
urls_by_source[source_type].append(url)
fresh_book_info = get_book_info(book_info.id, fetch_download_count=False)
_group_urls_by_source(fresh_book_info.download_urls, urls_by_source)
except Exception as e:
logger.warning(f"Failed to fetch AA page: {e}")
@@ -539,9 +554,8 @@ def _get_urls_for_source(
selector: network.AAMirrorSelector,
cancel_flag: Optional[Event],
status_callback: Optional[Callable[[str, Optional[str]], None]],
urls_by_source: dict[str, list[str]],
aa_page_fetched: bool
) -> list[str]:
urls_by_source: Dict[str, List[str]],
) -> List[str]:
"""Get URLs for a specific source, fetching lazily if needed."""
# AA Fast - generate URL dynamically
if source_id == "aa-fast":
@@ -552,22 +566,31 @@ def _get_urls_for_source(
return [url]
# MD5-based sources - generate URL from template
if source_id in _MD5_URL_TEMPLATES:
url = _MD5_URL_TEMPLATES[source_id].format(md5=book_info.id)
template = _get_md5_url_template(source_id)
if template:
url = template.format(md5=book_info.id)
_url_source_types[url] = source_id
return [url]
if source_id == "libgen":
urls = []
for base_url in _get_libgen_domains():
url = f"{base_url}/ads.php?md5={book_info.id}"
_url_source_types[url] = "libgen"
urls.append(url)
return urls
# Welib - fetch page and parse for slow_download links
if source_id == "welib":
if status_callback:
status_callback("resolving", "Fetching welib sources...")
return _get_download_urls_from_welib(book_info.id, selector=selector, cancel_flag=cancel_flag)
status_callback("resolving", "Fetching welib sources")
return _get_download_urls_from_welib(book_info.id, selector=selector, cancel_flag=cancel_flag, status_callback=status_callback)
# AA page sources - fetch AA page if not already done
if source_id in _AA_PAGE_SOURCES:
if not aa_page_fetched and not urls_by_source:
if not urls_by_source:
if status_callback:
status_callback("resolving", "Fetching download sources...")
status_callback("resolving", "Fetching download sources")
_fetch_aa_page_urls(book_info, urls_by_source)
return urls_by_source.get(source_id, [])
@@ -628,14 +651,21 @@ def _try_download_url(
return None
def _get_download_urls_from_welib(book_id: str, selector: Optional[network.AAMirrorSelector] = None, cancel_flag: Optional[Event] = None) -> list[str]:
def _get_download_urls_from_welib(
book_id: str,
selector: Optional[network.AAMirrorSelector] = None,
cancel_flag: Optional[Event] = None,
status_callback: Optional[Callable[[str, Optional[str]], None]] = None
) -> List[str]:
"""Get download URLs from welib.org (bypasser required)."""
from shelfmark.core import mirrors
if not _is_source_enabled("welib"):
return []
url = _MD5_URL_TEMPLATES["welib"].format(md5=book_id)
url = mirrors.get_welib_url_template().format(md5=book_id)
logger.info(f"Fetching welib download URLs for {book_id}")
try:
html = downloader.html_get_page(url, use_bypasser=True, selector=selector or network.AAMirrorSelector(), cancel_flag=cancel_flag)
html = downloader.html_get_page(url, use_bypasser=True, selector=selector or network.AAMirrorSelector(), cancel_flag=cancel_flag, status_callback=status_callback)
except Exception as exc:
logger.error_trace(f"Welib fetch failed for {book_id}: {exc}")
return []
@@ -652,6 +682,62 @@ def _get_download_urls_from_welib(book_id: str, selector: Optional[network.AAMir
return list(dict.fromkeys(links)) # Dedupe while preserving order
def _extract_libgen_download_url(link: str, cancel_flag: Optional[Event] = None) -> str:
"""Extract download URL from Libgen ads.php page using direct HTTP."""
if cancel_flag and cancel_flag.is_set():
return ""
base_url = "/".join(link.split("/")[:3])
logger.debug(f"Libgen fast: trying {link}")
try:
response = requests.get(
link,
headers=downloader.DOWNLOAD_HEADERS,
timeout=(5, 10),
allow_redirects=True,
proxies=network.get_proxies(),
)
if response.status_code != 200:
logger.debug(f"Libgen fast: {link} returned {response.status_code}")
return ""
html = response.text
final_url = response.url
if "libgen" not in final_url.lower() and "ads.php" not in final_url.lower():
logger.debug(f"Libgen fast: redirected away to {final_url}")
return ""
if "get.php" not in html:
logger.debug(f"Libgen fast: page doesn't contain get.php")
return ""
download_url = None
for pattern in _LIBGEN_GET_PATTERNS:
match = pattern.search(html)
if match:
download_url = match.group(1).replace("&amp;", "&").replace("&gt;", ">").replace("&lt;", "<")
break
if not download_url:
logger.debug(f"Libgen fast: couldn't extract GET link")
return ""
if not download_url.startswith("http"):
download_url = f"{base_url}/{download_url.lstrip('/')}"
logger.debug(f"Libgen fast: extracted {download_url}")
return download_url
except requests.exceptions.RequestException as e:
logger.debug(f"Libgen fast: request failed: {e}")
return ""
except Exception as e:
logger.warning(f"Libgen fast: unexpected error: {e}")
return ""
def _download_book(
book_info: BookInfo,
book_path: Path,
@@ -666,7 +752,6 @@ def _download_book(
selector = network.AAMirrorSelector()
source_failures: dict[str, int] = {}
urls_by_source: dict[str, list[str]] = {}
aa_page_fetched = False
url_attempt_counter = 0
# Get enabled sources in priority order
@@ -696,13 +781,9 @@ def _download_book(
# Get URLs for this source (lazy-loads as needed)
urls_to_try = _get_urls_for_source(
source_id, book_info, selector, cancel_flag, status_callback,
urls_by_source, aa_page_fetched
urls_by_source,
)
# Track if we fetched AA page
if source_id in _AA_PAGE_SOURCES and not aa_page_fetched:
aa_page_fetched = bool(urls_by_source)
if not urls_to_try:
continue
@@ -719,9 +800,12 @@ def _download_book(
if cancel_flag and cancel_flag.is_set():
return None
url_attempt_counter += 1
friendly_name = _friendly_source_name(url)
source_context = f"{friendly_name} (Server #{url_attempt_counter})"
if source_id == "libgen":
source_context = "Libgen (Fast)"
else:
url_attempt_counter += 1
friendly_name = _friendly_source_name(url)
source_context = f"{friendly_name} (Server #{url_attempt_counter})"
result = _try_download_url(
url, source_id, book_info, book_path,
@@ -766,10 +850,13 @@ def _get_download_url(
# AA fast download API (JSON response)
if link.startswith(f"{network.get_aa_base_url()}/dyn/api/fast_download.json"):
page = downloader.html_get_page(link, selector=sel, cancel_flag=cancel_flag)
page = downloader.html_get_page(link, selector=sel, cancel_flag=cancel_flag, status_callback=status_callback)
return downloader.get_absolute_url(link, json.loads(page).get("download_url", ""))
html = downloader.html_get_page(link, selector=sel, cancel_flag=cancel_flag)
if "/ads.php?md5=" in link and any(domain in link for domain in _get_libgen_domains()):
return _extract_libgen_download_url(link, cancel_flag)
html = downloader.html_get_page(link, selector=sel, cancel_flag=cancel_flag, status_callback=status_callback)
if not html:
return ""
@@ -782,7 +869,7 @@ def _get_download_url(
if not dl:
# Retry after delay if page not fully loaded
time.sleep(2)
html = downloader.html_get_page(link, selector=sel, cancel_flag=cancel_flag)
html = downloader.html_get_page(link, selector=sel, cancel_flag=cancel_flag, status_callback=status_callback)
if html:
soup = BeautifulSoup(html, "html.parser")
dl = soup.find("a", href=True, class_="addDownloadedBook")
@@ -792,10 +879,13 @@ def _get_download_url(
elif "/slow_download/" in link:
url = _extract_slow_download_url(soup, link, title, cancel_flag, status_callback, sel, source_context)
# Libgen (GET button)
else:
get_btn = soup.find("a", string="GET")
url = get_btn["href"] if get_btn else ""
get_btn = soup.find("a", string="GET") or soup.find("a", string="Download")
if get_btn:
url = get_btn.get("href", "")
else:
logger.warning(f"Unknown source type, couldn't find download link: {link}")
url = ""
return downloader.get_absolute_url(link, url)
@@ -810,21 +900,42 @@ def _extract_slow_download_url(
source_context: Optional[str] = None
) -> str:
"""Extract download URL from AA slow download pages."""
# Try "Download now" button variations
html_str = str(soup)
clipboard_match = re.search(r"navigator\.clipboard\.writeText\(['\"]([^'\"]+)['\"]\)", html_str)
if clipboard_match:
url = clipboard_match.group(1)
if url.startswith("http") and "/slow_download/" not in url:
return url
dl_link = soup.find("a", href=True, string="📚 Download now")
if not dl_link:
dl_link = soup.find("a", href=True, string=lambda s: s and "Download now" in s)
if dl_link:
return dl_link["href"]
# Try finding URL in gray background span (AA's copy URL format)
# The URL appears as plain text in <span class="bg-gray-200 ...">http://...</span>
for span in soup.find_all("span", class_=lambda c: c and "bg-gray-200" in c):
for a_tag in soup.find_all("a", href=True):
if a_tag.has_attr("download"):
href = a_tag["href"]
if href.startswith("http") and "/slow_download/" not in href:
return href
for span in soup.find_all("span", class_=lambda c: c and "whitespace-normal" in c):
text = span.get_text(strip=True)
if text.startswith("http://") or text.startswith("https://"):
if text.startswith(("http://", "https://")) and "/slow_download/" not in text:
return text
# Try "copy this URL" pattern (legacy)
for span in soup.find_all("span", class_=lambda c: c and "bg-gray-200" in c):
text = span.get_text(strip=True)
if text.startswith(("http://", "https://")):
return text
location_match = re.search(r"window\.location\.href\s*=\s*['\"]([^'\"]+)['\"]", html_str)
if location_match:
url = location_match.group(1)
if url.startswith("http") and "/slow_download/" not in url:
return url
copy_text = soup.find(string=lambda s: s and "copy this url" in s.lower())
if copy_text and copy_text.parent:
parent = copy_text.parent
@@ -839,30 +950,17 @@ def _extract_slow_download_url(
if text.startswith("http"):
return text
# Check for countdown timer (waitlist)
countdown = soup.find("span", class_="js-partner-countdown")
if countdown:
# Cap countdown at 10 minutes to prevent malformed HTML from blocking indefinitely
countdown_seconds = _extract_countdown_seconds(soup, html_str)
if countdown_seconds > 0:
MAX_COUNTDOWN_SECONDS = 600
try:
raw_countdown = int(countdown.text)
except (ValueError, TypeError):
logger.warning(f"Invalid countdown value '{countdown.text}', skipping wait")
raw_countdown = 0
sleep_time = min(raw_countdown, MAX_COUNTDOWN_SECONDS)
if raw_countdown > MAX_COUNTDOWN_SECONDS:
logger.warning(f"Countdown {raw_countdown}s exceeds max, capping at {MAX_COUNTDOWN_SECONDS}s")
logger.info(f"Waiting {sleep_time}s for {title}")
sleep_time = min(countdown_seconds, MAX_COUNTDOWN_SECONDS)
if countdown_seconds > MAX_COUNTDOWN_SECONDS:
logger.warning(f"Countdown {countdown_seconds}s exceeds max, capping at {MAX_COUNTDOWN_SECONDS}s")
logger.info(f"AA waitlist: {sleep_time}s for {title}")
# Live countdown with status updates
remaining = sleep_time
while remaining > 0:
# Format countdown message with source context
if source_context:
wait_msg = f"{source_context} - Waiting {remaining}s"
else:
wait_msg = f"Waiting {remaining}s"
for remaining in range(sleep_time, 0, -1):
wait_msg = f"{source_context} - Waiting {remaining}s" if source_context else f"Waiting {remaining}s"
if status_callback:
status_callback("resolving", wait_msg)
@@ -871,20 +969,75 @@ def _extract_slow_download_url(
logger.info(f"Cancelled wait for {title}")
return ""
remaining -= 1
# After countdown, update status and re-fetch
if status_callback and source_context:
status_callback("resolving", f"{source_context} - Fetching...")
status_callback("resolving", f"{source_context} - Fetching")
return _get_download_url(link, title, cancel_flag, status_callback, selector, source_context)
# Debug fallback
link_texts = [a.get_text(strip=True)[:50] for a in soup.find_all("a", href=True)[:10]]
logger.warning(f"No download URL found. First 10 links: {link_texts}")
return ""
def _extract_countdown_seconds(soup: BeautifulSoup, html_str: str) -> int:
"""Extract countdown timer seconds from AA slow download page."""
countdown_elem = soup.find("span", class_="js-partner-countdown")
if countdown_elem:
try:
seconds = int(countdown_elem.get_text(strip=True))
if 0 < seconds < 300:
return seconds
except (ValueError, TypeError):
pass
for elem in soup.find_all(["span", "div"], class_=lambda c: c and ("timer" in c.lower() or "countdown" in c.lower())):
try:
seconds = int(elem.get_text(strip=True))
if 0 < seconds < 300:
return seconds
except (ValueError, TypeError):
pass
countdown_attr = re.search(r'data-countdown=["\'](\d+)["\']', html_str)
if countdown_attr:
seconds = int(countdown_attr.group(1))
if 0 < seconds < 300:
return seconds
js_countdown = re.search(r'countdown:\s*(\d+)', html_str)
if js_countdown:
seconds = int(js_countdown.group(1))
if 0 < seconds < 300:
return seconds
js_var = re.search(r'(?:var|let|const)\s+countdown\s*=\s*(\d+)', html_str)
if js_var:
seconds = int(js_var.group(1))
if 0 < seconds < 300:
return seconds
countdown_secs = re.search(r'countdownSeconds\s*=\s*(\d+)', html_str)
if countdown_secs:
seconds = int(countdown_secs.group(1))
if 0 < seconds < 300:
return seconds
json_countdown = re.search(r'["\']countdown[_-]?seconds["\']\s*:\s*(\d+)', html_str)
if json_countdown:
seconds = int(json_countdown.group(1))
if 0 < seconds < 300:
return seconds
wait_text = re.search(r'wait\s+(\d+)\s+seconds', html_str, re.IGNORECASE)
if wait_text:
seconds = int(wait_text.group(1))
if 0 < seconds < 300:
return seconds
return 0
def _book_info_to_release(book_info: BookInfo) -> Release:
"""Convert a BookInfo object to a Release object.
@@ -898,14 +1051,15 @@ def _book_info_to_release(book_info: BookInfo) -> Release:
format=book_info.format,
size=book_info.size,
download_url=book_info.download_urls[0] if book_info.download_urls else None,
info_url=f"{network.get_aa_base_url()}/md5/{book_info.id}",
protocol=ReleaseProtocol.HTTP,
indexer="Anna's Archive",
content_type=book_info.content, # Preserve content type from source
extra={
"author": book_info.author,
"publisher": book_info.publisher,
"year": book_info.year,
"language": book_info.language,
"content": book_info.content,
"preview": book_info.preview,
"description": book_info.description,
"download_urls": book_info.download_urls,
@@ -924,6 +1078,17 @@ class DirectDownloadSource(ReleaseSource):
"""
name = "direct_download"
display_name = "Anna's Archive"
supported_content_types = ["ebook"] # Direct downloads only support ebooks
def __init__(self):
# Tracks which search method was used in the last search() call
# "isbn" = ISBN search returned results, "title_author" = title+author was used
self._last_search_type: str = "title_author"
@property
def last_search_type(self) -> str:
"""Returns the search type used in the last search() call."""
return self._last_search_type
@classmethod
def get_column_config(cls) -> ReleaseColumnConfig:
@@ -963,76 +1128,91 @@ class DirectDownloadSource(ReleaseSource):
hide_mobile=False, # Size shown on mobile
),
],
grid_template="minmax(0,2fr) 60px 80px 80px"
grid_template="minmax(0,2fr) 60px 80px 80px",
supported_filters=["format", "language"], # AA has reliable language metadata
)
def search(self, book: BookMetadata) -> List[Release]:
def search(
self,
book: BookMetadata,
expand_search: bool = False,
languages: Optional[List[str]] = None,
content_type: str = "ebook"
) -> List[Release]:
"""
Search for releases using the book's metadata.
Uses an ISBN-first strategy:
1. If ISBN available, try ISBN search first (most precise)
2. If no results or no ISBN, fall back to title+author search
Priority: ISBN search first (most precise), then title+author fallback.
For non-English languages, uses localized titles from book.titles_by_language.
This approach maximizes accuracy while ensuring we find results.
Args:
book: Book metadata from provider
expand_search: If True, skip ISBN and use title+author directly
languages: Language codes to filter by (overrides book.language/config)
content_type: Ignored - Direct download uses format filtering instead
"""
# Try ISBN search first if available
isbn = book.isbn_13 or book.isbn_10
if isbn:
logger.debug(f"Searching direct downloads by ISBN: {isbn}")
filters = SearchFilters(isbn=[isbn])
if book.language:
filters.lang = [book.language]
# Language filter: explicit param > book.language > config default
lang_filter = languages or ([book.language] if book.language else config.BOOK_LANGUAGE)
# Reset search type tracking
self._last_search_type = "title_author"
# ISBN search first (unless expand_search requested)
if not expand_search:
isbn = book.isbn_13 or book.isbn_10
if isbn:
logger.debug(f"Searching by ISBN: {isbn}")
filters = SearchFilters(isbn=[isbn])
if lang_filter:
filters.lang = lang_filter
try:
results = search_books(isbn, filters)
if results:
logger.info(f"Found {len(results)} releases via ISBN")
self._last_search_type = "isbn"
return [_book_info_to_release(bi) for bi in results]
logger.debug("No ISBN results, falling back to title+author")
except SearchUnavailable:
raise
except Exception as e:
logger.warning(f"ISBN search failed: {e}")
# Title + author fallback
author = book.authors[0] if book.authors else ""
# Group languages by localized title to avoid duplicate searches
if lang_filter and book.titles_by_language:
title_to_langs: Dict[str, List[str]] = {}
for lang in lang_filter:
title = book.titles_by_language.get(lang, book.title)
title_to_langs.setdefault(title, []).append(lang)
searches = list(title_to_langs.items())
else:
searches = [(book.title, lang_filter)]
# Execute searches with deduplication
seen_ids: set = set()
all_results: List[BookInfo] = []
for title, langs in searches:
query = f"{title} {author}".strip()
if not query:
continue
logger.debug(f"Searching: query='{query}', langs={langs}")
filters = SearchFilters(lang=langs) if langs else SearchFilters()
try:
book_infos = search_books(isbn, filters)
if book_infos:
logger.info(f"Found {len(book_infos)} releases via ISBN search")
return [_book_info_to_release(bi) for bi in book_infos]
logger.debug(f"No results from ISBN search, falling back to title+author")
for bi in search_books(query, filters):
if bi.id not in seen_ids:
seen_ids.add(bi.id)
all_results.append(bi)
except SearchUnavailable:
logger.warning("Direct download search unavailable during ISBN search")
raise # Service unreachable - no point trying title search
raise
except Exception as e:
logger.warning(f"ISBN search failed, falling back to title+author: {e}")
# Fall through to title search
logger.error(f"Search error: {e}")
# Fallback to title + author search
query_parts = []
if book.title:
query_parts.append(book.title)
if book.authors:
query_parts.append(book.authors[0]) # Use first author
query = " ".join(query_parts)
if not query.strip():
logger.warning("No search query available for book")
return []
logger.debug(f"Searching direct downloads by title+author: {query}")
filters = SearchFilters()
if book.language:
filters.lang = [book.language]
try:
book_infos = search_books(query, filters)
logger.info(f"Found {len(book_infos)} releases via title+author search")
return [_book_info_to_release(bi) for bi in book_infos]
except SearchUnavailable:
logger.warning("Direct download search unavailable")
raise # Re-raise so the API endpoint can report the error
except Exception as e:
logger.error(f"Error searching direct download source: {e}")
raise # Re-raise so the API endpoint can report the error
def search_raw(self, query: str, filters: SearchFilters) -> List[BookInfo]:
"""
Raw search using existing query format - for backward compatibility.
This is used by the existing "Direct Download Only" mode which doesn't
go through the metadata provider layer.
"""
return search_books(query, filters)
logger.info(f"Found {len(all_results)} releases via title+author")
return [_book_info_to_release(bi) for bi in all_results]
def is_available(self) -> bool:
"""Direct download is always available."""
@@ -1075,6 +1255,7 @@ class DirectDownloadHandler(DownloadHandler):
# Check for cancellation before starting
if cancel_flag.is_set():
logger.info(f"Download cancelled before starting: {task.task_id}")
status_callback("cancelled", "Cancelled")
return None
# Create BookInfo from task data - NO AA page fetch here
@@ -1099,6 +1280,7 @@ class DirectDownloadHandler(DownloadHandler):
except Exception as e:
if cancel_flag.is_set():
logger.info(f"Download cancelled during error handling: {task.task_id}")
status_callback("cancelled", "Cancelled")
else:
logger.error(f"Error downloading book: {e}")
status_callback("error", str(e))
@@ -1120,18 +1302,23 @@ class DirectDownloadHandler(DownloadHandler):
try:
logger.info(f"Starting download: {book_info.title}")
# Prepare paths
full_name = book_info.get_filename()
book_name = full_name if config.USE_BOOK_TITLE else f"{book_info.id}.{book_info.format or 'bin'}"
# Prepare paths - use descriptive staging filename, orchestrator will rename
# based on FILE_ORGANIZATION setting
file_org = config.get("FILE_ORGANIZATION", "rename")
if file_org == "none":
book_name = f"{book_info.id}.{book_info.format or 'bin'}"
else:
book_name = book_info.get_filename()
book_path = TMP_DIR / book_name
# Check cancellation before download
if cancel_flag.is_set():
logger.info(f"Download cancelled before download call: {book_info.id}")
status_callback("cancelled", "Cancelled")
return None
# Execute download via _download_book (handles cascade and bypass)
status_callback("resolving", "Finding download source...")
status_callback("resolving", "Finding download source")
success_url = _download_book(
book_info,
book_path,
@@ -1145,6 +1332,7 @@ class DirectDownloadHandler(DownloadHandler):
logger.info(f"Download cancelled during download: {book_info.id}")
if book_path.exists():
book_path.unlink()
status_callback("cancelled", "Cancelled")
return None
if not success_url:
@@ -1157,6 +1345,7 @@ class DirectDownloadHandler(DownloadHandler):
except Exception as e:
if cancel_flag.is_set():
logger.info(f"Download cancelled during error handling: {book_info.id}")
status_callback("cancelled", "Cancelled")
else:
logger.error(f"Error downloading book: {e}")
return None
+11
View File
@@ -0,0 +1,11 @@
"""IRC release source plugin.
Searches and downloads ebooks from IRC channels via DCC protocol.
Available when IRC server, channel, and nickname are configured in settings.
Based on OpenBooks (https://github.com/evan-buss/openbooks).
"""
from shelfmark.release_sources.irc import source # noqa: F401
from shelfmark.release_sources.irc import handler # noqa: F401
from shelfmark.release_sources.irc import settings # noqa: F401
+289
View File
@@ -0,0 +1,289 @@
"""Persistent file-based cache for IRC search results.
Stores search results in CONFIG_DIR to survive container restarts.
IRC searches are slow and resource-intensive, so we cache aggressively.
"""
import hashlib
import json
import time
from dataclasses import asdict
from pathlib import Path
from threading import Lock
from typing import Any, Dict, List, Optional
from shelfmark.config import env
from shelfmark.core.logger import setup_logger
from shelfmark.release_sources import Release, ReleaseProtocol
logger = setup_logger(__name__)
# Cache file location
CACHE_FILE = Path(env.CONFIG_DIR) / "irc_cache.json"
# Default TTL: 30 days (in seconds)
DEFAULT_CACHE_TTL = 30 * 24 * 60 * 60
# Lock for thread-safe file access
_cache_lock = Lock()
def _generate_cache_key(provider: str, provider_id: str) -> str:
"""Generate a cache key from provider and provider_id."""
return f"{provider}:{provider_id}"
def _load_cache() -> Dict[str, Any]:
"""Load cache from disk."""
try:
if CACHE_FILE.exists():
return json.loads(CACHE_FILE.read_text())
except (json.JSONDecodeError, IOError) as e:
logger.warning(f"Failed to load IRC cache: {e}")
return {"entries": {}, "version": 1}
def _save_cache(cache: Dict[str, Any]) -> None:
"""Save cache to disk."""
try:
CACHE_FILE.write_text(json.dumps(cache, indent=2))
except IOError as e:
logger.error(f"Failed to save IRC cache: {e}")
def _release_to_dict(release: Release) -> Dict[str, Any]:
"""Convert Release to a JSON-serializable dict."""
data = asdict(release)
# Convert enum to string
if data.get("protocol"):
data["protocol"] = data["protocol"].value if hasattr(data["protocol"], "value") else str(data["protocol"])
return data
def _dict_to_release(data: Dict[str, Any]) -> Release:
"""Convert dict back to Release object."""
# Convert protocol string back to enum
if data.get("protocol"):
try:
data["protocol"] = ReleaseProtocol(data["protocol"])
except (ValueError, KeyError):
data["protocol"] = None
return Release(**data)
def get_cached_results(
provider: str,
provider_id: str,
ttl_seconds: Optional[int] = None
) -> Optional[Dict[str, Any]]:
"""
Get cached search results for a book.
Args:
provider: Metadata provider name (e.g., "hardcover", "openlibrary")
provider_id: Book ID in the provider's system
ttl_seconds: Cache TTL in seconds (from settings)
Returns:
Dict with 'releases' (List[Release]) and 'online_servers' (List[str]),
or None if not cached or expired
"""
from shelfmark.core.config import config
if ttl_seconds is None:
ttl_value = config.get("IRC_CACHE_TTL", DEFAULT_CACHE_TTL)
# Config values are stored as strings, convert to int
ttl_seconds = int(ttl_value) if ttl_value else DEFAULT_CACHE_TTL
# TTL of 0 means cache forever
if ttl_seconds == 0:
ttl_seconds = float('inf')
cache_key = _generate_cache_key(provider, provider_id)
with _cache_lock:
cache = _load_cache()
entry = cache.get("entries", {}).get(cache_key)
if not entry:
return None
# Check expiration
cached_at = entry.get("cached_at", 0)
age = time.time() - cached_at
if age > ttl_seconds:
logger.debug(f"IRC cache expired for '{title}' (age: {age:.0f}s > TTL: {ttl_seconds}s)")
# Don't delete here - let cleanup handle it
return None
# Convert dicts back to Release objects
releases = [_dict_to_release(r) for r in entry.get("releases", [])]
online_servers = entry.get("online_servers", [])
title = entry.get("title", "")
logger.info(f"IRC cache hit for '{title}' ({len(releases)} releases, age: {age:.0f}s)")
return {
"releases": releases,
"online_servers": online_servers,
"cached_at": cached_at,
}
def cache_results(
provider: str,
provider_id: str,
title: str,
releases: List[Release],
online_servers: Optional[List[str]] = None
) -> None:
"""
Cache search results for a book.
Args:
provider: Metadata provider name
provider_id: Book ID in the provider's system
title: Book title (for logging/display)
releases: List of Release objects from search
online_servers: List of online server nicks (optional)
"""
cache_key = _generate_cache_key(provider, provider_id)
with _cache_lock:
cache = _load_cache()
if "entries" not in cache:
cache["entries"] = {}
cache["entries"][cache_key] = {
"provider": provider,
"provider_id": provider_id,
"title": title,
"releases": [_release_to_dict(r) for r in releases],
"online_servers": list(online_servers) if online_servers else [],
"cached_at": time.time(),
}
_save_cache(cache)
logger.info(f"Cached {len(releases)} IRC releases for '{title}'")
def invalidate_cache(provider: str, provider_id: str) -> bool:
"""
Remove a specific entry from the cache.
Args:
provider: Metadata provider name
provider_id: Book ID in the provider's system
Returns:
True if entry was found and removed
"""
cache_key = _generate_cache_key(provider, provider_id)
with _cache_lock:
cache = _load_cache()
entry = cache.get("entries", {}).get(cache_key)
title = entry.get("title", cache_key) if entry else cache_key
if cache_key in cache.get("entries", {}):
del cache["entries"][cache_key]
_save_cache(cache)
logger.info(f"Invalidated IRC cache for '{title}'")
return True
return False
def clear_cache() -> int:
"""
Clear all cached entries.
Returns:
Number of entries cleared
"""
with _cache_lock:
cache = _load_cache()
count = len(cache.get("entries", {}))
cache["entries"] = {}
_save_cache(cache)
logger.info(f"Cleared {count} IRC cache entries")
return count
def cleanup_expired(ttl_seconds: Optional[int] = None) -> int:
"""
Remove all expired entries from the cache.
Returns:
Number of entries removed
"""
from shelfmark.core.config import config
if ttl_seconds is None:
ttl_value = config.get("IRC_CACHE_TTL", DEFAULT_CACHE_TTL)
# Config values are stored as strings, convert to int
ttl_seconds = int(ttl_value) if ttl_value else DEFAULT_CACHE_TTL
current_time = time.time()
removed = 0
with _cache_lock:
cache = _load_cache()
entries = cache.get("entries", {})
expired_keys = [
key for key, entry in entries.items()
if current_time - entry.get("cached_at", 0) > ttl_seconds
]
for key in expired_keys:
del entries[key]
removed += 1
if removed:
_save_cache(cache)
logger.info(f"Cleaned up {removed} expired IRC cache entries")
return removed
def get_cache_stats() -> Dict[str, Any]:
"""
Get cache statistics.
Returns:
Dict with cache stats
"""
from shelfmark.core.config import config
ttl_value = config.get("IRC_CACHE_TTL", DEFAULT_CACHE_TTL)
# Config values are stored as strings, convert to int
ttl_seconds = int(ttl_value) if ttl_value else DEFAULT_CACHE_TTL
current_time = time.time()
with _cache_lock:
cache = _load_cache()
entries = cache.get("entries", {})
total = len(entries)
expired = sum(
1 for entry in entries.values()
if current_time - entry.get("cached_at", 0) > ttl_seconds
)
# Calculate total releases cached
total_releases = sum(
len(entry.get("releases", []))
for entry in entries.values()
)
return {
"total_entries": total,
"expired_entries": expired,
"valid_entries": total - expired,
"total_releases": total_releases,
"ttl_seconds": ttl_seconds,
"cache_file": str(CACHE_FILE),
}
+400
View File
@@ -0,0 +1,400 @@
"""IRC client implementation using raw sockets.
Minimal IRC client for ebook searches.
"""
import re
import socket
import ssl
import time
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Iterator, Optional
from shelfmark.core.logger import setup_logger
from .dcc import DCCOffer, parse_dcc_send
logger = setup_logger(__name__)
# Timing
POST_CONNECT_DELAY = 2.0 # Seconds to wait after connect before joining
SOCKET_TIMEOUT = 300.0 # 5 minutes - long because we wait for DCC offers
RECV_BUFFER = 4096
# IRC channel user prefixes that indicate elevated status (ops, voice, etc.)
# These are the download bots/servers
ELEVATED_PREFIXES = frozenset({'~', '&', '@', '%', '+'})
class IRCEvent(Enum):
"""Events detected from IRC messages."""
MESSAGE = auto() # Generic message
SEARCH_RESULT = auto() # DCC SEND with "_results_for"
BOOK_RESULT = auto() # DCC SEND for actual book
NO_RESULTS = auto() # "Sorry" notice
BAD_SERVER = auto() # "try another server" notice
SEARCH_ACCEPTED = auto() # "has been accepted" notice
MATCHES_FOUND = auto() # "X matches" notice
SERVER_LIST = auto() # User list (353/366)
PING = auto() # Server PING
VERSION = auto() # CTCP VERSION request
@dataclass
class IRCMessage:
"""Parsed IRC message."""
raw: str
prefix: Optional[str] = None
command: str = ""
params: list[str] = field(default_factory=list)
trailing: Optional[str] = None
event: IRCEvent = IRCEvent.MESSAGE
class IRCError(Exception):
"""Base IRC error."""
pass
class IRCConnectionError(IRCError):
"""Connection failed."""
pass
class IRCClient:
"""Minimal IRC client for per-request ebook searches."""
def __init__(
self,
nick: str,
server: str,
port: int,
use_tls: bool = True,
version: str = "Shelfmark 1.0",
):
if not nick:
raise IRCError("IRC nickname is required")
if not server:
raise IRCError("IRC server is required")
if not port:
raise IRCError("IRC port is required")
self.nick = nick
self.server = server
self.port = port
self.use_tls = use_tls
self.version = version
self._socket: Optional[socket.socket] = None
self._buffer = ""
self._connected = False
# Track online servers (elevated users in channel)
self.online_servers: set[str] = set()
def connect(self) -> None:
"""Connect to IRC server, send USER/NICK, and wait for welcome."""
logger.info(f"Connecting to {self.server}:{self.port} (TLS={self.use_tls})")
try:
# Create socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(SOCKET_TIMEOUT)
# Wrap with TLS if needed
if self.use_tls:
context = ssl.create_default_context()
# Skip verification for self-signed certs common on IRC servers
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
sock = context.wrap_socket(sock, server_hostname=self.server)
sock.connect((self.server, self.port))
self._socket = sock
except socket.error as e:
raise IRCConnectionError(f"Failed to connect: {e}")
# Send authentication (USER before NICK per IRC protocol)
self._send(f"USER {self.nick} 0 * :{self.nick}")
self._send(f"NICK {self.nick}")
# Wait for server to process welcome messages
logger.debug(f"Waiting {POST_CONNECT_DELAY}s for server welcome")
time.sleep(POST_CONNECT_DELAY)
self._connected = True
logger.info(f"Connected as {self.nick}")
def disconnect(self) -> None:
"""Gracefully disconnect from server."""
if self._socket:
try:
self._send("QUIT :Goodbye")
except Exception:
pass # Best effort
try:
self._socket.close()
except Exception:
pass
self._socket = None
self._connected = False
logger.info("Disconnected from IRC")
def join_channel(self, channel: str, wait_for_join: bool = True) -> None:
"""Join an IRC channel (without # prefix) and capture online servers."""
self._send(f"JOIN #{channel}")
logger.debug(f"Sent JOIN #{channel}")
# Clear any existing server list before joining
self.online_servers.clear()
if wait_for_join:
# Wait for end of NAMES list (366) which confirms we're in the channel
start = time.time()
timeout = 10.0 # 10 seconds should be plenty
for line in self._recv_lines():
if time.time() - start > timeout:
logger.warning(f"Timeout waiting for JOIN confirmation on #{channel}")
break
msg = self._parse_message(line)
# Handle PING during join wait
if msg.event == IRCEvent.PING:
self._handle_ping(msg)
continue
# 353 = RPL_NAMREPLY - parse the names list
if msg.command == "353":
self._parse_names_list(msg.raw)
continue
# 366 = RPL_ENDOFNAMES - channel join is complete
if msg.command == "366":
logger.info(f"Joined #{channel} - {len(self.online_servers)} servers online")
return
# Check for errors (e.g., banned, channel doesn't exist)
if msg.command in ("473", "474", "475", "403"):
logger.error(f"Cannot join #{channel}: {msg.trailing}")
return
logger.warning(f"Joined #{channel} (no confirmation received)")
def send_message(self, target: str, message: str) -> None:
"""Send a PRIVMSG to a channel or user."""
self._send(f"PRIVMSG {target} :{message}")
logger.debug(f"Sent to {target}: {message[:50]}...")
def send_notice(self, target: str, message: str) -> None:
"""Send a NOTICE to a user."""
self._send(f"NOTICE {target} :{message}")
def request_names(self, channel: str) -> None:
"""Request user list for a channel (without # prefix)."""
self._send(f"NAMES #{channel}")
def _parse_names_list(self, names_data: str) -> None:
"""Parse 353 NAMES reply and extract elevated users (download servers)."""
# Extract the trailing part after the last colon (the actual names)
if ' :' in names_data:
names_part = names_data.split(' :')[-1]
else:
names_part = names_data
for name in names_part.split():
# Check if user has an elevated prefix
if name[0] in ELEVATED_PREFIXES:
# Strip the prefix to get the actual nick
self.online_servers.add(name[1:])
# Note: we only care about elevated users for server status
def _send(self, message: str) -> None:
"""Send raw IRC message."""
if not self._socket:
raise IRCError("Not connected")
data = f"{message}\r\n".encode('utf-8')
self._socket.sendall(data)
def _recv_lines(self) -> Iterator[str]:
"""Receive and yield complete CRLF-delimited IRC lines."""
while True:
# Check if we have a complete line in buffer
while '\r\n' in self._buffer:
line, self._buffer = self._buffer.split('\r\n', 1)
if line:
yield line
# Read more data
try:
data = self._socket.recv(RECV_BUFFER)
if not data:
return # Connection closed
self._buffer += data.decode('utf-8', errors='replace')
except socket.timeout:
continue # Keep waiting
except socket.error as e:
logger.warning(f"Socket error: {e}")
return # Connection error
def _parse_message(self, line: str) -> IRCMessage:
"""Parse an IRC message line into components.
Format: [:prefix] COMMAND [params] [:trailing]
"""
msg = IRCMessage(raw=line)
# Extract prefix if present
if line.startswith(':'):
space_idx = line.find(' ')
if space_idx != -1:
msg.prefix = line[1:space_idx]
line = line[space_idx + 1:]
# Extract trailing if present
if ' :' in line:
idx = line.find(' :')
msg.trailing = line[idx + 2:]
line = line[:idx]
# Split remaining into command and params
parts = line.split()
if parts:
msg.command = parts[0]
msg.params = parts[1:]
# Classify event type based on message content
msg.event = self._classify_event(msg)
return msg
def _classify_event(self, msg: IRCMessage) -> IRCEvent:
"""Classify message into event type using string containment checks."""
raw = msg.raw
trailing = msg.trailing or ""
# DCC SEND detection
if "DCC SEND" in raw:
if "_results_for" in raw:
return IRCEvent.SEARCH_RESULT
return IRCEvent.BOOK_RESULT
# NOTICE messages
if msg.command == "NOTICE" or "NOTICE" in raw:
if "Sorry" in trailing:
return IRCEvent.NO_RESULTS
if "try another server" in trailing:
return IRCEvent.BAD_SERVER
if "has been accepted" in trailing:
return IRCEvent.SEARCH_ACCEPTED
if "matches" in trailing:
return IRCEvent.MATCHES_FOUND
# User list (RPL_NAMREPLY and RPL_ENDOFNAMES)
if msg.command in ("353", "366"):
return IRCEvent.SERVER_LIST
# Server PING
if msg.command == "PING":
return IRCEvent.PING
# CTCP VERSION
if "\x01VERSION\x01" in raw:
return IRCEvent.VERSION
return IRCEvent.MESSAGE
def _handle_ping(self, msg: IRCMessage) -> None:
"""Respond to server PING with PONG."""
# PING message format: PING :server
server = msg.trailing or self.server
self._send(f"PONG :{server}")
logger.debug(f"PONG {server}")
def _handle_version(self, msg: IRCMessage) -> None:
"""Respond to CTCP VERSION request."""
if msg.prefix:
# Extract nick from prefix (nick!user@host)
sender = msg.prefix.split('!')[0]
self.send_notice(sender, f"\x01VERSION {self.version}\x01")
logger.debug(f"Sent VERSION to {sender}")
def read_messages(self, auto_handle: bool = True) -> Iterator[IRCMessage]:
"""Read and yield IRC messages, optionally auto-handling PING/VERSION."""
for line in self._recv_lines():
msg = self._parse_message(line)
# Auto-handle certain events
if auto_handle:
if msg.event == IRCEvent.PING:
self._handle_ping(msg)
continue # Don't yield PING messages
if msg.event == IRCEvent.VERSION:
self._handle_version(msg)
continue # Don't yield VERSION messages
yield msg
def wait_for_dcc(
self,
timeout: float = 60.0,
result_type: bool = False,
) -> Optional[DCCOffer]:
"""Wait for a DCC SEND offer. Returns None on timeout or no results."""
target_event = IRCEvent.SEARCH_RESULT if result_type else IRCEvent.BOOK_RESULT
start = time.time()
for msg in self.read_messages():
if time.time() - start > timeout:
logger.warning("Timeout waiting for DCC offer")
return None
if msg.event == target_event:
try:
offer = parse_dcc_send(msg.raw)
logger.info(f"Received DCC offer: {offer.filename}")
return offer
except Exception as e:
logger.error(f"Failed to parse DCC: {e}")
return None
# Log other events for debugging
if msg.event == IRCEvent.NO_RESULTS:
logger.info("Server reports no results")
return None
elif msg.event == IRCEvent.BAD_SERVER:
logger.warning("Server unavailable")
return None
elif msg.event == IRCEvent.SEARCH_ACCEPTED:
logger.info("Search accepted, waiting for results...")
elif msg.event == IRCEvent.MATCHES_FOUND:
# Extract count from "returned X matches"
if msg.trailing and "returned" in msg.trailing:
try:
match = re.search(r'returned\s+(\d+)\s+matches', msg.trailing)
if match:
count = match.group(1)
logger.info(f"Found {count} matches")
except Exception:
pass
return None
@property
def is_connected(self) -> bool:
"""Check if currently connected."""
return self._connected and self._socket is not None
def __enter__(self):
self.connect()
return self
def __exit__(self, *args):
self.disconnect()
+144
View File
@@ -0,0 +1,144 @@
"""DCC (Direct Client-to-Client) protocol implementation.
Handles DCC SEND file transfers used by IRC bots to send files.
"""
import re
import socket
import struct
from dataclasses import dataclass
from pathlib import Path
from threading import Event
from typing import Callable, Optional
from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
# Regex to parse DCC SEND messages - handles quoted filenames
# Format: DCC SEND "filename.epub" 2760158537 2050 2321788
# | | | |
# filename IP(int) port size
DCC_REGEX = re.compile(r'DCC SEND "?(.+[^"])"?\s(\d+)\s+(\d+)\s+(\d+)\s*')
# Buffer size for DCC transfers - 4096 bytes provides good performance
BUFFER_SIZE = 4096
@dataclass
class DCCOffer:
"""Parsed DCC SEND offer."""
filename: str
ip: str
port: int
size: int
@property
def address(self) -> tuple[str, int]:
"""Return (ip, port) tuple for socket.connect()."""
return (self.ip, self.port)
class DCCError(Exception):
"""Base exception for DCC operations."""
pass
class DCCParseError(DCCError):
"""Failed to parse DCC SEND string."""
pass
class DCCSizeError(DCCError):
"""Downloaded size doesn't match expected size."""
pass
class DCCConnectionError(DCCError):
"""Failed to connect to DCC sender."""
pass
def int_to_ip(ip_int: int) -> str:
"""Convert 32-bit integer (DCC format) to dotted IP notation."""
packed = struct.pack('>I', ip_int)
return '.'.join(str(b) for b in packed)
def parse_dcc_send(text: str) -> DCCOffer:
"""Parse a DCC SEND message into a DCCOffer. Raises DCCParseError on failure."""
match = DCC_REGEX.search(text)
if not match:
raise DCCParseError(f"Invalid DCC SEND format: {text[:100]}")
filename = match.group(1).strip('"')
ip_int = int(match.group(2))
port = int(match.group(3))
size = int(match.group(4))
return DCCOffer(
filename=filename,
ip=int_to_ip(ip_int),
port=port,
size=size,
)
def download_dcc(
offer: DCCOffer,
dest_path: Path,
progress_callback: Optional[Callable[[float], None]] = None,
cancel_flag: Optional[Event] = None,
timeout: float = 30.0,
) -> None:
"""Download file via DCC protocol to dest_path. Raises DCCError on failure."""
logger.info(f"DCC connecting to {offer.ip}:{offer.port} for {offer.filename}")
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)
sock.connect(offer.address)
except socket.error as e:
raise DCCConnectionError(f"Failed to connect to {offer.ip}:{offer.port}: {e}")
try:
received = 0
last_progress = -1
with open(dest_path, 'wb') as f:
while received < offer.size:
# Check for cancellation
if cancel_flag and cancel_flag.is_set():
logger.info("DCC download cancelled")
return
# Read chunk
try:
chunk = sock.recv(BUFFER_SIZE)
except socket.timeout:
raise DCCError(f"Timeout reading from {offer.ip}:{offer.port}")
if not chunk:
# Connection closed prematurely
break
f.write(chunk)
received += len(chunk)
# Report progress (every 1%)
if progress_callback:
progress = int((received / offer.size) * 100)
if progress != last_progress:
progress_callback(progress)
last_progress = progress
# Verify downloaded size matches expected
if received != offer.size:
raise DCCSizeError(
f"Size mismatch: expected {offer.size} bytes, got {received}"
)
logger.info(f"DCC download complete: {received} bytes")
finally:
sock.close()
+137
View File
@@ -0,0 +1,137 @@
"""IRC DCC download handler.
Handles downloading books via IRC DCC protocol.
"""
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.release_sources import DownloadHandler, register_handler
from .client import IRCClient
from .dcc import DCCError, download_dcc
logger = setup_logger(__name__)
@register_handler("irc")
class IRCDownloadHandler(DownloadHandler):
"""Handle IRC DCC downloads."""
def download(
self,
task: DownloadTask,
cancel_flag: Event,
progress_callback: Callable[[float], None],
status_callback: Callable[[str, Optional[str]], None],
) -> Optional[str]:
"""Download a book via IRC DCC. task.task_id contains the IRC request string."""
download_request = task.task_id
logger.info(f"IRC download: {download_request[:60]}...")
# Get IRC settings
server = config.get("IRC_SERVER", "")
port = config.get("IRC_PORT", 6697)
channel = config.get("IRC_CHANNEL", "")
nick = config.get("IRC_NICK", "")
if not server or not channel or not nick:
logger.warning("IRC not fully configured")
status_callback("failed", "IRC not configured")
return None
client = None
def check_cancelled() -> bool:
"""Check if cancelled and handle cleanup."""
if not cancel_flag.is_set():
return False
if client:
client.disconnect()
status_callback("cancelled", "Cancelled")
return True
try:
# Phase 1: Connect to IRC
status_callback("resolving", f"Connecting to {server}")
if check_cancelled():
return None
client = IRCClient(nick, server, port)
client.connect()
client.join_channel(channel)
# Phase 2: Send download request
status_callback("resolving", "Requesting file from bot")
if check_cancelled():
return None
# Send the full request line to the channel
client.send_message(f"#{channel}", download_request)
# Phase 3: Wait for DCC offer
status_callback("resolving", "Waiting for bot response")
offer = client.wait_for_dcc(timeout=120.0, result_type=False)
if not offer:
status_callback("error", "No response from bot")
client.disconnect()
return None
if check_cancelled():
return None
# Phase 4: Download via DCC
status_callback("downloading", "")
# Get file extension from offer filename
ext = Path(offer.filename).suffix.lstrip('.') or task.format or "epub"
# Stage to temp directory (lazy import to avoid circular import)
from shelfmark.download.orchestrator import get_staging_path
staging_path = get_staging_path(task.task_id, ext)
download_dcc(
offer=offer,
dest_path=staging_path,
progress_callback=progress_callback,
cancel_flag=cancel_flag,
timeout=60.0,
)
client.disconnect()
if cancel_flag.is_set():
# Clean up partial download
staging_path.unlink(missing_ok=True)
status_callback("cancelled", "Cancelled")
return None
logger.info(f"Download complete: {staging_path}")
return str(staging_path)
except DCCError as e:
logger.error(f"DCC error: {e}")
status_callback("error", str(e))
if client:
client.disconnect()
return None
except Exception as e:
logger.error(f"Download failed: {e}")
status_callback("error", f"Download failed: {e}")
if client:
client.disconnect()
return None
def cancel(self, task_id: str) -> bool:
"""Cancel an in-progress download (cleanup if cancel_flag fails)."""
logger.debug(f"Cancel requested for IRC task: {task_id}")
return True
+188
View File
@@ -0,0 +1,188 @@
"""Search results file parser.
Parses the text files sent via DCC that contain search results.
"""
import re
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
# All recognized formats for parsing IRC result lines.
# This comprehensive list is used to identify file extensions in results.
# User's configured formats are used separately for filtering.
# Note: IRC source currently only supports ebooks, but audiobook formats
# are included for future-proofing and format detection consistency.
ALL_RECOGNIZED_FORMATS = {
# Ebook formats
'epub', 'mobi', 'azw3', 'azw', 'pdf', 'doc', 'docx',
'html', 'htm', 'rtf', 'txt', 'lit', 'fb2', 'djvu',
'cbr', 'cbz', 'cdr', 'jpg', 'rar', 'zip',
# Audiobook formats
'm4b', 'mp3', 'm4a', 'flac', 'ogg', 'wma', 'aac', 'wav', 'opus'
}
def _get_supported_formats() -> set[str]:
"""Get user's configured supported formats from settings."""
formats = config.get("SUPPORTED_FORMATS", ["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"])
if isinstance(formats, str):
return {fmt.strip().lower() for fmt in formats.split(",") if fmt.strip()}
return {fmt.lower() for fmt in formats}
# Regex to parse result lines
# Format: !Server Author - Title.format ::INFO:: size
RESULT_LINE_REGEX = re.compile(
r'^!(\S+)\s+' # !ServerName
r'(.+?)\s+-\s+' # Author Name -
r'(.+?)\.(\w+)' # Title.format
r'(?:\s+::INFO::\s*(.+?))?' # Optional ::INFO:: metadata
r'(?:\s+::HASH::\s*(\S+))?' # Optional ::HASH::
r'\s*$'
)
# Simpler fallback pattern
SIMPLE_RESULT_REGEX = re.compile(
r'^!(\S+)\s+(.+)$' # !Server everything_else
)
@dataclass
class SearchResult:
"""Parsed search result entry."""
server: str # Bot name (without !)
author: str # Author name
title: str # Book title
format: str # File format (epub, mobi, etc)
size: Optional[str] # Human-readable size
full_line: str # Original line for download request
@property
def download_request(self) -> str:
"""The string to send to IRC to request this book."""
return self.full_line.strip()
@property
def display_name(self) -> str:
"""Human-readable display name."""
return f"{self.author} - {self.title}"
def parse_result_line(line: str) -> Optional[SearchResult]:
"""Parse a single search result line. Returns None if unparseable."""
line = line.strip()
# Must start with !
if not line.startswith('!'):
return None
# Try detailed pattern first
match = RESULT_LINE_REGEX.match(line)
if match:
server, author, title, fmt, size, _ = match.groups()
return SearchResult(
server=server,
author=author.strip(),
title=title.strip(),
format=fmt.lower(),
size=size.strip() if size else None,
full_line=line,
)
# Fallback: simpler parsing
match = SIMPLE_RESULT_REGEX.match(line)
if match:
server, rest = match.groups()
# Try to extract format from the line
fmt = None
for known_fmt in ALL_RECOGNIZED_FORMATS:
if f'.{known_fmt}' in rest.lower():
fmt = known_fmt
break
# Try to split author - title
if ' - ' in rest:
parts = rest.split(' - ', 1)
author = parts[0].strip()
title_part = parts[1].strip() if len(parts) > 1 else rest
else:
author = "Unknown"
title_part = rest
# Extract size if present
size = None
if '::INFO::' in title_part:
title_part, info = title_part.split('::INFO::', 1)
size = info.split('::')[0].strip()
# Clean up title (remove extension)
title = title_part
for known_fmt in ALL_RECOGNIZED_FORMATS:
title = re.sub(rf'\.{known_fmt}\b', '', title, flags=re.IGNORECASE)
return SearchResult(
server=server,
author=author,
title=title.strip(),
format=fmt or 'unknown',
size=size,
full_line=line,
)
logger.debug(f"Could not parse line: {line[:80]}...")
return None
def parse_results_file(content: str) -> list[SearchResult]:
"""Parse a search results file into SearchResult objects."""
results = []
supported = _get_supported_formats()
for line in content.splitlines():
result = parse_result_line(line)
if result:
# Filter to user's configured formats
if result.format in supported or result.format == 'unknown':
results.append(result)
logger.info(f"Parsed {len(results)} results from search file")
return results
def extract_results_from_zip(zip_path: Path) -> str:
"""Extract and return text content from a search results ZIP."""
with zipfile.ZipFile(zip_path, 'r') as zf:
# Should contain exactly one text file
names = zf.namelist()
if not names:
raise ValueError("Empty ZIP file")
# Find the text file
txt_file = None
for name in names:
if name.endswith('.txt'):
txt_file = name
break
if not txt_file:
# Use first file
txt_file = names[0]
content = zf.read(txt_file)
# Try different encodings
for encoding in ['utf-8', 'latin-1', 'cp1252']:
try:
return content.decode(encoding)
except UnicodeDecodeError:
continue
# Last resort
return content.decode('utf-8', errors='replace')
+119
View File
@@ -0,0 +1,119 @@
"""IRC settings registration.
Registers IRC settings for the settings UI.
"""
from shelfmark.core.settings_registry import (
ActionButton,
HeadingField,
NumberField,
SelectField,
TextField,
register_settings,
)
def _clear_irc_cache():
"""Clear all cached IRC search results."""
from shelfmark.release_sources.irc.cache import clear_cache, get_cache_stats
stats = get_cache_stats()
count = clear_cache()
return {
"success": True,
"message": f"Cleared {count} cached searches ({stats['total_releases']} releases)",
}
@register_settings(
name="irc",
display_name="IRC",
icon="download",
order=56,
)
def irc_settings():
"""Define IRC source settings."""
return [
HeadingField(
key="heading",
title="IRC",
description=(
"Search and download books from IRC ebook channels. "
"This source connects via IRC and uses DCC for file transfers. "
"Configure the connection details below to enable IRC search. "
"Note: DCC requires direct TCP connections to arbitrary ports, "
"which may not work behind strict firewalls or NAT."
),
),
TextField(
key="IRC_SERVER",
label="Server",
placeholder="e.g. irc.example.net",
description="IRC server hostname",
required=True,
env_supported=True,
),
NumberField(
key="IRC_PORT",
label="Port",
default=6697,
description="IRC server port (usually 6697 for TLS, 6667 for plain)",
env_supported=True,
),
TextField(
key="IRC_CHANNEL",
label="Channel",
placeholder="e.g. ebooks",
description="Channel name without the # prefix",
required=True,
env_supported=True,
),
TextField(
key="IRC_NICK",
label="Nickname",
placeholder="e.g. myusername",
description="Your IRC nickname (required). Must be unique on the IRC network.",
required=True,
env_supported=True,
),
TextField(
key="IRC_SEARCH_BOT",
label="Search bot",
placeholder="e.g. search",
description="The search bot to query for results",
env_supported=True,
),
HeadingField(
key="cache_heading",
title="Search Cache",
description=(
"IRC search results are cached to reduce load on IRC servers. "
"Use the Refresh button in the release modal to force a new search."
),
),
SelectField(
key="IRC_CACHE_TTL",
label="Cache Duration",
description="How long to keep cached search results before they expire.",
options=[
{"value": "2592000", "label": "30 days"},
{"value": "0", "label": "Forever (until manually cleared)"},
],
default="2592000", # 30 days
),
ActionButton(
key="clear_irc_cache",
label="Clear Cache",
description="Remove all cached IRC search results.",
style="danger",
callback=_clear_irc_cache,
),
]
+347
View File
@@ -0,0 +1,347 @@
"""IRC release source plugin.
Searches IRC ebook channels for book releases.
"""
import tempfile
import time
from pathlib import Path
from typing import List, Optional
from shelfmark.api.websocket import ws_manager
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.metadata_providers import BookMetadata
from shelfmark.release_sources import (
ColumnColorHint,
ColumnRenderType,
ColumnSchema,
LeadingCellConfig,
LeadingCellType,
Release,
ReleaseColumnConfig,
ReleaseProtocol,
ReleaseSource,
SourceActionButton,
register_source,
)
from .client import IRCClient
from .dcc import DCCError, download_dcc
from .parser import SearchResult, extract_results_from_zip, parse_results_file
logger = setup_logger(__name__)
def _emit_status(message: str, phase: str = 'searching') -> None:
"""Emit search status to frontend via WebSocket."""
ws_manager.broadcast_search_status(
source='irc',
provider='',
book_id='',
message=message,
phase=phase,
)
# Rate limiting to avoid server throttling
MIN_SEARCH_INTERVAL = 15.0
_last_search_time: float = 0
def _enforce_rate_limit() -> None:
"""Ensure minimum time between searches."""
global _last_search_time
elapsed = time.time() - _last_search_time
if elapsed < MIN_SEARCH_INTERVAL:
wait_time = MIN_SEARCH_INTERVAL - elapsed
logger.info(f"Rate limiting: waiting {wait_time:.1f}s")
time.sleep(wait_time)
_last_search_time = time.time()
@register_source("irc")
class IRCReleaseSource(ReleaseSource):
"""Search IRC channels for book releases."""
name = "irc"
display_name = "IRC"
supported_content_types = ["ebook"] # IRC only supports ebooks
can_be_default = False # Exclude from default source options (requires deliberate selection)
def __init__(self):
# Track online servers from most recent search
self._online_servers: Optional[set[str]] = None
@classmethod
def is_available(cls) -> bool:
"""Check if IRC is configured (server, channel, and nick are set)."""
server = config.get("IRC_SERVER", "")
channel = config.get("IRC_CHANNEL", "")
nick = config.get("IRC_NICK", "")
return bool(server and channel and nick)
def get_column_config(self) -> ReleaseColumnConfig:
"""Configure UI columns for IRC results."""
return ReleaseColumnConfig(
columns=[
ColumnSchema(
key="extra.server",
label="Server",
render_type=ColumnRenderType.TEXT,
width="100px",
sortable=True,
),
ColumnSchema(
key="format",
label="Format",
render_type=ColumnRenderType.BADGE,
color_hint=ColumnColorHint(type="map", value="format"),
width="70px",
uppercase=True,
sortable=True,
),
ColumnSchema(
key="size",
label="Size",
render_type=ColumnRenderType.TEXT,
width="70px",
sortable=True,
sort_key="size_bytes",
),
],
grid_template="minmax(0,2fr) 100px 70px 70px",
leading_cell=LeadingCellConfig(type=LeadingCellType.NONE),
online_servers=list(self._online_servers) if self._online_servers else None,
cache_ttl_seconds=1800, # 30 minutes - IRC searches are slow, cache longer
supported_filters=["format"], # IRC has no language metadata
action_button=SourceActionButton(label="Refresh search"),
)
def search(
self,
book: BookMetadata,
expand_search: bool = False,
languages: Optional[List[str]] = None,
content_type: str = "ebook"
) -> List[Release]:
"""Search IRC for books matching metadata.
The expand_search parameter is repurposed for IRC as a "refresh" flag.
When True, it bypasses the cache and forces a fresh search.
"""
from .cache import get_cached_results, cache_results
if not self.is_available():
logger.debug("IRC source is disabled, skipping search")
return []
# Check cache first (unless expand_search/refresh is requested)
if not expand_search:
cached = get_cached_results(book.provider, book.provider_id)
if cached:
_emit_status("Using cached results", phase='complete')
self._online_servers = set(cached.get("online_servers", []))
return cached["releases"]
# Build search query
query = self._build_query(book)
if not query:
logger.warning("No search query could be built")
return []
logger.info(f"IRC search: {query}")
# Enforce rate limit
_enforce_rate_limit()
# Get IRC settings
server = config.get("IRC_SERVER", "")
port = config.get("IRC_PORT", 6697)
channel = config.get("IRC_CHANNEL", "")
nick = config.get("IRC_NICK", "")
search_bot = config.get("IRC_SEARCH_BOT", "")
client = None
try:
# Connect to IRC
_emit_status(f"Connecting to {server}...", phase='connecting')
client = IRCClient(nick, server, port)
client.connect()
_emit_status(f"Joining #{channel}...", phase='connecting')
client.join_channel(channel)
# Capture online servers (elevated users in channel)
self._online_servers = client.online_servers
# Send search request
search_msg = f"@{search_bot} {query}" if search_bot else query
client.send_message(f"#{channel}", search_msg)
# Wait for results DCC - this is the long wait
_emit_status(f"Connected to #{channel} - Waiting for results...", phase='searching')
offer = client.wait_for_dcc(timeout=60.0, result_type=True)
if not offer:
logger.info("No search results received")
_emit_status("No results found", phase='complete')
client.disconnect()
# Cache empty result to avoid repeated failed searches
cache_results(
book.provider,
book.provider_id,
book.title,
[],
list(self._online_servers) if self._online_servers else None
)
return []
# Download results file
_emit_status(f"Connected to #{channel} - Downloading results...", phase='downloading')
with tempfile.TemporaryDirectory() as tmpdir:
result_path = Path(tmpdir) / offer.filename
download_dcc(offer, result_path, timeout=30.0)
# Parse results
if result_path.suffix.lower() == '.zip':
content = extract_results_from_zip(result_path)
else:
content = result_path.read_text(errors='replace')
client.disconnect()
# Convert to Release objects
results = parse_results_file(content)
releases = self._convert_to_releases(results)
# Cache results
cache_results(
book.provider,
book.provider_id,
book.title,
releases,
list(self._online_servers) if self._online_servers else None
)
return releases
except DCCError as e:
logger.error(f"DCC error during search: {e}")
_emit_status(f"DCC error: {e}", phase='error')
if client:
client.disconnect()
return []
except Exception as e:
logger.error(f"IRC search failed: {e}")
_emit_status(f"Search failed: {e}", phase='error')
if client:
client.disconnect()
return []
def _build_query(self, book: BookMetadata) -> str:
"""Build search query from book metadata."""
parts = []
if book.title:
parts.append(book.title)
if book.authors:
# Use first author
author = book.authors[0] if isinstance(book.authors, list) else book.authors
parts.append(author)
return ' '.join(parts)
# Format priority for sorting (lower = higher priority)
FORMAT_PRIORITY = {
'epub': 0,
'mobi': 1,
'azw3': 2,
'azw': 3,
'fb2': 4,
'djvu': 5,
'pdf': 6,
'cbr': 7,
'cbz': 8,
'doc': 9,
'docx': 10,
'rtf': 11,
'txt': 12,
'html': 13,
'htm': 14,
'rar': 15,
'zip': 16,
}
def _convert_to_releases(self, results: List[SearchResult]) -> List[Release]:
"""Convert parsed results to Release objects, sorted by online/format/server."""
releases = []
online_servers = self._online_servers if self._online_servers else set()
for result in results:
release = Release(
source="irc",
source_id=result.download_request, # Full line for download
title=result.title,
format=result.format,
size=result.size,
size_bytes=self._parse_size(result.size) if result.size else None,
protocol=ReleaseProtocol.DCC,
indexer=f"IRC:{result.server}",
extra={
"server": result.server,
"author": result.author,
"full_line": result.full_line,
},
)
releases.append(release)
# Tiered sort: online first, then by format priority, then by server name
def sort_key(release: Release) -> tuple:
server = release.extra.get("server", "")
is_online = server in online_servers
fmt = release.format.lower() if release.format else ""
format_priority = self.FORMAT_PRIORITY.get(fmt, 99)
return (
0 if is_online else 1, # Online first
format_priority, # Then by format
server.lower(), # Then alphabetically by server
)
releases.sort(key=sort_key)
return releases
@staticmethod
def _parse_size(size_str: str) -> Optional[int]:
"""Parse human-readable size (e.g., '1.2MB', '500K') to bytes."""
if not size_str:
return None
size_str = size_str.strip().upper()
# Map suffixes to multipliers (check longer suffixes first)
multipliers = [
('GB', 1024 * 1024 * 1024),
('MB', 1024 * 1024),
('KB', 1024),
('G', 1024 * 1024 * 1024),
('M', 1024 * 1024),
('K', 1024),
('B', 1),
]
for suffix, mult in multipliers:
if size_str.endswith(suffix):
try:
num = float(size_str[:-len(suffix)].strip())
return int(num * mult)
except ValueError:
return None
# Try parsing as plain number (bytes)
try:
return int(float(size_str))
except ValueError:
return None
@@ -0,0 +1,26 @@
"""
Prowlarr release source plugin.
This plugin integrates with Prowlarr to search for book releases
across multiple indexers (torrent and usenet).
Includes:
- ProwlarrSource: Search integration with Prowlarr
- ProwlarrHandler: Download handling via external clients
- Download clients: qBittorrent (torrents), NZBGet (usenet)
"""
# Import submodules to trigger decorator registration
from shelfmark.release_sources.prowlarr import source # noqa: F401
from shelfmark.release_sources.prowlarr import handler # noqa: F401
from shelfmark.release_sources.prowlarr import settings # noqa: F401
# Import clients to trigger client registration
# This is in a try/except to handle optional dependencies gracefully
try:
from shelfmark.release_sources.prowlarr import clients # noqa: F401
except ImportError as e:
# Log but don't fail - clients require optional dependencies
import logging
logging.getLogger(__name__).debug(f"Prowlarr clients not loaded: {e}")
+148
View File
@@ -0,0 +1,148 @@
"""Prowlarr API client for connection testing, indexer listing, and search."""
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urljoin
import requests
from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
class ProwlarrClient:
"""Client for interacting with the Prowlarr API."""
def __init__(self, url: str, api_key: str, timeout: int = 30):
self.base_url = url.rstrip("/")
self.api_key = api_key
self.timeout = timeout
self._session = requests.Session()
self._session.headers.update({
"X-Api-Key": api_key,
"Accept": "application/json",
})
def _request(
self,
method: str,
endpoint: str,
params: Optional[Dict[str, Any]] = None,
json_data: Optional[Dict[str, Any]] = None,
) -> Any:
"""Make an API request to Prowlarr. Returns parsed JSON response."""
url = urljoin(self.base_url, endpoint)
logger.debug(f"Prowlarr API: {method} {url}")
try:
response = self._session.request(
method=method,
url=url,
params=params,
json=json_data,
timeout=self.timeout,
)
if not response.ok:
try:
error_body = response.text[:500]
logger.error(f"Prowlarr API error response: {error_body}")
except Exception:
pass
response.raise_for_status()
return response.json()
except requests.exceptions.JSONDecodeError as e:
logger.error(f"Invalid JSON response from Prowlarr: {e}")
raise ValueError(f"Invalid JSON response: {e}")
except requests.exceptions.HTTPError as e:
logger.error(f"Prowlarr API HTTP error: {e.response.status_code} {e.response.reason}")
raise
except requests.exceptions.RequestException as e:
logger.error(f"Prowlarr API request failed: {e}")
raise
def test_connection(self) -> Tuple[bool, str]:
"""Test connection to Prowlarr. Returns (success, message)."""
logger.info(f"Testing Prowlarr connection to: {self.base_url}")
try:
data = self._request("GET", "/api/v1/system/status")
version = data.get("version", "unknown")
logger.info(f"Prowlarr connection successful: version {version}")
return True, f"Connected to Prowlarr {version}"
except requests.exceptions.ConnectionError:
return False, "Could not connect to Prowlarr. Check the URL."
except requests.exceptions.HTTPError as e:
status = e.response.status_code if e.response is not None else "unknown"
if e.response is not None and e.response.status_code == 401:
return False, "Invalid API key"
return False, f"HTTP error {status}"
except Exception as e:
return False, f"Connection failed: {str(e)}"
def get_indexers(self) -> List[Dict[str, Any]]:
"""Get all configured indexers."""
try:
indexers = self._request("GET", "/api/v1/indexer")
return indexers
except Exception as e:
logger.error(f"Failed to get indexers: {e}")
return []
def get_enabled_indexers(self) -> List[Dict[str, Any]]:
"""Get enabled indexers with book capability info."""
indexers = self.get_indexers()
result = []
for idx in indexers:
if not idx.get("enable", False):
continue
# Check for book categories (7000-7999 range)
categories = idx.get("capabilities", {}).get("categories", [])
has_books = self._has_book_categories(categories)
result.append({
"id": idx.get("id"),
"name": idx.get("name"),
"protocol": idx.get("protocol"),
"has_books": has_books,
})
return result
def _has_book_categories(self, categories: List[Dict[str, Any]]) -> bool:
"""Check if any category or subcategory is in the book range (7000-7999)."""
for cat in categories:
cat_id = cat.get("id", 0)
if 7000 <= cat_id <= 7999:
return True
for subcat in cat.get("subCategories", []):
if 7000 <= subcat.get("id", 0) <= 7999:
return True
return False
def search(
self,
query: str,
indexer_ids: Optional[List[int]] = None,
categories: Optional[List[int]] = None,
limit: int = 100,
) -> List[Dict[str, Any]]:
"""Search for releases via Prowlarr."""
if not query:
return []
params: Dict[str, Any] = {"query": query, "limit": limit}
if indexer_ids:
params["indexerIds"] = indexer_ids
if categories:
params["categories"] = categories
try:
results = self._request("GET", "/api/v1/search", params=params)
return results if isinstance(results, list) else []
except Exception as e:
logger.error(f"Prowlarr search failed: {e}")
return []
+113
View File
@@ -0,0 +1,113 @@
"""
Prowlarr release cache.
Stores search results so the handler can look up releases by source_id.
This keeps all Prowlarr-specific data within the plugin.
"""
import time
from threading import Lock
from typing import Dict, Optional
from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
# Cache TTL in seconds (1 hour - releases should be downloaded within this time)
RELEASE_CACHE_TTL = 3600
# Internal cache storage: source_id -> (release_dict, timestamp)
_cache: Dict[str, tuple] = {}
_cache_lock = Lock()
def cache_release(source_id: str, release_data: dict) -> None:
"""
Cache a release by its source_id.
Args:
source_id: The unique identifier for this release (GUID)
release_data: The full Prowlarr API result dict
"""
with _cache_lock:
_cache[source_id] = (release_data, time.time())
def get_release(source_id: str) -> Optional[dict]:
"""
Get a cached release by source_id.
Args:
source_id: The unique identifier for the release
Returns:
The cached release dict, or None if not found or expired
"""
with _cache_lock:
if source_id not in _cache:
logger.debug(f"Prowlarr release not in cache: {source_id}")
return None
release_data, cached_at = _cache[source_id]
age = time.time() - cached_at
if age > RELEASE_CACHE_TTL:
# Expired - remove from cache
del _cache[source_id]
logger.debug(f"Prowlarr release expired: {source_id}")
return None
return release_data
def remove_release(source_id: str) -> None:
"""
Remove a release from the cache (e.g., after successful download).
Args:
source_id: The unique identifier for the release
"""
with _cache_lock:
if source_id in _cache:
del _cache[source_id]
logger.debug(f"Removed Prowlarr release from cache: {source_id}")
def cleanup_expired() -> int:
"""
Remove all expired entries from the cache.
Returns:
Number of entries removed
"""
current_time = time.time()
removed = 0
with _cache_lock:
expired_ids = [
source_id
for source_id, (_, cached_at) in _cache.items()
if current_time - cached_at > RELEASE_CACHE_TTL
]
for source_id in expired_ids:
del _cache[source_id]
removed += 1
if removed:
logger.debug(f"Cleaned up {removed} expired Prowlarr cache entries")
return removed
def get_cache_stats() -> dict:
"""
Get cache statistics for debugging.
Returns:
Dict with cache stats
"""
with _cache_lock:
return {
"size": len(_cache),
"entries": list(_cache.keys()),
}
@@ -0,0 +1,300 @@
"""
Download client infrastructure for Prowlarr integration.
This module provides:
- DownloadState: Enum of valid download states
- DownloadStatus: Status dataclass for external download progress
- DownloadClient: Abstract base class for download clients
- Client registry and factory functions
Clients register themselves via the @register_client decorator.
"""
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
from typing import Dict, List, Optional, Tuple, Type, Union
_logger = logging.getLogger(__name__)
class DownloadState(Enum):
"""Valid states for a download."""
DOWNLOADING = "downloading"
COMPLETE = "complete"
ERROR = "error"
SEEDING = "seeding"
PAUSED = "paused"
QUEUED = "queued"
CHECKING = "checking"
PROCESSING = "processing"
UNKNOWN = "unknown"
@dataclass(frozen=True)
class DownloadStatus:
"""Status of an external download (immutable)."""
progress: float # 0-100
state: Union[DownloadState, str] # Prefer DownloadState enum; strings auto-normalized
message: Optional[str] # Status message
complete: bool # True when download finished
file_path: Optional[str] # Path in client's download dir (when complete)
download_speed: Optional[int] = None # Bytes per second
eta: Optional[int] = None # Seconds remaining
@classmethod
def error(cls, message: str) -> "DownloadStatus":
"""Create an error status."""
return cls(
progress=0,
state=DownloadState.ERROR,
message=message,
complete=False,
file_path=None,
)
def __post_init__(self):
"""Validate and normalize state."""
# Normalize string states to enum
if isinstance(self.state, str):
try:
normalized_state = DownloadState(self.state)
object.__setattr__(self, 'state', normalized_state)
except ValueError:
# Unknown state string - keep as-is for backwards compatibility
_logger.warning(f"Unknown download state '{self.state}', keeping as string")
# Validate progress is in range
if not 0 <= self.progress <= 100:
_logger.debug(f"Progress {self.progress} out of range, clamping to [0, 100]")
object.__setattr__(self, 'progress', max(0, min(100, self.progress)))
@property
def state_value(self) -> str:
"""Get the state as a string value (for JSON serialization)."""
if isinstance(self.state, DownloadState):
return self.state.value
return self.state
class DownloadClient(ABC):
"""
Base class for external download clients.
Subclasses implement protocol-specific download management:
- Torrent clients: qBittorrent, Transmission, Deluge
- Usenet clients: NZBGet, SABnzbd
Subclasses must define:
- protocol: "torrent" or "usenet"
- name: Unique client identifier (e.g., "qbittorrent", "nzbget")
"""
# Class attributes that subclasses must define
protocol: str
name: str
def __init_subclass__(cls, **kwargs):
"""Validate that subclasses define required class attributes."""
super().__init_subclass__(**kwargs)
# Skip validation for abstract subclasses
if ABC in cls.__bases__:
return
# Validate protocol attribute
if not hasattr(cls, 'protocol') or not cls.protocol:
raise TypeError(f"{cls.__name__} must define 'protocol' class attribute")
if cls.protocol not in ('torrent', 'usenet'):
raise TypeError(
f"{cls.__name__}.protocol must be 'torrent' or 'usenet', got '{cls.protocol}'"
)
# Validate name attribute
if not hasattr(cls, 'name') or not cls.name:
raise TypeError(f"{cls.__name__} must define 'name' class attribute")
@staticmethod
@abstractmethod
def is_configured() -> bool:
"""
Check if this client is configured.
Returns:
True if required settings (URL, etc.) are present.
"""
pass
@abstractmethod
def test_connection(self) -> Tuple[bool, str]:
"""
Test connectivity to the client.
Returns:
Tuple of (success, message).
"""
pass
@abstractmethod
def add_download(self, url: str, name: str, category: str = "cwabd") -> 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
Returns:
Client-specific download ID (hash for torrents, ID for NZBGet).
Raises:
Exception: If adding fails.
"""
pass
@abstractmethod
def get_status(self, download_id: str) -> DownloadStatus:
"""
Get status of a download.
Args:
download_id: The ID returned by add_download()
Returns:
Current download status.
"""
pass
@abstractmethod
def remove(self, download_id: str, delete_files: bool = False) -> bool:
"""
Remove a download from the client.
Args:
download_id: The ID returned by add_download()
delete_files: Whether to also delete downloaded files
Returns:
True if removal succeeded.
"""
pass
@abstractmethod
def get_download_path(self, download_id: str) -> Optional[str]:
"""
Get the path where files were downloaded.
Args:
download_id: The ID returned by add_download()
Returns:
File or directory path, or None if not available.
"""
pass
def find_existing(self, url: str) -> Optional[Tuple[str, DownloadStatus]]:
"""
Check if a download for this URL already exists in the client.
This is useful for detecting already-completed downloads so we can
skip re-downloading and just copy the existing file.
Args:
url: Download URL (magnet link, .torrent URL, or NZB URL)
Returns:
Tuple of (download_id, status) if found, None if not found.
Default implementation returns None.
"""
return None
# Client registry: protocol -> list of client classes
_CLIENTS: Dict[str, List[Type[DownloadClient]]] = {}
def register_client(protocol: str):
"""
Decorator to register a download client for a protocol.
Multiple clients can be registered for the same protocol.
The `is_configured()` method determines which one is active.
Args:
protocol: The protocol this client handles ("torrent" or "usenet")
Example:
@register_client("torrent")
class QBittorrentClient(DownloadClient):
...
"""
def decorator(cls: Type[DownloadClient]) -> Type[DownloadClient]:
if protocol not in _CLIENTS:
_CLIENTS[protocol] = []
_CLIENTS[protocol].append(cls)
return cls
return decorator
def get_client(protocol: str) -> Optional[DownloadClient]:
"""
Get a configured client instance for the given protocol.
Iterates through all registered clients for the protocol and
returns the first one that is configured.
Args:
protocol: "torrent" or "usenet"
Returns:
Configured client instance, or None if not available/configured.
"""
if protocol not in _CLIENTS:
return None
for client_cls in _CLIENTS[protocol]:
if client_cls.is_configured():
return client_cls()
return None
def list_configured_clients() -> List[str]:
"""
List protocols that have configured clients.
Returns:
List of protocol names (e.g., ["torrent", "usenet"]).
"""
result = []
for protocol, client_classes in _CLIENTS.items():
for cls in client_classes:
if cls.is_configured():
result.append(protocol)
break
return result
def get_all_clients() -> Dict[str, List[Type[DownloadClient]]]:
"""
Get all registered client classes.
Returns:
Dict of protocol -> list of client classes.
"""
return dict(_CLIENTS)
# 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
@@ -0,0 +1,308 @@
"""
Deluge download client for Prowlarr integration.
Uses the deluge-client library to communicate with Deluge's RPC daemon.
Note: Deluge uses a custom binary RPC protocol over TCP (default port 58846,
configurable via DELUGE_PORT), which requires the daemon to have
"Allow Remote Connections" enabled.
"""
import base64
from typing import Any, Optional, Tuple
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.release_sources.prowlarr.clients import (
DownloadClient,
DownloadStatus,
register_client,
)
from shelfmark.release_sources.prowlarr.clients.torrent_utils import (
extract_torrent_info,
)
logger = setup_logger(__name__)
def _decode(value: Any) -> Any:
"""Decode bytes to string if needed (Deluge returns bytes for strings)."""
return value.decode('utf-8') if isinstance(value, bytes) else value
@register_client("torrent")
class DelugeClient(DownloadClient):
"""Deluge download client using deluge-client RPC library."""
protocol = "torrent"
name = "deluge"
def __init__(self):
"""Initialize Deluge client with settings from config."""
from deluge_client import DelugeRPCClient
host = config.get("DELUGE_HOST", "localhost")
password = config.get("DELUGE_PASSWORD", "")
if not host:
raise ValueError("DELUGE_HOST is required")
if not password:
raise ValueError("DELUGE_PASSWORD is required")
port = int(config.get("DELUGE_PORT", "58846"))
username = config.get("DELUGE_USERNAME", "")
self._client = DelugeRPCClient(
host=host,
port=port,
username=username,
password=password,
)
self._connected = False
self._category = config.get("DELUGE_CATEGORY", "cwabd")
def _ensure_connected(self):
"""Ensure we're connected to the Deluge daemon."""
if not self._connected:
logger.debug("Connecting to Deluge daemon...")
try:
self._client.connect()
self._connected = True
logger.debug("Connected to Deluge daemon")
except Exception as e:
logger.error(f"Failed to connect to Deluge daemon: {type(e).__name__}: {e}")
raise
@staticmethod
def is_configured() -> bool:
"""Check if Deluge is configured and selected as the torrent client."""
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]:
"""Test connection to Deluge."""
try:
self._ensure_connected()
# Get daemon info
version = self._client.call('daemon.info')
return True, f"Connected to Deluge {version}"
except Exception as e:
self._connected = False
return False, f"Connection failed: {str(e)}"
def add_download(self, url: str, name: str, category: str = None) -> 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)
Returns:
Torrent hash (info_hash).
Raises:
Exception: If adding fails.
"""
try:
self._ensure_connected()
category = category or self._category
torrent_info = extract_torrent_info(url)
if not torrent_info.is_magnet and not torrent_info.torrent_data:
raise Exception("Failed to fetch torrent file")
options = {}
if torrent_info.is_magnet:
# Use magnet URL if available, otherwise original URL
magnet_url = torrent_info.magnet_url or url
torrent_id = self._client.call(
'core.add_torrent_magnet',
magnet_url,
options,
)
else:
filedump = base64.b64encode(torrent_info.torrent_data).decode('ascii')
torrent_id = self._client.call(
'core.add_torrent_file',
f"{name}.torrent",
filedump,
options,
)
if torrent_id:
torrent_id = _decode(torrent_id)
logger.info(f"Added torrent to Deluge: {torrent_id}")
return torrent_id.lower()
raise Exception("Deluge returned no torrent ID")
except Exception as e:
self._connected = False
logger.error(f"Deluge 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:
self._ensure_connected()
# Get torrent status
status = self._client.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 = _decode(status.get(b'state', b'Unknown'))
state, message = state_map.get(deluge_state, ('unknown', deluge_state))
progress = status.get(b'progress', 0)
complete = progress >= 100
if complete:
message = "Complete"
eta = status.get(b'eta')
if eta and eta > 604800:
eta = None
file_path = None
if complete:
save_path = _decode(status.get(b'save_path', b''))
name = _decode(status.get(b'name', b''))
if save_path and name:
file_path = f"{save_path}/{name}"
return DownloadStatus(
progress=progress,
state="complete" if complete else state,
message=message,
complete=complete,
file_path=file_path,
download_speed=status.get(b'download_payload_rate'),
eta=eta,
)
except Exception as e:
self._connected = False
error_type = type(e).__name__
logger.error(f"Deluge 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 Deluge.
Args:
download_id: Torrent info_hash
delete_files: Whether to also delete files
Returns:
True if successful.
"""
try:
self._ensure_connected()
result = self._client.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._connected = False
error_type = type(e).__name__
logger.error(f"Deluge 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:
self._ensure_connected()
status = self._client.call(
'core.get_torrent_status',
download_id,
['save_path', 'name'],
)
if status:
save_path = _decode(status.get(b'save_path', b''))
name = _decode(status.get(b'name', b''))
if save_path and name:
return f"{save_path}/{name}"
return None
except Exception as e:
self._connected = False
error_type = type(e).__name__
logger.debug(f"Deluge get_download_path failed ({error_type}): {e}")
return None
def find_existing(self, url: str) -> Optional[Tuple[str, DownloadStatus]]:
"""Check if a torrent for this URL already exists in Deluge."""
try:
self._ensure_connected()
torrent_info = extract_torrent_info(url)
if not torrent_info.info_hash:
return None
status = self._client.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._connected = False
logger.debug(f"Error checking for existing torrent: {e}")
return None
@@ -0,0 +1,291 @@
"""
NZBGet download client for Prowlarr integration.
Uses NZBGet's JSON-RPC API directly via requests (no external dependency).
"""
import json
from typing import Any, Optional, Tuple
import requests
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.release_sources.prowlarr.clients import (
DownloadClient,
DownloadStatus,
register_client,
)
logger = setup_logger(__name__)
@register_client("usenet")
class NZBGetClient(DownloadClient):
"""NZBGet download client using JSON-RPC API."""
protocol = "usenet"
name = "nzbget"
def __init__(self):
"""Initialize NZBGet client with settings from config."""
url = config.get("NZBGET_URL", "")
if not url:
raise ValueError("NZBGET_URL is required")
self.url = url.rstrip("/")
self.username = config.get("NZBGET_USERNAME", "nzbget")
self.password = config.get("NZBGET_PASSWORD", "")
self._category = config.get("NZBGET_CATEGORY", "Books")
@staticmethod
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", "")
return client == "nzbget" and bool(url)
def _rpc_call(self, method: str, params: list = None) -> Any:
"""
Make a JSON-RPC call to NZBGet.
Args:
method: RPC method name
params: Method parameters
Returns:
Result from NZBGet.
Raises:
Exception: If RPC call fails.
"""
rpc_url = f"{self.url}/jsonrpc"
payload = json.dumps({
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params or [],
}, separators=(',', ':'))
response = requests.post(
rpc_url,
data=payload,
headers={"Content-Type": "application/json"},
auth=(self.username, self.password),
timeout=30,
)
response.raise_for_status()
result = response.json()
if "error" in result and result["error"]:
raise Exception(result["error"].get("message", "RPC error"))
return result.get("result")
def test_connection(self) -> Tuple[bool, str]:
"""Test connection to NZBGet."""
try:
status = self._rpc_call("status")
version = status.get("Version", "unknown")
return True, f"Connected to NZBGet {version}"
except requests.exceptions.ConnectionError:
return False, "Could not connect to NZBGet"
except requests.exceptions.Timeout:
return False, "Connection timed out"
except Exception as e:
return False, f"Connection failed: {str(e)}"
def add_download(self, url: str, name: str, category: str = None) -> str:
"""
Add NZB by URL.
Fetches the NZB content from the URL (e.g., Prowlarr proxy) and sends
it base64-encoded to NZBGet, since NZBGet may not handle redirects well.
Args:
url: NZB URL (can be Prowlarr proxy URL)
name: Display name for the download
category: Category for organization (uses configured default if not specified)
Returns:
NZBGet download ID (NZBID).
Raises:
Exception: If adding fails.
"""
import base64
# Use configured category if not explicitly provided
category = category or self._category
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.raise_for_status()
nzb_content = base64.b64encode(response.content).decode('ascii')
# Ensure filename has .nzb extension
nzb_filename = name if name.endswith('.nzb') else f"{name}.nzb"
# NZBGet append method parameters (all 10 required):
# NZBFilename, Content, Category, Priority, AddToTop, AddPaused,
# DupeKey, DupeScore, DupeMode, PPParameters
nzb_id = self._rpc_call(
"append",
[
nzb_filename, # NZBFilename
nzb_content, # Content (base64-encoded NZB)
category, # Category
0, # Priority (0 = normal)
False, # AddToTop
False, # AddPaused
"", # DupeKey
0, # DupeScore
"SCORE", # DupeMode
[], # PPParameters (empty array)
],
)
if nzb_id and nzb_id > 0:
logger.info(f"Added NZB to NZBGet: {nzb_id}")
return str(nzb_id)
raise Exception("NZBGet returned invalid ID")
except requests.RequestException as e:
logger.error(f"Failed to fetch NZB from URL: {e}")
raise Exception(f"Failed to fetch NZB: {e}")
except Exception as e:
logger.error(f"NZBGet add failed: {e}")
raise
def get_status(self, download_id: str) -> DownloadStatus:
"""
Get NZB status by ID.
Args:
download_id: NZBGet NZBID
Returns:
Current download status.
"""
try:
nzb_id = int(download_id)
# Check active downloads (queue)
groups = self._rpc_call("listgroups", [0])
for group in groups:
if group.get("NZBID") == nzb_id:
# Calculate progress
# NZBGet uses Hi/Lo for 64-bit values on 32-bit systems
file_size = (group.get("FileSizeHi", 0) << 32) + group.get(
"FileSizeLo", 0
)
remaining = (group.get("RemainingSizeHi", 0) << 32) + group.get(
"RemainingSizeLo", 0
)
progress = (
((file_size - remaining) / file_size * 100)
if file_size > 0
else 0
)
status = group.get("Status", "")
# Map NZBGet status to our states
if "DOWNLOADING" in status:
state = "downloading"
elif "PAUSED" in status:
state = "paused"
elif "QUEUED" in status:
state = "queued"
elif "POST-PROCESSING" in status or "UNPACKING" in status:
state = "processing"
else:
state = "unknown"
return DownloadStatus(
progress=progress,
state=state,
message=status.replace("-", " ").title(),
complete=False,
file_path=None,
download_speed=group.get("DownloadRate"),
eta=(
group.get("RemainingSec")
if group.get("RemainingSec", 0) > 0
else None
),
)
# Check history for completed downloads
history = self._rpc_call("history", [False])
for item in history:
if item.get("NZBID") == nzb_id:
status = item.get("Status", "")
dest_dir = item.get("DestDir", "")
if "SUCCESS" in status:
return DownloadStatus(
progress=100,
state="complete",
message="Complete",
complete=True,
file_path=dest_dir,
)
else:
return DownloadStatus(
progress=100,
state="error",
message=f"Download failed: {status}",
complete=True,
file_path=None,
)
# 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}")
def remove(self, download_id: str, delete_files: bool = False) -> bool:
"""
Remove a download from NZBGet.
Args:
download_id: NZBGet NZBID
delete_files: Whether to permanently delete (vs move to history)
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}")
return False
def get_download_path(self, download_id: str) -> Optional[str]:
"""
Get the path where NZB files are located.
Args:
download_id: NZBGet NZBID
Returns:
Destination directory, or None.
"""
status = self.get_status(download_id)
return status.file_path
@@ -0,0 +1,308 @@
"""qBittorrent download client for Prowlarr integration."""
import time
from types import SimpleNamespace
from typing import List, Optional, Tuple
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.release_sources.prowlarr.clients import (
DownloadClient,
DownloadStatus,
register_client,
)
from shelfmark.release_sources.prowlarr.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
@register_client("torrent")
class QBittorrentClient(DownloadClient):
"""qBittorrent download client."""
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
url = config.get("QBITTORRENT_URL", "")
if not url:
raise ValueError("QBITTORRENT_URL is required")
self._base_url = url.rstrip("/")
self._client = Client(
host=url,
username=config.get("QBITTORRENT_USERNAME", ""),
password=config.get("QBITTORRENT_PASSWORD", ""),
)
self._category = config.get("QBITTORRENT_CATEGORY", "cwabd")
def _get_torrents_info(self, torrent_hash: Optional[str] = None) -> List:
"""Get torrent info using GET (per API spec for read operations)."""
import requests
try:
# Ensure session is authenticated before using it directly
self._client.auth_log_in()
params = {"hashes": torrent_hash} if torrent_hash else {}
response = self._client._session.get(
f"{self._base_url}/api/v2/torrents/info",
params=params,
timeout=10,
)
response.raise_for_status()
torrents = response.json()
return [SimpleNamespace(**t) for t in torrents]
except requests.exceptions.HTTPError as e:
if e.response is not None and e.response.status_code == 403:
logger.warning("qBittorrent auth failed - check credentials")
else:
logger.warning(f"qBittorrent API error: {e}")
return []
except requests.exceptions.ConnectionError:
logger.warning(f"Cannot connect to qBittorrent at {self._base_url}")
return []
except Exception as e:
logger.debug(f"Failed to get torrents info: {e}")
return []
@staticmethod
def is_configured() -> bool:
"""Check if qBittorrent is configured and selected as the torrent client."""
client = config.get("PROWLARR_TORRENT_CLIENT", "")
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) -> 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)
Returns:
Torrent hash (info_hash).
Raises:
Exception: If adding fails.
"""
try:
# Use configured category if not explicitly provided
category = category or self._category
# Ensure category exists (may already exist, which is fine)
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 = torrent_info.info_hash
torrent_data = torrent_info.torrent_data
# Add the torrent - use file content if we have it, otherwise URL
if torrent_data:
result = self._client.torrents_add(
torrent_files=torrent_data,
category=category,
rename=name,
)
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,
category=category,
rename=name,
)
logger.debug(f"qBittorrent add result: {result}")
if result == "Ok.":
if not expected_hash:
raise Exception("Could not determine torrent hash from URL")
# Wait for torrent to appear in client
for _ in range(10):
torrents = self._get_torrents_info(expected_hash)
for t in torrents:
if _hashes_match(t.hash, expected_hash):
logger.info(f"Added torrent: {t.hash}")
return t.hash.lower()
time.sleep(0.5)
# Client said Ok, trust it
logger.warning(f"Torrent not yet visible, returning expected hash")
return expected_hash
raise Exception(f"Failed to add torrent: {result}")
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 = self._get_torrents_info(download_id)
torrent = next((t for t in torrents if _hashes_match(t.hash, download_id)), None)
if not torrent:
return DownloadStatus.error("Torrent not found")
# 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"),
}
state, message = state_info.get(torrent.state, ("unknown", torrent.state))
complete = torrent.progress >= 1.0
# For active downloads without a special message, leave message as None
# so the handler can build the progress message
if complete:
message = "Complete"
eta = torrent.eta if 0 < torrent.eta < 604800 else None
# Get file path for completed downloads
file_path = None
if complete:
if getattr(torrent, 'content_path', ''):
file_path = torrent.content_path
else:
# Fallback for Amarr which doesn't populate content_path
save_path = getattr(torrent, 'save_path', '')
name = getattr(torrent, 'name', '')
if save_path and name:
file_path = f"{save_path}/{name}"
return DownloadStatus(
progress=torrent.progress * 100,
state="complete" if complete else state,
message=message,
complete=complete,
file_path=file_path,
download_speed=torrent.dlspeed,
eta=eta,
)
except Exception as e:
error_type = type(e).__name__
logger.error(f"qBittorrent 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 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:
error_type = type(e).__name__
logger.error(f"qBittorrent 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."""
try:
torrents = self._get_torrents_info(download_id)
torrent = next((t for t in torrents if _hashes_match(t.hash, download_id)), None)
if not torrent:
return None
# Prefer content_path, fall back to save_path/name (for Amarr compatibility)
if getattr(torrent, 'content_path', ''):
return torrent.content_path
save_path = getattr(torrent, 'save_path', '')
name = getattr(torrent, 'name', '')
return f"{save_path}/{name}" if save_path and name else None
except Exception as e:
error_type = type(e).__name__
logger.debug(f"qBittorrent get_download_path failed ({error_type}): {e}")
return None
def find_existing(self, url: str) -> 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 = self._get_torrents_info(torrent_info.info_hash)
torrent = next((t for t in torrents if _hashes_match(t.hash, torrent_info.info_hash)), None)
if torrent:
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
@@ -0,0 +1,394 @@
"""
SABnzbd download client for Prowlarr integration.
Uses SABnzbd's REST API directly via requests (no external dependency).
"""
from typing import Any, Optional, Tuple
import requests
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.release_sources.prowlarr.clients import (
DownloadClient,
DownloadStatus,
register_client,
)
logger = setup_logger(__name__)
def _parse_eta(eta_str: str) -> Optional[int]:
"""Parse SABnzbd ETA string (format: 'H:MM:SS') to seconds."""
if not eta_str or eta_str == "0:00:00":
return None
try:
parts = eta_str.split(":")
if len(parts) == 3:
return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
except (ValueError, IndexError):
pass
return None
def _parse_speed(slot: dict) -> Optional[int]:
"""Parse download speed from SABnzbd slot data, returning bytes/sec."""
# Prefer kbpersec field (more reliable numeric value)
kbpersec_str = slot.get("kbpersec", "")
if kbpersec_str:
try:
return int(float(kbpersec_str) * 1024)
except (ValueError, TypeError):
pass
# Fall back to human-readable speed field
speed_str = slot.get("speed", "")
if not speed_str:
return None
try:
speed_parts = speed_str.split()
if len(speed_parts) < 2:
return None
speed_val = float(speed_parts[0])
unit = speed_parts[1].upper()
multipliers = {"K": 1024, "M": 1024**2, "G": 1024**3}
for prefix, mult in multipliers.items():
if prefix in unit:
return int(speed_val * mult)
return int(speed_val)
except (ValueError, IndexError):
return None
@register_client("usenet")
class SABnzbdClient(DownloadClient):
"""SABnzbd download client using REST API."""
protocol = "usenet"
name = "sabnzbd"
def __init__(self):
"""Initialize SABnzbd client with settings from config."""
url = config.get("SABNZBD_URL", "")
if not 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.api_key = api_key
self._category = config.get("SABNZBD_CATEGORY", "cwabd")
@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", "")
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:
"""
Make an API call to SABnzbd.
Args:
mode: API mode (e.g., "version", "addurl", "queue", "history")
params: Additional parameters
Returns:
JSON response from SABnzbd.
Raises:
Exception: If API call fails.
"""
api_url = f"{self.url}/api"
request_params = {
"apikey": self.api_key,
"mode": mode,
"output": "json",
}
if params:
request_params.update(params)
response = requests.get(api_url, params=request_params, timeout=30)
response.raise_for_status()
result = response.json()
# Check for error in response
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 test_connection(self) -> Tuple[bool, str]:
"""Test connection to SABnzbd."""
try:
result = self._api_call("version")
version = result.get("version", "unknown")
return True, f"Connected to SABnzbd {version}"
except requests.exceptions.ConnectionError:
return False, "Could not connect to SABnzbd"
except requests.exceptions.Timeout:
return False, "Connection timed out"
except Exception as e:
return False, f"Connection failed: {str(e)}"
def add_download(self, url: str, name: str, category: str = None) -> str:
"""
Add NZB by URL.
Args:
url: NZB URL (can be Prowlarr proxy URL)
name: Display name for the download
category: Category for organization (uses configured default if not specified)
Returns:
SABnzbd nzo_id.
Raises:
Exception: If adding fails.
"""
# Use configured category if not explicitly provided
category = category or self._category
try:
logger.debug(f"Adding NZB to SABnzbd: {name}")
result = self._api_call(
"addurl",
{
"name": url,
"nzbname": name,
"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")
except Exception as e:
logger.error(f"SABnzbd add failed: {e}")
raise
def get_status(self, download_id: str) -> DownloadStatus:
"""
Get NZB status by nzo_id.
Args:
download_id: SABnzbd nzo_id
Returns:
Current download status.
"""
try:
# Check active queue first
queue_result = self._api_call("queue")
queue = queue_result.get("queue", {})
slots = queue.get("slots", [])
for slot in slots:
if slot.get("nzo_id") == download_id:
# Found in queue
status_text = slot.get("status", "").upper()
percentage = float(slot.get("percentage", 0))
# Map SABnzbd status to our states
status_mapping = {
"DOWNLOADING": "downloading",
"PAUSED": "paused",
"QUEUED": "queued",
"IDLE": "queued",
"PROPAGATING": "queued",
"FETCHING": "queued",
"GRABBING": "queued",
"VERIFYING": "processing",
"REPAIRING": "processing",
"EXTRACTING": "processing",
"MOVING": "processing",
"RUNNING": "processing",
"FAILED": "error",
}
state = status_mapping.get(status_text, "downloading")
return DownloadStatus(
progress=percentage,
state=state,
message=status_text.lower().replace("_", " ").title(),
complete=False,
file_path=None,
download_speed=_parse_speed(slot),
eta=_parse_eta(slot.get("timeleft", "")),
)
# Not in queue, check history
history_result = self._api_call("history", {"limit": 100})
history = history_result.get("history", {})
history_slots = history.get("slots", [])
for slot in history_slots:
if slot.get("nzo_id") == download_id:
status_text = slot.get("status", "").upper()
storage = slot.get("storage", "")
if status_text == "COMPLETED":
return DownloadStatus(
progress=100,
state="complete",
message="Complete",
complete=True,
file_path=storage,
)
else:
# Failed or other status
fail_message = slot.get("fail_message", status_text)
return DownloadStatus(
progress=100,
state="error",
message=f"Download failed: {fail_message}",
complete=True,
file_path=None,
)
# Not found
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}")
def remove(self, download_id: str, delete_files: bool = False) -> bool:
"""
Remove a download from SABnzbd.
Args:
download_id: SABnzbd nzo_id
delete_files: Whether to delete the files
Returns:
True if successful.
"""
try:
# First try to remove from queue
result = self._api_call(
"queue",
{
"name": "delete",
"value": download_id,
"del_files": 1 if delete_files else 0,
},
)
if result.get("status"):
logger.info(f"Removed NZB from SABnzbd queue: {download_id}")
return True
# If not in queue, try to remove from history
result = self._api_call(
"history",
{
"name": "delete",
"value": download_id,
"del_files": 1 if delete_files else 0,
},
)
if result.get("status"):
logger.info(f"Removed NZB 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}")
return False
def get_download_path(self, download_id: str) -> Optional[str]:
"""
Get the path where NZB files are located.
Args:
download_id: SABnzbd nzo_id
Returns:
Storage directory, or None.
"""
status = self.get_status(download_id)
return status.file_path
def find_existing(self, url: str) -> Optional[Tuple[str, DownloadStatus]]:
"""
Check if an NZB for this URL already exists in SABnzbd.
Note: Unlike torrents which have a unique info_hash, usenet NZBs don't have
a universal unique identifier. SABnzbd generates an nzo_id when adding,
but there's no way to derive it from the URL. This method searches by
NZB name extracted from the URL, which may not always be accurate.
Args:
url: NZB URL
Returns:
Tuple of (nzo_id, status) if found, None if not found.
"""
try:
# Extract NZB name from URL (last path component without extension)
from urllib.parse import unquote, urlparse
parsed = urlparse(url)
path = unquote(parsed.path)
# Get filename from path
if "/" in path:
filename = path.rsplit("/", 1)[-1]
else:
filename = path
# Remove common NZB extensions
for ext in [".nzb", ".nzb.gz"]:
if filename.lower().endswith(ext):
filename = filename[:-len(ext)]
break
if not filename:
return None
# Search queue
queue_result = self._api_call("queue")
queue = queue_result.get("queue", {})
for slot in queue.get("slots", []):
slot_name = slot.get("filename", "")
if filename.lower() in slot_name.lower():
nzo_id = slot.get("nzo_id")
if nzo_id:
status = self.get_status(nzo_id)
logger.debug(f"Found existing NZB in SABnzbd queue: {nzo_id}")
return (nzo_id, status)
# Search history
history_result = self._api_call("history", {"limit": 100})
history = history_result.get("history", {})
for slot in history.get("slots", []):
slot_name = slot.get("name", "")
if filename.lower() in slot_name.lower():
nzo_id = slot.get("nzo_id")
if nzo_id:
status = self.get_status(nzo_id)
logger.debug(f"Found existing NZB in SABnzbd history: {nzo_id}")
return (nzo_id, status)
return None
except Exception as e:
logger.debug(f"Error checking for existing NZB: {e}")
return None
@@ -0,0 +1,218 @@
"""Shared utilities for torrent clients."""
import base64
import hashlib
import re
from dataclasses import dataclass
from typing import Optional, Tuple
from urllib.parse import parse_qs, urlparse
import requests
from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
@dataclass
class TorrentInfo:
"""Parsed information from a torrent URL."""
info_hash: Optional[str]
"""Lowercase hex info_hash (32 or 40 chars), or None if extraction failed."""
torrent_data: Optional[bytes]
"""Raw .torrent file content, only populated for .torrent URLs."""
is_magnet: bool
"""True if the URL was a magnet link."""
magnet_url: Optional[str] = None
"""The actual magnet URL, if available."""
def extract_torrent_info(url: str, fetch_torrent: bool = True) -> TorrentInfo:
"""Extract info_hash from magnet link or .torrent URL."""
is_magnet = url.startswith("magnet:")
# Try to extract hash from magnet URL
if is_magnet:
info_hash = extract_hash_from_magnet(url)
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)
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)
# 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", "")
if redirect_url.startswith("magnet:"):
logger.debug(f"Download URL redirected to magnet link")
info_hash = extract_hash_from_magnet(redirect_url)
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.raise_for_status()
torrent_data = resp.content
# Check if response is actually a magnet link (text response)
# Some indexers return magnet links as plain text instead of redirecting
if len(torrent_data) < 2000: # Magnet links are typically short
try:
text_content = torrent_data.decode("utf-8", errors="ignore").strip()
if text_content.startswith("magnet:"):
logger.debug("Download URL returned magnet link as response body")
info_hash = extract_hash_from_magnet(text_content)
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)
if info_hash:
logger.debug(f"Extracted hash from torrent file: {info_hash}")
else:
logger.warning("Could not extract hash from torrent file")
return TorrentInfo(info_hash=info_hash, torrent_data=torrent_data, is_magnet=False)
except Exception as e:
logger.debug(f"Could not fetch torrent file: {e}")
return TorrentInfo(info_hash=None, torrent_data=None, is_magnet=False)
def parse_transmission_url(url: str) -> Tuple[str, int, str]:
"""Parse Transmission URL into (host, port, path)."""
parsed = urlparse(url)
host = parsed.hostname or "localhost"
port = parsed.port or 9091
path = parsed.path or "/transmission/rpc"
# Ensure path ends with /rpc
if not path.endswith("/rpc"):
path = path.rstrip("/") + "/transmission/rpc"
return host, port, path
def bencode_decode(data: bytes) -> tuple:
"""Decode bencoded data. Returns (value, remaining_bytes)."""
if data[0:1] == b'd':
# Dictionary
result = {}
data = data[1:]
while data[0:1] != b'e':
key, data = bencode_decode(data)
value, data = bencode_decode(data)
result[key] = value
return result, data[1:]
elif data[0:1] == b'l':
# List
result = []
data = data[1:]
while data[0:1] != b'e':
value, data = bencode_decode(data)
result.append(value)
return result, data[1:]
elif data[0:1] == b'i':
# Integer
end = data.index(b'e')
return int(data[1:end]), data[end + 1:]
elif data[0:1].isdigit():
# Byte string
colon = data.index(b':')
length = int(data[:colon])
start = colon + 1
return data[start:start + length], data[start + length:]
else:
first_byte = data[0:1]
raise ValueError(
f"Invalid bencode data: expected 'd', 'l', 'i', or digit, "
f"got {first_byte!r}. First 20 bytes: {data[:20]!r}"
)
def bencode_encode(data) -> bytes:
"""Encode data to bencode format."""
if isinstance(data, dict):
# Keys must be sorted (bencode spec requirement)
result = b'd'
for key in sorted(data.keys()):
result += bencode_encode(key)
result += bencode_encode(data[key])
result += b'e'
return result
elif isinstance(data, list):
result = b'l'
for item in data:
result += bencode_encode(item)
result += b'e'
return result
elif isinstance(data, int):
return f'i{data}e'.encode()
elif isinstance(data, bytes):
return f'{len(data)}:'.encode() + data
elif isinstance(data, str):
encoded = data.encode('utf-8')
return f'{len(encoded)}:'.encode() + encoded
else:
raise ValueError(
f"Cannot bencode type {type(data).__name__}: "
f"expected dict, list, int, bytes, or str. Value: {data!r}"
)
def extract_info_hash_from_torrent(torrent_data: bytes) -> Optional[str]:
"""Extract info_hash from .torrent file data."""
try:
decoded, _ = bencode_decode(torrent_data)
if b'info' not in decoded:
return None
info_bencoded = bencode_encode(decoded[b'info'])
return hashlib.sha1(info_bencoded).hexdigest().lower()
except Exception as e:
logger.debug(f"Failed to parse torrent file: {e}")
return None
def extract_hash_from_magnet(magnet_url: str) -> Optional[str]:
"""Extract info_hash from a magnet URL."""
if not magnet_url.startswith("magnet:"):
return None
parsed = urlparse(magnet_url)
params = parse_qs(parsed.query)
for xt in params.get("xt", []):
# 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:
hash_value = match.group(1)
# 40-char hex or 32-char hex (ED2K) - return as-is
if len(hash_value) == 40 or re.match(r'^[a-fA-F0-9]{32}$', hash_value):
return hash_value.lower()
# 32-char base32 - decode to hex
if re.match(r'^[A-Z2-7]{32}$', hash_value.upper()):
try:
return base64.b32decode(hash_value.upper()).hex().lower()
except Exception:
pass
# Fallback: return as-is
return hash_value.lower()
return None
@@ -0,0 +1,248 @@
"""
Transmission download client for Prowlarr integration.
Uses the transmission-rpc library to communicate with Transmission's RPC API.
"""
from typing import Optional, Tuple
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.release_sources.prowlarr.clients import (
DownloadClient,
DownloadStatus,
register_client,
)
from shelfmark.release_sources.prowlarr.clients.torrent_utils import (
extract_torrent_info,
parse_transmission_url,
)
logger = setup_logger(__name__)
@register_client("torrent")
class TransmissionClient(DownloadClient):
"""Transmission download client using transmission-rpc library."""
protocol = "torrent"
name = "transmission"
def __init__(self):
"""Initialize Transmission client with settings from config."""
from transmission_rpc import Client
url = config.get("TRANSMISSION_URL", "")
if not url:
raise ValueError("TRANSMISSION_URL is required")
username = config.get("TRANSMISSION_USERNAME", "")
password = config.get("TRANSMISSION_PASSWORD", "")
# Parse URL to extract host, port, and path
host, port, path = parse_transmission_url(url)
self._client = Client(
host=host,
port=port,
path=path,
username=username if username else None,
password=password if password else None,
)
self._category = config.get("TRANSMISSION_CATEGORY", "cwabd")
@staticmethod
def is_configured() -> bool:
"""Check if Transmission is configured and selected as the torrent client."""
client = config.get("PROWLARR_TORRENT_CLIENT", "")
url = config.get("TRANSMISSION_URL", "")
return client == "transmission" and bool(url)
def test_connection(self) -> Tuple[bool, str]:
"""Test connection to Transmission."""
try:
session = self._client.get_session()
version = session.version
return True, f"Connected to Transmission {version}"
except Exception as e:
return False, f"Connection failed: {str(e)}"
def add_download(self, url: str, name: str, category: str = None) -> 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)
Returns:
Torrent hash (info_hash).
Raises:
Exception: If adding fails.
"""
try:
category = category or self._category
torrent_info = extract_torrent_info(url)
if torrent_info.torrent_data:
torrent = self._client.add_torrent(
torrent=torrent_info.torrent_data,
labels=[category],
)
else:
# Use magnet URL if available, otherwise original URL
add_url = torrent_info.magnet_url or url
torrent = self._client.add_torrent(
torrent=add_url,
labels=[category],
)
torrent_hash = torrent.hashString.lower()
logger.info(f"Added torrent to Transmission: {torrent_hash}")
return torrent_hash
except Exception as e:
logger.error(f"Transmission 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:
torrent = self._client.get_torrent(download_id)
# Transmission status values:
# 0: stopped
# 1: check pending
# 2: checking
# 3: download pending
# 4: downloading
# 5: seed pending
# 6: seeding
# torrent.status is an enum with .value as string
status_value = torrent.status.value if hasattr(torrent.status, 'value') else str(torrent.status)
status_map = {
"stopped": ("paused", "Paused"),
"check pending": ("checking", "Waiting to check"),
"checking": ("checking", "Checking files"),
"download pending": ("queued", "Waiting to download"),
"downloading": ("downloading", "Downloading"),
"seed pending": ("processing", "Moving files"),
"seeding": ("seeding", "Seeding"),
}
state, message = status_map.get(status_value, ("downloading", "Downloading"))
progress = torrent.percent_done * 100
# Only mark complete when seeding - seed pending means files still being moved
complete = progress >= 100 and status_value == "seeding"
if complete:
message = "Complete"
# Get ETA if available and reasonable (less than 1 week)
eta = None
if hasattr(torrent, 'eta') and torrent.eta:
eta_seconds = torrent.eta.total_seconds()
if 0 < eta_seconds < 604800:
eta = int(eta_seconds)
# Get download speed
download_speed = torrent.rate_download if hasattr(torrent, 'rate_download') else None
# Get file path for completed downloads
file_path = None
if complete:
download_dir = torrent.download_dir
name = torrent.name
file_path = f"{download_dir}/{name}"
return DownloadStatus(
progress=progress,
state="complete" if complete else state,
message=message,
complete=complete,
file_path=file_path,
download_speed=download_speed,
eta=eta,
)
except KeyError:
return DownloadStatus.error("Torrent not found")
except Exception as e:
error_type = type(e).__name__
logger.error(f"Transmission 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 Transmission.
Args:
download_id: Torrent info_hash
delete_files: Whether to also delete files
Returns:
True if successful.
"""
try:
self._client.remove_torrent(
download_id,
delete_data=delete_files,
)
logger.info(
f"Removed torrent from Transmission: {download_id}"
+ (" (with files)" if delete_files else "")
)
return True
except Exception as e:
error_type = type(e).__name__
logger.error(f"Transmission 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:
torrent = self._client.get_torrent(download_id)
download_dir = torrent.download_dir
name = torrent.name
return f"{download_dir}/{name}"
except Exception as e:
error_type = type(e).__name__
logger.debug(f"Transmission get_download_path failed ({error_type}): {e}")
return None
def find_existing(self, url: str) -> Optional[Tuple[str, DownloadStatus]]:
"""Check if a torrent for this URL already exists in Transmission."""
try:
torrent_info = extract_torrent_info(url)
if not torrent_info.info_hash:
return None
try:
self._client.get_torrent(torrent_info.info_hash)
status = self.get_status(torrent_info.info_hash)
return (torrent_info.info_hash, status)
except KeyError:
return None
except Exception as e:
logger.debug(f"Error checking for existing torrent: {e}")
return None
@@ -0,0 +1,296 @@
"""Prowlarr download handler - executes downloads via torrent/usenet clients."""
import shutil
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.release_sources import DownloadHandler, register_handler
from shelfmark.release_sources.prowlarr.cache import get_release, remove_release
from shelfmark.release_sources.prowlarr.clients import (
DownloadState,
get_client,
list_configured_clients,
)
from shelfmark.release_sources.prowlarr.utils import get_protocol, get_unique_path
logger = setup_logger(__name__)
# How often to poll the download client for status (seconds)
POLL_INTERVAL = 2
@register_handler("prowlarr")
class ProwlarrHandler(DownloadHandler):
"""Handler for Prowlarr downloads via configured torrent or usenet client."""
def _get_category_for_task(self, client, 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 _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:
# Look up the cached release
prowlarr_result = get_release(task.task_id)
if not prowlarr_result:
logger.warning(f"Release cache miss: {task.task_id}")
status_callback("error", "Release not found in cache (may have expired)")
return None
# Extract download URL
download_url = prowlarr_result.get("downloadUrl") or prowlarr_result.get("magnetUrl")
if not download_url:
status_callback("error", "No download URL available")
return None
# Determine protocol
protocol = get_protocol(prowlarr_result)
if protocol == "unknown":
status_callback("error", "Could not determine download protocol")
return None
# Get the appropriate download client
client = get_client(protocol)
if not client:
configured = 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 {protocol} client configured")
return None
# Check if this download already exists in the client
status_callback("resolving", f"Checking {client.name}")
existing = client.find_existing(download_url)
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(f"Existing download is complete, copying file directly")
status_callback("resolving", "Found existing download, copying to library")
source_path = client.get_download_path(download_id)
if not source_path:
status_callback("error", "Could not locate existing download file")
return None
result = self._handle_completed_file(
source_path=Path(source_path),
protocol=protocol,
task=task,
status_callback=status_callback,
)
if result:
remove_release(task.task_id)
return result
# Existing but still downloading - join the progress polling
logger.info(f"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:
release_name = prowlarr_result.get("title") or task.title or "Unknown"
category = self._get_category_for_task(client, task)
download_id = client.add_download(
url=download_url,
name=release_name,
category=category,
)
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 '{release_name}'")
# Poll for progress
return self._poll_and_complete(
client=client,
download_id=download_id,
protocol=protocol,
task=task,
cancel_flag=cancel_flag,
progress_callback=progress_callback,
status_callback=status_callback,
)
except Exception as e:
logger.error(f"Prowlarr download error: {e}")
status_callback("error", str(e))
return None
def _poll_and_complete(
self,
client,
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."""
try:
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:
status_callback("error", status.message or "Download failed")
return None
# Download complete - break to handle file
break
# Check for error state
if status.state == DownloadState.ERROR:
status_callback("error", status.message or "Download failed")
client.remove(download_id, delete_files=True)
return None
# Build status message - use client message if provided, else build progress
msg = status.message or self._build_progress_message(status)
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():
logger.info(f"Download cancelled, removing from {client.name}: {download_id}")
client.remove(download_id, delete_files=True)
status_callback("cancelled", "Cancelled")
return None
# Handle completed file
source_path = client.get_download_path(download_id)
if not source_path:
status_callback("error", "Could not locate downloaded file")
return None
result = self._handle_completed_file(
source_path=Path(source_path),
protocol=protocol,
task=task,
status_callback=status_callback,
)
# Clean up cache on success
if result:
remove_release(task.task_id)
return result
except Exception as e:
logger.error(f"Error during download polling: {e}")
status_callback("error", str(e))
try:
client.remove(download_id, delete_files=True)
except Exception as cleanup_error:
logger.error(f"Failed to cleanup download {download_id} after error: {cleanup_error}")
return None
def _handle_completed_file(
self,
source_path: Path,
protocol: str,
task: DownloadTask,
status_callback: Callable[[str, Optional[str]], None],
) -> Optional[str]:
"""Handle completed download. Torrents return original path; usenet stages to temp."""
try:
# For torrents, skip staging - return original path directly
# Orchestrator will hardlink (library mode) or copy (ingest mode) as needed
if protocol == "torrent":
task.original_download_path = str(source_path)
logger.debug(f"Torrent complete, returning original path: {source_path}")
return str(source_path)
# Usenet: stage based on config
status_callback("resolving", "Staging file")
use_copy = config.get("PROWLARR_USENET_ACTION", "move") == "copy"
from shelfmark.download.orchestrator import get_staging_dir
staging_dir = get_staging_dir()
if source_path.is_dir():
staged_path = get_unique_path(staging_dir, source_path.name)
if use_copy:
shutil.copytree(str(source_path), str(staged_path))
else:
shutil.move(str(source_path), str(staged_path))
logger.debug(f"Staged directory: {staged_path.name}")
else:
staged_path = get_unique_path(staging_dir, source_path.stem, source_path.suffix)
if use_copy:
shutil.copy2(str(source_path), str(staged_path))
else:
shutil.move(str(source_path), str(staged_path))
logger.debug(f"Staged: {staged_path.name}")
return str(staged_path)
except PermissionError as e:
logger.error(f"Permission denied staging file: {e}")
status_callback("error", f"Permission denied: {e}")
return None
except Exception as e:
logger.error(f"Staging failed: {e}")
status_callback("error", f"Failed to stage file: {e}")
return None
def cancel(self, task_id: str) -> bool:
"""Cancel download and clean up cache. Primary cancellation is via cancel_flag."""
logger.debug(f"Cancel requested for Prowlarr task: {task_id}")
# Remove from cache if present
remove_release(task_id)
return True

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