Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f154b6994e | ||
|
|
823ceeef4a | ||
|
|
8ed6b94dfb | ||
|
|
2b5983d201 | ||
|
|
a4173eafcb | ||
|
|
15a61a5191 | ||
|
|
0cac541c0b | ||
|
|
85c8c9151d | ||
|
|
4472fbe8cf | ||
|
|
b293bee5f4 | ||
|
|
122a3633c2 | ||
|
|
0e2580030b | ||
|
|
17057ecfbe | ||
|
|
2b831dcfa5 | ||
|
|
78c61e88b3 | ||
|
|
57d85d0748 | ||
|
|
6492bd6a3c | ||
|
|
ed88aac5d5 | ||
|
|
5751910426 | ||
|
|
b02ad7452c | ||
|
|
289666aeef | ||
|
|
cc30d24144 | ||
|
|
50e53a13b0 | ||
|
|
a46d302ba8 | ||
|
|
c5d22e0f91 | ||
|
|
03321a5435 | ||
|
|
6aed906dfe | ||
|
|
742da1c43a | ||
|
|
8ea2fee0bb | ||
|
|
1c24312eb0 | ||
|
|
98e3a2f114 | ||
|
|
cd16f09f2e | ||
|
|
527c5d495d | ||
|
|
f5de2ab143 | ||
|
|
4e5c9b788f | ||
|
|
199d8453eb | ||
|
|
a9854b1a5c | ||
|
|
e4d3a372c8 | ||
|
|
ff44881415 | ||
|
|
9ffedc1fc0 | ||
|
|
00370818f0 | ||
|
|
7d9a82bfea | ||
|
|
207cff96d3 | ||
|
|
c8f21b8f8d |
@@ -37,3 +37,13 @@ dist/
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
|
||||
# Frontend build artifacts (built in separate stage)
|
||||
src/frontend/node_modules/
|
||||
src/frontend/dist/
|
||||
src/frontend/.vite/
|
||||
|
||||
# Old frontend code (replaced by src/frontend)
|
||||
templates/
|
||||
static/css/
|
||||
static/js/
|
||||
|
||||
@@ -26,6 +26,9 @@ jobs:
|
||||
- suffix: "-tor"
|
||||
target: cwa-bd-tor
|
||||
image_name_suffix: "-tor"
|
||||
- suffix: "-extbp"
|
||||
target: cwa-bd-extbp
|
||||
image_name_suffix: "-extbp"
|
||||
steps:
|
||||
- name: Get current date
|
||||
id: date
|
||||
@@ -67,6 +70,7 @@ jobs:
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ steps.date.outputs.date }}-${{ github.sha }}
|
||||
RELEASE_VERSION=${{ github.ref_name }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
|
||||
@@ -227,3 +227,4 @@ pyrightconfig.json
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/macos,visualstudiocode,python
|
||||
/downloaded_files
|
||||
/.local/
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"LOG_ROOT": "/tmp/cwa-book-downloader",
|
||||
"ENABLE_LOGGING": "true",
|
||||
"DOCKERMODE": "false",
|
||||
"DEBUG": "true"
|
||||
"DEBUG": "true",
|
||||
"CUSTOM_DNS": "google",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,9 +1,36 @@
|
||||
ARG TARGETPLATFORM
|
||||
ARG TARGETARCH
|
||||
ARG BUILDPLATFORM
|
||||
ARG BUILDARCH
|
||||
|
||||
# Frontend build stage.
|
||||
FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend-builder
|
||||
|
||||
# Helpful debug output to see what platforms BuildKit thinks it's using
|
||||
RUN echo "BUILDPLATFORM=$BUILDPLATFORM BUILDARCH=$BUILDARCH TARGETPLATFORM=$TARGETPLATFORM TARGETARCH=$TARGETARCH"
|
||||
|
||||
WORKDIR /frontend
|
||||
|
||||
# Copy frontend package files
|
||||
COPY src/frontend/package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy frontend source
|
||||
COPY src/frontend/ ./
|
||||
|
||||
# Build the frontend
|
||||
RUN npm run build
|
||||
|
||||
# Use python-slim as the base image
|
||||
FROM python:3.10-slim AS base
|
||||
|
||||
# Add build argument for version
|
||||
ARG BUILD_VERSION
|
||||
ENV BUILD_VERSION=${BUILD_VERSION}
|
||||
ARG RELEASE_VERSION
|
||||
ENV RELEASE_VERSION=${RELEASE_VERSION}
|
||||
|
||||
# Set shell to bash with pipefail option
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
@@ -22,8 +49,7 @@ ENV DEBIAN_FRONTEND=noninteractive \
|
||||
# UID/GID 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 \
|
||||
APP_ENV=prod
|
||||
LC_ALL=en_US.UTF-8
|
||||
|
||||
# Set ARG for build-time expansion (FLASK_PORT), ENV for runtime access
|
||||
ENV FLASK_PORT=8084
|
||||
@@ -38,18 +64,10 @@ RUN apt-get update && \
|
||||
curl \
|
||||
# For entrypoint
|
||||
dumb-init \
|
||||
# For dumb display
|
||||
xvfb \
|
||||
# For screen recording
|
||||
ffmpeg \
|
||||
# For debug
|
||||
zip iputils-ping \
|
||||
# For user switching
|
||||
sudo \
|
||||
# --- Chromium Browser ---
|
||||
chromium-driver \
|
||||
# For tkinter (pyautogui)
|
||||
python3-tk && \
|
||||
sudo && \
|
||||
# Cleanup APT cache *after* all installs in this layer
|
||||
apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false && \
|
||||
apt-get clean && \
|
||||
@@ -67,20 +85,18 @@ WORKDIR /app
|
||||
|
||||
# Install Python dependencies using pip
|
||||
# Upgrade pip first, then copy requirements and install
|
||||
# Copying requirements.txt separately leverages build cache
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt && \
|
||||
# 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
|
||||
|
||||
# Add this line to grant read/execute permissions to others
|
||||
RUN chmod -R o+rx /usr/bin/chromium && \
|
||||
chmod -R o+rx /usr/bin/chromedriver && \
|
||||
chmod -R o+w /usr/local/lib/python3.10/site-packages/seleniumbase/drivers/
|
||||
|
||||
# Copy application code *after* dependencies are installed
|
||||
COPY . .
|
||||
|
||||
# Copy built frontend from frontend-builder stage
|
||||
COPY --from=frontend-builder /frontend/dist /app/frontend-dist
|
||||
|
||||
# Final setup: permissions and directories in one layer
|
||||
# Only creating directories and setting executable bits.
|
||||
# Ownership will be handled by the entrypoint script.
|
||||
@@ -91,9 +107,9 @@ RUN mkdir -p /var/log/cwa-book-downloader /cwa-book-ingest && \
|
||||
EXPOSE ${FLASK_PORT}
|
||||
|
||||
# Add healthcheck for container status
|
||||
# This will run as root initially, but check localhost which should work if the app binds correctly.
|
||||
# Uses /api/health which doesn't require authentication
|
||||
HEALTHCHECK --interval=60s --timeout=60s --start-period=60s --retries=3 \
|
||||
CMD curl -s http://localhost:${FLASK_PORT}/request/api/status > /dev/null || exit 1
|
||||
CMD curl -s http://localhost:${FLASK_PORT}/api/health > /dev/null || exit 1
|
||||
|
||||
# Use dumb-init as the entrypoint to handle signals properly
|
||||
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
|
||||
@@ -101,10 +117,38 @@ ENTRYPOINT ["/usr/bin/dumb-init", "--"]
|
||||
|
||||
FROM base AS cwa-bd
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
# For dumb display
|
||||
xvfb \
|
||||
# For screen recording
|
||||
ffmpeg \
|
||||
# --- Chromium ---
|
||||
chromium \
|
||||
# --- ChromeDriver ---
|
||||
chromium-driver \
|
||||
# For tkinter (pyautogui)
|
||||
python3-tk \
|
||||
# For RAR extraction
|
||||
unrar-free && \
|
||||
# Create symlink so rarfile library can find unrar
|
||||
ln -sf /usr/bin/unrar-free /usr/bin/unrar
|
||||
|
||||
# install additional dependencies
|
||||
COPY requirements-cwa-bd.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements-cwa-bd.txt && \
|
||||
# Clean root's pip cache
|
||||
rm -rf /root/.cache
|
||||
|
||||
# Add this line to grant read/execute permissions to others
|
||||
RUN chmod -R o+rx /usr/bin/chromium && \
|
||||
chmod -R o+rx /usr/bin/chromedriver && \
|
||||
chmod -R o+w /usr/local/lib/python3.10/site-packages/seleniumbase/drivers/
|
||||
|
||||
# Default command to run the application entrypoint script
|
||||
CMD ["/app/entrypoint.sh"]
|
||||
|
||||
FROM base AS cwa-bd-tor
|
||||
FROM cwa-bd AS cwa-bd-tor
|
||||
|
||||
ENV USING_TOR=true
|
||||
|
||||
@@ -113,6 +157,8 @@ 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 && \
|
||||
@@ -124,3 +170,9 @@ RUN apt-get update && \
|
||||
|
||||
# Override the default command to run Tor
|
||||
CMD ["/app/entrypoint.sh"]
|
||||
|
||||
FROM base AS cwa-bd-extbp
|
||||
|
||||
ENV USING_EXTERNAL_BYPASSER=true
|
||||
|
||||
CMD ["/app/entrypoint.sh"]
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
.PHONY: help install dev build preview typecheck clean up down docker-build refresh
|
||||
|
||||
# Frontend directory
|
||||
FRONTEND_DIR := src/frontend
|
||||
|
||||
# Docker compose file
|
||||
COMPOSE_FILE := docker-compose.dev.yml
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo ""
|
||||
@echo "Frontend:"
|
||||
@echo " install - Install frontend dependencies"
|
||||
@echo " dev - Start development server"
|
||||
@echo " build - Build frontend for production"
|
||||
@echo " preview - Preview production build"
|
||||
@echo " typecheck - Run TypeScript type checking"
|
||||
@echo " clean - Remove node_modules and build artifacts"
|
||||
@echo ""
|
||||
@echo "Backend (Docker):"
|
||||
@echo " up - Start backend services"
|
||||
@echo " down - Stop backend services"
|
||||
@echo " docker-build - Build Docker image"
|
||||
@echo " refresh - Rebuild and restart backend services"
|
||||
|
||||
# Install dependencies
|
||||
install:
|
||||
@echo "Installing frontend dependencies..."
|
||||
cd $(FRONTEND_DIR) && npm install
|
||||
|
||||
# Start development server
|
||||
dev:
|
||||
@echo "Starting development server..."
|
||||
cd $(FRONTEND_DIR) && npm run dev
|
||||
|
||||
# Build for production
|
||||
build:
|
||||
@echo "Building frontend for production..."
|
||||
cd $(FRONTEND_DIR) && npm run build
|
||||
|
||||
# Preview production build
|
||||
preview:
|
||||
@echo "Previewing production build..."
|
||||
cd $(FRONTEND_DIR) && npm run preview
|
||||
|
||||
# Type checking
|
||||
typecheck:
|
||||
@echo "Running TypeScript type checking..."
|
||||
cd $(FRONTEND_DIR) && npm run typecheck
|
||||
|
||||
# Clean build artifacts and dependencies
|
||||
clean:
|
||||
@echo "Cleaning build artifacts and dependencies..."
|
||||
rm -rf $(FRONTEND_DIR)/node_modules
|
||||
rm -rf $(FRONTEND_DIR)/dist
|
||||
|
||||
# Start backend services
|
||||
up:
|
||||
@echo "Starting backend services..."
|
||||
docker compose -f $(COMPOSE_FILE) up -d
|
||||
|
||||
# Stop backend services
|
||||
down:
|
||||
@echo "Stopping backend services..."
|
||||
docker compose -f $(COMPOSE_FILE) down
|
||||
|
||||
# Build Docker image
|
||||
docker-build:
|
||||
@echo "Building Docker image..."
|
||||
docker compose -f $(COMPOSE_FILE) build
|
||||
|
||||
# Rebuild and restart backend services
|
||||
refresh:
|
||||
@echo "Rebuilding and restarting backend services..."
|
||||
docker compose -f $(COMPOSE_FILE) down
|
||||
docker compose -f $(COMPOSE_FILE) build
|
||||
docker compose -f $(COMPOSE_FILE) up -d
|
||||
@@ -0,0 +1,240 @@
|
||||
# 📚 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**
|
||||

|
||||
|
||||
**Search results**
|
||||

|
||||
|
||||
**Multi-source downloads**
|
||||

|
||||
|
||||
**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.
|
||||
|
Before Width: | Height: | Size: 874 KiB |
|
Before Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 504 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 764 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 419 KiB |
|
Before Width: | Height: | Size: 244 KiB |
@@ -1,529 +0,0 @@
|
||||
"""Flask web application for book download service with URL rewrite support."""
|
||||
|
||||
import logging
|
||||
import io, re, os
|
||||
import sqlite3
|
||||
from functools import wraps
|
||||
from flask import Flask, request, jsonify, render_template, send_file, send_from_directory
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from werkzeug.security import check_password_hash
|
||||
from werkzeug.wrappers import Response
|
||||
from flask import url_for as flask_url_for
|
||||
import typing
|
||||
|
||||
from logger import setup_logger
|
||||
from config import _SUPPORTED_BOOK_LANGUAGE, BOOK_LANGUAGE
|
||||
from env import FLASK_HOST, FLASK_PORT, APP_ENV, CWA_DB_PATH, DEBUG
|
||||
import backend
|
||||
|
||||
from models import SearchFilters
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
app = Flask(__name__)
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app) # type: ignore
|
||||
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 # Disable caching
|
||||
app.config['APPLICATION_ROOT'] = '/'
|
||||
|
||||
# Flask logger
|
||||
app.logger.handlers = logger.handlers
|
||||
app.logger.setLevel(logger.level)
|
||||
# Also handle Werkzeug's logger
|
||||
werkzeug_logger = logging.getLogger('werkzeug')
|
||||
werkzeug_logger.handlers = logger.handlers
|
||||
werkzeug_logger.setLevel(logger.level)
|
||||
|
||||
# Set up authentication defaults
|
||||
# The secret key will reset every time we restart, which will
|
||||
# require users to authenticate again
|
||||
app.config.update(
|
||||
SECRET_KEY = os.urandom(64)
|
||||
)
|
||||
|
||||
def login_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# If the CWA_DB_PATH variable exists, but isn't a valid
|
||||
# path, return a server error
|
||||
if CWA_DB_PATH is not None and not os.path.isfile(CWA_DB_PATH):
|
||||
logger.error(f"CWA_DB_PATH is set to {CWA_DB_PATH} but this is not a valid path")
|
||||
return Response("Internal Server Error", 500)
|
||||
if not authenticate():
|
||||
return Response(
|
||||
response="Unauthorized",
|
||||
status=401,
|
||||
headers={
|
||||
"WWW-Authenticate": 'Basic realm="Calibre-Web-Automated-Book-Downloader"',
|
||||
},
|
||||
)
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
def register_dual_routes(app : Flask) -> None:
|
||||
"""
|
||||
Register each route both with and without the /request prefix.
|
||||
This function should be called after all routes are defined.
|
||||
"""
|
||||
# Store original url_map rules
|
||||
rules = list(app.url_map.iter_rules())
|
||||
|
||||
# Add /request prefix to each rule
|
||||
for rule in rules:
|
||||
if rule.rule != '/request/' and rule.rule != '/request': # Skip if it's already a request route
|
||||
# Create new routes with /request prefix, both with and without trailing slash
|
||||
base_rule = rule.rule[:-1] if rule.rule.endswith('/') else rule.rule
|
||||
if base_rule == '': # Special case for root path
|
||||
app.add_url_rule('/request', f"root_request",
|
||||
view_func=app.view_functions[rule.endpoint],
|
||||
methods=rule.methods)
|
||||
app.add_url_rule('/request/', f"root_request_slash",
|
||||
view_func=app.view_functions[rule.endpoint],
|
||||
methods=rule.methods)
|
||||
else:
|
||||
app.add_url_rule(f"/request{base_rule}",
|
||||
f"{rule.endpoint}_request",
|
||||
view_func=app.view_functions[rule.endpoint],
|
||||
methods=rule.methods)
|
||||
app.add_url_rule(f"/request{base_rule}/",
|
||||
f"{rule.endpoint}_request_slash",
|
||||
view_func=app.view_functions[rule.endpoint],
|
||||
methods=rule.methods)
|
||||
app.jinja_env.globals['url_for'] = url_for_with_request
|
||||
|
||||
def url_for_with_request(endpoint : str, **values : typing.Any) -> str:
|
||||
"""Generate URLs with /request prefix by default."""
|
||||
if endpoint == 'static':
|
||||
# For static files, add /request prefix
|
||||
url = flask_url_for(endpoint, **values)
|
||||
return f"/request{url}"
|
||||
return flask_url_for(endpoint, **values)
|
||||
|
||||
@app.route('/')
|
||||
@login_required
|
||||
def index() -> str:
|
||||
"""
|
||||
Render main page with search and status table.
|
||||
"""
|
||||
return render_template('index.html', book_languages=_SUPPORTED_BOOK_LANGUAGE, default_language=BOOK_LANGUAGE, debug=DEBUG)
|
||||
|
||||
@app.route('/favico<path:_>')
|
||||
@app.route('/request/favico<path:_>')
|
||||
@app.route('/request/static/favico<path:_>')
|
||||
def favicon(_ : typing.Any) -> Response:
|
||||
return send_from_directory(os.path.join(app.root_path, 'static', 'media'),
|
||||
'favicon.ico', mimetype='image/vnd.microsoft.icon')
|
||||
|
||||
from typing import Union, Tuple
|
||||
|
||||
if DEBUG:
|
||||
import subprocess
|
||||
import time
|
||||
from cloudflare_bypasser import _reset_driver as STOP_GUI
|
||||
@app.route('/debug', methods=['GET'])
|
||||
@login_required
|
||||
def debug() -> Union[Response, Tuple[Response, int]]:
|
||||
"""
|
||||
This will run the /app/debug.sh script, which will generate a debug zip with all the logs
|
||||
The file will be named /tmp/cwa-book-downloader-debug.zip
|
||||
And then return it to the user
|
||||
"""
|
||||
try:
|
||||
# Run the debug script
|
||||
STOP_GUI()
|
||||
time.sleep(1)
|
||||
result = subprocess.run(['/app/genDebug.sh'], capture_output=True, text=True, check=True)
|
||||
if result.returncode != 0:
|
||||
raise Exception(f"Debug script failed: {result.stderr}")
|
||||
logger.info(f"Debug script executed: {result.stdout}")
|
||||
debug_file_path = result.stdout.strip().split('\n')[-1]
|
||||
if not os.path.exists(debug_file_path):
|
||||
logger.error("Debug zip file not found after running debug script")
|
||||
return jsonify({"error": "Failed to generate debug information"}), 500
|
||||
|
||||
# Return the file to the user
|
||||
return send_file(
|
||||
debug_file_path,
|
||||
mimetype='application/zip',
|
||||
download_name=os.path.basename(debug_file_path),
|
||||
as_attachment=True
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error_trace(f"Debug script error: {e}, stdout: {e.stdout}, stderr: {e.stderr}")
|
||||
return jsonify({"error": f"Debug script failed: {e.stderr}"}), 500
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Debug endpoint error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
if DEBUG:
|
||||
@app.route('/api/restart', methods=['GET'])
|
||||
@login_required
|
||||
def restart() -> Union[Response, Tuple[Response, int]]:
|
||||
"""
|
||||
Restart the application
|
||||
"""
|
||||
os._exit(0)
|
||||
|
||||
@app.route('/api/search', methods=['GET'])
|
||||
@login_required
|
||||
def api_search() -> Union[Response, Tuple[Response, int]]:
|
||||
"""
|
||||
Search for books matching the provided query.
|
||||
|
||||
Query Parameters:
|
||||
query (str): Search term (ISBN, title, author, etc.)
|
||||
isbn (str): Book ISBN
|
||||
author (str): Book Author
|
||||
title (str): Book Title
|
||||
lang (str): Book Language
|
||||
sort (str): Order to sort results
|
||||
content (str): Content type of book
|
||||
format (str): File format filter (pdf, epub, mobi, azw3, fb2, djvu, cbz, cbr)
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON array of matching books or error response.
|
||||
"""
|
||||
query = request.args.get('query', '')
|
||||
|
||||
filters = SearchFilters(
|
||||
isbn = request.args.getlist('isbn'),
|
||||
author = request.args.getlist('author'),
|
||||
title = request.args.getlist('title'),
|
||||
lang = request.args.getlist('lang'),
|
||||
sort = request.args.get('sort'),
|
||||
content = request.args.getlist('content'),
|
||||
format = request.args.getlist('format'),
|
||||
)
|
||||
|
||||
if not query and not any(vars(filters).values()):
|
||||
return jsonify([])
|
||||
|
||||
try:
|
||||
books = backend.search_books(query, filters)
|
||||
return jsonify(books)
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Search error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/info', methods=['GET'])
|
||||
@login_required
|
||||
def api_info() -> Union[Response, Tuple[Response, int]]:
|
||||
"""
|
||||
Get detailed book information.
|
||||
|
||||
Query Parameters:
|
||||
id (str): Book identifier (MD5 hash)
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON object with book details, or an error message.
|
||||
"""
|
||||
book_id = request.args.get('id', '')
|
||||
if not book_id:
|
||||
return jsonify({"error": "No book ID provided"}), 400
|
||||
|
||||
try:
|
||||
book = backend.get_book_info(book_id)
|
||||
if book:
|
||||
return jsonify(book)
|
||||
return jsonify({"error": "Book not found"}), 404
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Info error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/download', methods=['GET'])
|
||||
@login_required
|
||||
def api_download() -> Union[Response, Tuple[Response, int]]:
|
||||
"""
|
||||
Queue a book for download.
|
||||
|
||||
Query Parameters:
|
||||
id (str): Book identifier (MD5 hash)
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON status object indicating success or failure.
|
||||
"""
|
||||
book_id = request.args.get('id', '')
|
||||
if not book_id:
|
||||
return jsonify({"error": "No book ID provided"}), 400
|
||||
|
||||
try:
|
||||
priority = int(request.args.get('priority', 0))
|
||||
success = backend.queue_book(book_id, priority)
|
||||
if success:
|
||||
return jsonify({"status": "queued", "priority": priority})
|
||||
return jsonify({"error": "Failed to queue book"}), 500
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Download error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/status', methods=['GET'])
|
||||
@login_required
|
||||
def api_status() -> Union[Response, Tuple[Response, int]]:
|
||||
"""
|
||||
Get current download queue status.
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON object with queue status.
|
||||
"""
|
||||
try:
|
||||
status = backend.queue_status()
|
||||
return jsonify(status)
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Status error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/localdownload', methods=['GET'])
|
||||
@login_required
|
||||
def api_local_download() -> Union[Response, Tuple[Response, int]]:
|
||||
"""
|
||||
Download an EPUB file from local storage if available.
|
||||
|
||||
Query Parameters:
|
||||
id (str): Book identifier (MD5 hash)
|
||||
|
||||
Returns:
|
||||
flask.Response: The EPUB file if found, otherwise an error response.
|
||||
"""
|
||||
book_id = request.args.get('id', '')
|
||||
if not book_id:
|
||||
return jsonify({"error": "No book ID provided"}), 400
|
||||
|
||||
try:
|
||||
file_data, book_info = backend.get_book_data(book_id)
|
||||
if file_data is None:
|
||||
# Book data not found or not available
|
||||
return jsonify({"error": "File not found"}), 404
|
||||
# Santize the file name
|
||||
file_name = book_info.title
|
||||
file_name = re.sub(r'[\\/:*?"<>|]', '_', file_name.strip())[:245]
|
||||
file_extension = book_info.format
|
||||
# Prepare the file for sending to the client
|
||||
data = io.BytesIO(file_data)
|
||||
return send_file(
|
||||
data,
|
||||
download_name=f"{file_name}.{file_extension}",
|
||||
as_attachment=True
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Local download error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/download/<book_id>/cancel', methods=['DELETE'])
|
||||
@login_required
|
||||
def api_cancel_download(book_id: str) -> Union[Response, Tuple[Response, int]]:
|
||||
"""
|
||||
Cancel a download.
|
||||
|
||||
Path Parameters:
|
||||
book_id (str): Book identifier to cancel
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON status indicating success or failure.
|
||||
"""
|
||||
try:
|
||||
success = backend.cancel_download(book_id)
|
||||
if success:
|
||||
return jsonify({"status": "cancelled", "book_id": book_id})
|
||||
return jsonify({"error": "Failed to cancel download or book not found"}), 404
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Cancel download error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/queue/<book_id>/priority', methods=['PUT'])
|
||||
@login_required
|
||||
def api_set_priority(book_id: str) -> Union[Response, Tuple[Response, int]]:
|
||||
"""
|
||||
Set priority for a queued book.
|
||||
|
||||
Path Parameters:
|
||||
book_id (str): Book identifier
|
||||
|
||||
Request Body:
|
||||
priority (int): New priority level (lower number = higher priority)
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON status indicating success or failure.
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data or 'priority' not in data:
|
||||
return jsonify({"error": "Priority not provided"}), 400
|
||||
|
||||
priority = int(data['priority'])
|
||||
success = backend.set_book_priority(book_id, priority)
|
||||
|
||||
if success:
|
||||
return jsonify({"status": "updated", "book_id": book_id, "priority": priority})
|
||||
return jsonify({"error": "Failed to update priority or book not found"}), 404
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid priority value"}), 400
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Set priority error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/queue/reorder', methods=['POST'])
|
||||
@login_required
|
||||
def api_reorder_queue() -> Union[Response, Tuple[Response, int]]:
|
||||
"""
|
||||
Bulk reorder queue by setting new priorities.
|
||||
|
||||
Request Body:
|
||||
book_priorities (dict): Mapping of book_id to new priority
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON status indicating success or failure.
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data or 'book_priorities' not in data:
|
||||
return jsonify({"error": "book_priorities not provided"}), 400
|
||||
|
||||
book_priorities = data['book_priorities']
|
||||
if not isinstance(book_priorities, dict):
|
||||
return jsonify({"error": "book_priorities must be a dictionary"}), 400
|
||||
|
||||
# Validate all priorities are integers
|
||||
for book_id, priority in book_priorities.items():
|
||||
if not isinstance(priority, int):
|
||||
return jsonify({"error": f"Invalid priority for book {book_id}"}), 400
|
||||
|
||||
success = backend.reorder_queue(book_priorities)
|
||||
|
||||
if success:
|
||||
return jsonify({"status": "reordered", "updated_count": len(book_priorities)})
|
||||
return jsonify({"error": "Failed to reorder queue"}), 500
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Reorder queue error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/queue/order', methods=['GET'])
|
||||
@login_required
|
||||
def api_queue_order() -> Union[Response, Tuple[Response, int]]:
|
||||
"""
|
||||
Get current queue order for display.
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON array of queued books with their order and priorities.
|
||||
"""
|
||||
try:
|
||||
queue_order = backend.get_queue_order()
|
||||
return jsonify({"queue": queue_order})
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Queue order error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/downloads/active', methods=['GET'])
|
||||
@login_required
|
||||
def api_active_downloads() -> Union[Response, Tuple[Response, int]]:
|
||||
"""
|
||||
Get list of currently active downloads.
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON array of active download book IDs.
|
||||
"""
|
||||
try:
|
||||
active_downloads = backend.get_active_downloads()
|
||||
return jsonify({"active_downloads": active_downloads})
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Active downloads error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/queue/clear', methods=['DELETE'])
|
||||
@login_required
|
||||
def api_clear_completed() -> Union[Response, Tuple[Response, int]]:
|
||||
"""
|
||||
Clear all completed, errored, or cancelled books from tracking.
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON with count of removed books.
|
||||
"""
|
||||
try:
|
||||
removed_count = backend.clear_completed()
|
||||
return jsonify({"status": "cleared", "removed_count": removed_count})
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Clear completed error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found_error(error: Exception) -> Union[Response, Tuple[Response, int]]:
|
||||
"""
|
||||
Handle 404 (Not Found) errors.
|
||||
|
||||
Args:
|
||||
error (HTTPException): The 404 error raised by Flask.
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON error message with 404 status.
|
||||
"""
|
||||
logger.warning(f"404 error: {request.url} : {error}")
|
||||
return jsonify({"error": "Resource not found"}), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(error: Exception) -> Union[Response, Tuple[Response, int]]:
|
||||
"""
|
||||
Handle 500 (Internal Server) errors.
|
||||
|
||||
Args:
|
||||
error (HTTPException): The 500 error raised by Flask.
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON error message with 500 status.
|
||||
"""
|
||||
logger.error_trace(f"500 error: {error}")
|
||||
return jsonify({"error": "Internal server error"}), 500
|
||||
|
||||
def authenticate() -> bool:
|
||||
"""
|
||||
Helper function that validates Basic credentials
|
||||
against a Calibre-Web app.db SQLite database
|
||||
|
||||
Database structure:
|
||||
- Table 'user' with columns: 'name' (username), 'password'
|
||||
"""
|
||||
|
||||
# If the database doesn't exist, the user is always authenticated
|
||||
if not CWA_DB_PATH:
|
||||
return True
|
||||
|
||||
# If no authorization object exists, return false to prompt
|
||||
# a request to the user
|
||||
if not request.authorization:
|
||||
return False
|
||||
|
||||
username = request.authorization.get("username")
|
||||
password = request.authorization.get("password")
|
||||
|
||||
# Validate credentials against database
|
||||
try:
|
||||
# Open database in true read-only mode to avoid journal/WAL writes on RO mounts
|
||||
db_path = os.fspath(CWA_DB_PATH)
|
||||
db_uri = f"file:{db_path}?mode=ro&immutable=1"
|
||||
conn = sqlite3.connect(db_uri, uri=True)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT password FROM user WHERE name = ?", (username,))
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
|
||||
# Check if user exists and password is correct
|
||||
if not row or not row[0] or not check_password_hash(row[0], password):
|
||||
logger.error("User not found or password check failed")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error_trace(f"CWA DB or authentication send_from_directory: {e}")
|
||||
return False
|
||||
|
||||
logger.info(f"Authentication successful for user {username}")
|
||||
return True
|
||||
|
||||
# Register all routes with /request prefix
|
||||
register_dual_routes(app)
|
||||
|
||||
logger.log_resource_usage()
|
||||
|
||||
if __name__ == '__main__':
|
||||
logger.info(f"Starting Flask application on {FLASK_HOST}:{FLASK_PORT} IN {APP_ENV} mode")
|
||||
app.run(
|
||||
host=FLASK_HOST,
|
||||
port=FLASK_PORT,
|
||||
debug=DEBUG
|
||||
)
|
||||
@@ -1,338 +0,0 @@
|
||||
"""Backend logic for the book download application."""
|
||||
|
||||
import threading, time
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any, Tuple
|
||||
import subprocess
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor, Future
|
||||
from threading import Event
|
||||
|
||||
from logger import setup_logger
|
||||
from config import CUSTOM_SCRIPT
|
||||
from env import INGEST_DIR, TMP_DIR, MAIN_LOOP_SLEEP_TIME, USE_BOOK_TITLE, MAX_CONCURRENT_DOWNLOADS, DOWNLOAD_PROGRESS_UPDATE_INTERVAL
|
||||
from models import book_queue, BookInfo, QueueStatus, SearchFilters
|
||||
import book_manager
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
def _sanitize_filename(filename: str) -> str:
|
||||
"""Sanitize a filename by replacing spaces with underscores and removing invalid characters."""
|
||||
keepcharacters = (' ','.','_')
|
||||
return "".join(c for c in filename if c.isalnum() or c in keepcharacters).rstrip()
|
||||
|
||||
def search_books(query: str, filters: SearchFilters) -> List[Dict[str, Any]]:
|
||||
"""Search for books matching the query.
|
||||
|
||||
Args:
|
||||
query: Search term
|
||||
filters: Search filters object
|
||||
|
||||
Returns:
|
||||
List[Dict]: List of book information dictionaries
|
||||
"""
|
||||
try:
|
||||
books = book_manager.search_books(query, filters)
|
||||
return [_book_info_to_dict(book) for book in books]
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Error searching books: {e}")
|
||||
return []
|
||||
|
||||
def get_book_info(book_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get detailed information for a specific book.
|
||||
|
||||
Args:
|
||||
book_id: Book identifier
|
||||
|
||||
Returns:
|
||||
Optional[Dict]: Book information dictionary if found
|
||||
"""
|
||||
try:
|
||||
book = book_manager.get_book_info(book_id)
|
||||
return _book_info_to_dict(book)
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Error getting book info: {e}")
|
||||
return None
|
||||
|
||||
def queue_book(book_id: str, priority: int = 0) -> bool:
|
||||
"""Add a book to the download queue with specified priority.
|
||||
|
||||
Args:
|
||||
book_id: Book identifier
|
||||
priority: Priority level (lower number = higher priority)
|
||||
|
||||
Returns:
|
||||
bool: True if book was successfully queued
|
||||
"""
|
||||
try:
|
||||
book_info = book_manager.get_book_info(book_id)
|
||||
book_queue.add(book_id, book_info, priority)
|
||||
logger.info(f"Book queued with priority {priority}: {book_info.title}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Error queueing book: {e}")
|
||||
return False
|
||||
|
||||
def queue_status() -> Dict[str, Dict[str, Any]]:
|
||||
"""Get current status of the download queue.
|
||||
|
||||
Returns:
|
||||
Dict: Queue status organized by status type
|
||||
"""
|
||||
status = book_queue.get_status()
|
||||
# Convert Enum keys to strings and properly format the response
|
||||
return {
|
||||
status_type.value: books
|
||||
for status_type, books in status.items()
|
||||
}
|
||||
|
||||
def get_book_data(book_id: str) -> Tuple[Optional[bytes], BookInfo]:
|
||||
"""Get book data for a specific book, including its title.
|
||||
|
||||
Args:
|
||||
book_id: Book identifier
|
||||
|
||||
Returns:
|
||||
Tuple[Optional[bytes], str]: Book data if available, and the book title
|
||||
"""
|
||||
try:
|
||||
book_info = book_queue._book_data[book_id]
|
||||
path = book_info.download_path
|
||||
with open(path, "rb") as f:
|
||||
return f.read(), book_info
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Error getting book data: {e}")
|
||||
if book_info:
|
||||
book_info.download_path = None
|
||||
return None, book_info if book_info else BookInfo(id=book_id, title="Unknown")
|
||||
|
||||
def _book_info_to_dict(book: BookInfo) -> Dict[str, Any]:
|
||||
"""Convert BookInfo object to dictionary representation."""
|
||||
return {
|
||||
key: value for key, value in book.__dict__.items()
|
||||
if value is not None
|
||||
}
|
||||
|
||||
def _download_book_with_cancellation(book_id: str, cancel_flag: Event) -> Optional[str]:
|
||||
"""Download and process a book with cancellation support.
|
||||
|
||||
Args:
|
||||
book_id: Book identifier
|
||||
cancel_flag: Threading event to signal cancellation
|
||||
|
||||
Returns:
|
||||
str: Path to the downloaded book if successful, None otherwise
|
||||
"""
|
||||
try:
|
||||
# Check for cancellation before starting
|
||||
if cancel_flag.is_set():
|
||||
logger.info(f"Download cancelled before starting: {book_id}")
|
||||
return None
|
||||
|
||||
book_info = book_queue._book_data[book_id]
|
||||
logger.info(f"Starting download: {book_info.title}")
|
||||
|
||||
if USE_BOOK_TITLE:
|
||||
book_name = _sanitize_filename(book_info.title)
|
||||
else:
|
||||
book_name = book_id
|
||||
book_name += f".{book_info.format}"
|
||||
book_path = TMP_DIR / book_name
|
||||
|
||||
# Check cancellation before download
|
||||
if cancel_flag.is_set():
|
||||
logger.info(f"Download cancelled before book manager call: {book_id}")
|
||||
return None
|
||||
|
||||
# Update progress periodically during download
|
||||
progress_thread = threading.Thread(
|
||||
target=_update_download_progress,
|
||||
args=(book_id, cancel_flag),
|
||||
daemon=True
|
||||
)
|
||||
progress_thread.start()
|
||||
|
||||
success = book_manager.download_book(book_info, book_path)
|
||||
|
||||
# Stop progress updates
|
||||
cancel_flag.wait(0.1) # Brief pause for progress thread cleanup
|
||||
|
||||
if cancel_flag.is_set():
|
||||
logger.info(f"Download cancelled during download: {book_id}")
|
||||
# Clean up partial download
|
||||
if book_path.exists():
|
||||
book_path.unlink()
|
||||
return None
|
||||
|
||||
if not success:
|
||||
raise Exception("Unknown error downloading book")
|
||||
|
||||
# Check cancellation before post-processing
|
||||
if cancel_flag.is_set():
|
||||
logger.info(f"Download cancelled before post-processing: {book_id}")
|
||||
if book_path.exists():
|
||||
book_path.unlink()
|
||||
return None
|
||||
|
||||
if CUSTOM_SCRIPT:
|
||||
logger.info(f"Running custom script: {CUSTOM_SCRIPT}")
|
||||
subprocess.run([CUSTOM_SCRIPT, book_path])
|
||||
|
||||
intermediate_path = INGEST_DIR / f"{book_id}.crdownload"
|
||||
final_path = INGEST_DIR / book_name
|
||||
|
||||
if os.path.exists(book_path):
|
||||
logger.info(f"Moving book to ingest directory: {book_path} -> {final_path}")
|
||||
try:
|
||||
shutil.move(book_path, intermediate_path)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error moving book: {e}, will try copying instead")
|
||||
shutil.copy(book_path, intermediate_path)
|
||||
os.remove(book_path)
|
||||
|
||||
# Final cancellation check before completing
|
||||
if cancel_flag.is_set():
|
||||
logger.info(f"Download cancelled before final rename: {book_id}")
|
||||
if intermediate_path.exists():
|
||||
intermediate_path.unlink()
|
||||
return None
|
||||
|
||||
os.rename(intermediate_path, final_path)
|
||||
logger.info(f"Download completed successfully: {book_info.title}")
|
||||
|
||||
return str(final_path)
|
||||
except Exception as e:
|
||||
if cancel_flag.is_set():
|
||||
logger.info(f"Download cancelled during error handling: {book_id}")
|
||||
else:
|
||||
logger.error_trace(f"Error downloading book: {e}")
|
||||
return None
|
||||
|
||||
def _update_download_progress(book_id: str, cancel_flag: Event) -> None:
|
||||
"""Update download progress periodically."""
|
||||
progress = 0.0
|
||||
while not cancel_flag.is_set() and progress < 100.0:
|
||||
# Simulate progress (in real implementation, this would get actual progress)
|
||||
progress = min(100.0, progress + 10.0)
|
||||
book_queue.update_progress(book_id, progress)
|
||||
time.sleep(DOWNLOAD_PROGRESS_UPDATE_INTERVAL)
|
||||
|
||||
def cancel_download(book_id: str) -> bool:
|
||||
"""Cancel a download.
|
||||
|
||||
Args:
|
||||
book_id: Book identifier to cancel
|
||||
|
||||
Returns:
|
||||
bool: True if cancellation was successful
|
||||
"""
|
||||
return book_queue.cancel_download(book_id)
|
||||
|
||||
def set_book_priority(book_id: str, priority: int) -> bool:
|
||||
"""Set priority for a queued book.
|
||||
|
||||
Args:
|
||||
book_id: Book identifier
|
||||
priority: New priority level (lower = higher priority)
|
||||
|
||||
Returns:
|
||||
bool: True if priority was successfully changed
|
||||
"""
|
||||
return book_queue.set_priority(book_id, priority)
|
||||
|
||||
def reorder_queue(book_priorities: Dict[str, int]) -> bool:
|
||||
"""Bulk reorder queue.
|
||||
|
||||
Args:
|
||||
book_priorities: Dict mapping book_id to new priority
|
||||
|
||||
Returns:
|
||||
bool: True if reordering was successful
|
||||
"""
|
||||
return book_queue.reorder_queue(book_priorities)
|
||||
|
||||
def get_queue_order() -> List[Dict[str, any]]:
|
||||
"""Get current queue order for display."""
|
||||
return book_queue.get_queue_order()
|
||||
|
||||
def get_active_downloads() -> List[str]:
|
||||
"""Get list of currently active downloads."""
|
||||
return book_queue.get_active_downloads()
|
||||
|
||||
def clear_completed() -> int:
|
||||
"""Clear all completed downloads from tracking."""
|
||||
return book_queue.clear_completed()
|
||||
|
||||
def _process_single_download(book_id: str, cancel_flag: Event) -> None:
|
||||
"""Process a single download job."""
|
||||
try:
|
||||
book_queue.update_status(book_id, QueueStatus.DOWNLOADING)
|
||||
download_path = _download_book_with_cancellation(book_id, cancel_flag)
|
||||
|
||||
if cancel_flag.is_set():
|
||||
book_queue.update_status(book_id, QueueStatus.CANCELLED)
|
||||
return
|
||||
|
||||
if download_path:
|
||||
book_queue.update_download_path(book_id, download_path)
|
||||
new_status = QueueStatus.AVAILABLE
|
||||
else:
|
||||
new_status = QueueStatus.ERROR
|
||||
|
||||
book_queue.update_status(book_id, new_status)
|
||||
|
||||
logger.info(
|
||||
f"Book {book_id} download {'successful' if download_path else 'failed'}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
if not cancel_flag.is_set():
|
||||
logger.error_trace(f"Error in download processing: {e}")
|
||||
book_queue.update_status(book_id, QueueStatus.ERROR)
|
||||
else:
|
||||
logger.info(f"Download cancelled: {book_id}")
|
||||
book_queue.update_status(book_id, QueueStatus.CANCELLED)
|
||||
|
||||
def concurrent_download_loop() -> None:
|
||||
"""Main download coordinator using ThreadPoolExecutor for concurrent downloads."""
|
||||
logger.info(f"Starting concurrent download loop with {MAX_CONCURRENT_DOWNLOADS} workers")
|
||||
|
||||
with ThreadPoolExecutor(max_workers=MAX_CONCURRENT_DOWNLOADS, thread_name_prefix="BookDownload") as executor:
|
||||
active_futures: Dict[Future, str] = {} # Track active download futures
|
||||
|
||||
while True:
|
||||
# Clean up completed futures
|
||||
completed_futures = [f for f in active_futures if f.done()]
|
||||
for future in completed_futures:
|
||||
book_id = active_futures.pop(future)
|
||||
try:
|
||||
future.result() # This will raise any exceptions from the worker
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Future exception for {book_id}: {e}")
|
||||
|
||||
# Start new downloads if we have capacity
|
||||
while len(active_futures) < MAX_CONCURRENT_DOWNLOADS:
|
||||
next_download = book_queue.get_next()
|
||||
if not next_download:
|
||||
break
|
||||
|
||||
book_id, cancel_flag = next_download
|
||||
logger.info(f"Starting concurrent download: {book_id}")
|
||||
|
||||
# Submit download job to thread pool
|
||||
future = executor.submit(_process_single_download, book_id, cancel_flag)
|
||||
active_futures[future] = book_id
|
||||
|
||||
# Brief sleep to prevent busy waiting
|
||||
time.sleep(MAIN_LOOP_SLEEP_TIME)
|
||||
|
||||
# Start concurrent download coordinator
|
||||
download_coordinator_thread = threading.Thread(
|
||||
target=concurrent_download_loop,
|
||||
daemon=True,
|
||||
name="DownloadCoordinator"
|
||||
)
|
||||
download_coordinator_thread.start()
|
||||
|
||||
logger.info(f"Download system initialized with {MAX_CONCURRENT_DOWNLOADS} concurrent workers")
|
||||
@@ -1,381 +0,0 @@
|
||||
"""Book download manager handling search and retrieval operations."""
|
||||
|
||||
import time, json, re
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
from typing import List, Optional, Dict, Union
|
||||
from bs4 import BeautifulSoup, Tag, NavigableString, ResultSet
|
||||
|
||||
import downloader
|
||||
from logger import setup_logger
|
||||
from config import SUPPORTED_FORMATS, BOOK_LANGUAGE, AA_BASE_URL
|
||||
from env import AA_DONATOR_KEY, USE_CF_BYPASS, PRIORITIZE_WELIB
|
||||
from models import BookInfo, SearchFilters
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def search_books(query: str, filters: SearchFilters) -> List[BookInfo]:
|
||||
"""Search for books matching the query.
|
||||
|
||||
Args:
|
||||
query: Search term (ISBN, title, author, etc.)
|
||||
|
||||
Returns:
|
||||
List[BookInfo]: List of matching books
|
||||
|
||||
Raises:
|
||||
Exception: If no books found or parsing fails
|
||||
"""
|
||||
query_html = quote(query)
|
||||
|
||||
if filters.isbn:
|
||||
# ISBNs are included in query string
|
||||
isbns = " || ".join(
|
||||
[f"('isbn13:{isbn}' || 'isbn10:{isbn}')" for isbn in filters.isbn]
|
||||
)
|
||||
query_html = quote(f"({isbns}) {query}")
|
||||
|
||||
filters_query = ""
|
||||
|
||||
for value in filters.lang or BOOK_LANGUAGE:
|
||||
if value != "all":
|
||||
filters_query += f"&lang={quote(value)}"
|
||||
|
||||
if filters.sort:
|
||||
filters_query += f"&sort={quote(filters.sort)}"
|
||||
|
||||
if filters.content:
|
||||
for value in filters.content:
|
||||
filters_query += f"&content={quote(value)}"
|
||||
|
||||
# Handle format filter
|
||||
formats_to_use = filters.format if filters.format else SUPPORTED_FORMATS
|
||||
|
||||
index = 1
|
||||
for filter_type, filter_values in vars(filters).items():
|
||||
if filter_type == "author" or filter_type == "title" and filter_values:
|
||||
for value in filter_values:
|
||||
filters_query += (
|
||||
f"&termtype_{index}={filter_type}&termval_{index}={quote(value)}"
|
||||
)
|
||||
index += 1
|
||||
|
||||
url = (
|
||||
f"{AA_BASE_URL}"
|
||||
f"/search?index=&page=1&display=table"
|
||||
f"&acc=aa_download&acc=external_download"
|
||||
f"&ext={'&ext='.join(formats_to_use)}"
|
||||
f"&q={query_html}"
|
||||
f"{filters_query}"
|
||||
)
|
||||
|
||||
html = downloader.html_get_page(url)
|
||||
if not html:
|
||||
raise Exception("Failed to fetch search results")
|
||||
|
||||
if "No files found." in html:
|
||||
logger.info(f"No books found for query: {query}")
|
||||
raise Exception("No books found. Please try another query.")
|
||||
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
tbody: Tag | NavigableString | None = soup.find("table")
|
||||
|
||||
if not tbody:
|
||||
logger.warning(f"No results table found for query: {query}")
|
||||
raise Exception("No books found. Please try another query.")
|
||||
|
||||
books = []
|
||||
if isinstance(tbody, Tag):
|
||||
for line_tr in tbody.find_all("tr"):
|
||||
try:
|
||||
book = _parse_search_result_row(line_tr)
|
||||
if book:
|
||||
books.append(book)
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Failed to parse search result row: {e}")
|
||||
|
||||
books.sort(
|
||||
key=lambda x: (
|
||||
SUPPORTED_FORMATS.index(x.format)
|
||||
if x.format in SUPPORTED_FORMATS
|
||||
else len(SUPPORTED_FORMATS)
|
||||
)
|
||||
)
|
||||
|
||||
return books
|
||||
|
||||
|
||||
def _parse_search_result_row(row: Tag) -> Optional[BookInfo]:
|
||||
"""Parse a single search result row into a BookInfo object."""
|
||||
try:
|
||||
cells = row.find_all("td")
|
||||
preview_img = cells[0].find("img")
|
||||
preview = preview_img["src"] if preview_img else None
|
||||
|
||||
return BookInfo(
|
||||
id=row.find_all("a")[0]["href"].split("/")[-1],
|
||||
preview=preview,
|
||||
title=cells[1].find("span").next,
|
||||
author=cells[2].find("span").next,
|
||||
publisher=cells[3].find("span").next,
|
||||
year=cells[4].find("span").next,
|
||||
language=cells[7].find("span").next,
|
||||
format=cells[9].find("span").next.lower(),
|
||||
size=cells[10].find("span").next,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Error parsing search result row: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_book_info(book_id: str) -> BookInfo:
|
||||
"""Get detailed information for a specific book.
|
||||
|
||||
Args:
|
||||
book_id: Book identifier (MD5 hash)
|
||||
|
||||
Returns:
|
||||
BookInfo: Detailed book information
|
||||
"""
|
||||
url = f"{AA_BASE_URL}/md5/{book_id}"
|
||||
html = downloader.html_get_page(url)
|
||||
|
||||
if not html:
|
||||
raise Exception(f"Failed to fetch book info for ID: {book_id}")
|
||||
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
|
||||
return _parse_book_info_page(soup, book_id)
|
||||
|
||||
|
||||
def _parse_book_info_page(soup: BeautifulSoup, book_id: str) -> BookInfo:
|
||||
"""Parse the book info page HTML into a BookInfo object."""
|
||||
data = soup.select_one("body > main > div:nth-of-type(1)")
|
||||
|
||||
if not data:
|
||||
raise Exception(f"Failed to parse book info for ID: {book_id}")
|
||||
|
||||
preview: str = ""
|
||||
|
||||
node = data.select_one("div:nth-of-type(1) > img")
|
||||
if node:
|
||||
preview_value = node.get("src", "")
|
||||
if isinstance(preview_value, list):
|
||||
preview = preview_value[0]
|
||||
else:
|
||||
preview = preview_value
|
||||
|
||||
data = soup.find_all("div", {"class": "main-inner"})[0].find_next("div")
|
||||
divs = list(data.children)
|
||||
format = divs[13].text.split(" · ")[1].strip().lower()
|
||||
size = divs[13].text.split(" · ")[2].strip().lower()
|
||||
|
||||
every_url = soup.find_all("a")
|
||||
slow_urls_no_waitlist = set()
|
||||
slow_urls_with_waitlist = set()
|
||||
external_urls_libgen = set()
|
||||
external_urls_z_lib = set()
|
||||
external_urls_welib = set()
|
||||
|
||||
for url in every_url:
|
||||
try:
|
||||
if url.text.strip().lower().startswith("slow partner server"):
|
||||
if (
|
||||
url.next is not None
|
||||
and url.next.next is not None
|
||||
and "waitlist" in url.next.next.strip().lower()
|
||||
):
|
||||
internal_text = url.next.next.strip().lower()
|
||||
if "no waitlist" in internal_text:
|
||||
slow_urls_no_waitlist.add(url["href"])
|
||||
else:
|
||||
slow_urls_with_waitlist.add(url["href"])
|
||||
elif (
|
||||
url.next is not None
|
||||
and url.next.next is not None
|
||||
and "click “GET” at the top" in url.next.next.text.strip()
|
||||
):
|
||||
libgen_url = url["href"]
|
||||
# TODO : Temporary fix ? Maybe get URLs from https://open-slum.org/ ?
|
||||
libgen_url = libgen_url = re.sub(r'libgen\.(\w+)', 'libgen.gs', url["href"])
|
||||
external_urls_libgen.add(libgen_url)
|
||||
elif url.text.strip().lower().startswith("z-lib"):
|
||||
if ".onion/" not in url["href"]:
|
||||
external_urls_z_lib.add(url["href"])
|
||||
except:
|
||||
pass
|
||||
|
||||
external_urls_welib = _get_download_urls_from_welib(book_id) if USE_CF_BYPASS else set()
|
||||
|
||||
urls = []
|
||||
urls += list(external_urls_welib) if PRIORITIZE_WELIB else []
|
||||
urls += list(slow_urls_no_waitlist) if USE_CF_BYPASS else []
|
||||
urls += list(external_urls_libgen)
|
||||
urls += list(external_urls_welib) if not PRIORITIZE_WELIB else []
|
||||
urls += list(slow_urls_with_waitlist) if USE_CF_BYPASS else []
|
||||
urls += list(external_urls_z_lib)
|
||||
|
||||
for i in range(len(urls)):
|
||||
urls[i] = downloader.get_absolute_url(AA_BASE_URL, urls[i])
|
||||
|
||||
# Remove empty urls
|
||||
urls = [url for url in urls if url != ""]
|
||||
|
||||
# Extract basic information
|
||||
book_info = BookInfo(
|
||||
id=book_id,
|
||||
preview=preview,
|
||||
title=divs[7].next.strip(),
|
||||
publisher=divs[11].text.strip(),
|
||||
author=divs[9].text.strip(),
|
||||
format=format,
|
||||
size=size,
|
||||
download_urls=urls,
|
||||
)
|
||||
|
||||
# Extract additional metadata
|
||||
info = _extract_book_metadata(divs[-6])
|
||||
book_info.info = info
|
||||
|
||||
# Set language and year from metadata if available
|
||||
if info.get("Language"):
|
||||
book_info.language = info["Language"][0]
|
||||
if info.get("Year"):
|
||||
book_info.year = info["Year"][0]
|
||||
|
||||
return book_info
|
||||
|
||||
def _get_download_urls_from_welib(book_id: str) -> set[str]:
|
||||
"""Get download urls from welib.org."""
|
||||
url = f"https://welib.org/md5/{book_id}"
|
||||
logger.info(f"Getting download urls from welib.org for {book_id}. While this uses the bypasser, it will not start downloading them yet.")
|
||||
html = downloader.html_get_page(url, use_bypasser=True)
|
||||
if not html:
|
||||
return []
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
download_links = soup.find_all("a", href=True)
|
||||
download_links = [link["href"] for link in download_links]
|
||||
download_links = [link for link in download_links if "/slow_download/" in link]
|
||||
download_links = [downloader.get_absolute_url(url, link) for link in download_links]
|
||||
return set(download_links)
|
||||
|
||||
def _extract_book_metadata(
|
||||
metadata_divs
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Extract metadata from book info divs."""
|
||||
info: Dict[str, List[str]] = {}
|
||||
|
||||
# Process the first set of metadata
|
||||
sub_datas = metadata_divs.find_all("div")[0]
|
||||
sub_datas = list(sub_datas.children)
|
||||
for sub_data in sub_datas:
|
||||
if sub_data.text.strip() == "":
|
||||
continue
|
||||
sub_data = list(sub_data.children)
|
||||
key = sub_data[0].text.strip()
|
||||
value = sub_data[1].text.strip()
|
||||
if key not in info:
|
||||
info[key] = set()
|
||||
info[key].add(value)
|
||||
|
||||
# make set into list
|
||||
for key, value in info.items():
|
||||
info[key] = list(value)
|
||||
|
||||
# Filter relevant metadata
|
||||
relevant_prefixes = [
|
||||
"ISBN-",
|
||||
"ALTERNATIVE",
|
||||
"ASIN",
|
||||
"Goodreads",
|
||||
"Language",
|
||||
"Year",
|
||||
]
|
||||
return {
|
||||
k.strip(): v
|
||||
for k, v in info.items()
|
||||
if any(k.lower().startswith(prefix.lower()) for prefix in relevant_prefixes)
|
||||
and "filename" not in k.lower()
|
||||
}
|
||||
|
||||
|
||||
def download_book(book_info: BookInfo, book_path: Path) -> bool:
|
||||
"""Download a book from available sources.
|
||||
|
||||
Args:
|
||||
book_id: Book identifier (MD5 hash)
|
||||
title: Book title for logging
|
||||
|
||||
Returns:
|
||||
Optional[BytesIO]: Book content buffer if successful
|
||||
"""
|
||||
|
||||
if len(book_info.download_urls) == 0:
|
||||
book_info = get_book_info(book_info.id)
|
||||
download_links = book_info.download_urls
|
||||
|
||||
# If AA_DONATOR_KEY is set, use the fast download URL. Else try other sources.
|
||||
if AA_DONATOR_KEY != "":
|
||||
download_links.insert(
|
||||
0,
|
||||
f"{AA_BASE_URL}/dyn/api/fast_download.json?md5={book_info.id}&key={AA_DONATOR_KEY}",
|
||||
)
|
||||
|
||||
for link in download_links:
|
||||
try:
|
||||
download_url = _get_download_url(link, book_info.title)
|
||||
if download_url != "":
|
||||
logger.info(f"Downloading `{book_info.title}` from `{download_url}`")
|
||||
data = downloader.download_url(download_url, book_info.size or "")
|
||||
if not data:
|
||||
raise Exception("No data received")
|
||||
|
||||
logger.info(f"Download finished. Writing to {book_path}")
|
||||
with open(book_path, "wb") as f:
|
||||
f.write(data.getbuffer())
|
||||
logger.info(f"Writing `{book_info.title}` successfully")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Failed to download from {link}: {e}")
|
||||
continue
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _get_download_url(link: str, title: str) -> str:
|
||||
"""Extract actual download URL from various source pages."""
|
||||
|
||||
url = ""
|
||||
|
||||
if link.startswith(f"{AA_BASE_URL}/dyn/api/fast_download.json"):
|
||||
page = downloader.html_get_page(link)
|
||||
url = json.loads(page).get("download_url")
|
||||
else:
|
||||
html = downloader.html_get_page(link)
|
||||
|
||||
if html == "":
|
||||
return ""
|
||||
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
|
||||
if link.startswith("https://z-lib."):
|
||||
download_link = soup.find_all("a", href=True, class_="addDownloadedBook")
|
||||
if download_link:
|
||||
url = download_link[0]["href"]
|
||||
elif "/slow_download/" in link:
|
||||
download_links = soup.find_all("a", href=True, string="📚 Download now")
|
||||
if not download_links:
|
||||
countdown = soup.find_all("span", class_="js-partner-countdown")
|
||||
if countdown:
|
||||
sleep_time = int(countdown[0].text)
|
||||
logger.info(f"Waiting {sleep_time}s for {title}")
|
||||
time.sleep(sleep_time)
|
||||
url = _get_download_url(link, title)
|
||||
else:
|
||||
url = download_links[0]["href"]
|
||||
else:
|
||||
url = soup.find_all("a", string="GET")[0]["href"]
|
||||
|
||||
return downloader.get_absolute_url(link, url)
|
||||
@@ -1,478 +0,0 @@
|
||||
import time
|
||||
import os
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
import threading
|
||||
import env
|
||||
from env import LOG_DIR, DEBUG
|
||||
import signal
|
||||
from datetime import datetime
|
||||
import subprocess
|
||||
|
||||
# --- SeleniumBase Import ---
|
||||
from seleniumbase import Driver
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.common.exceptions import TimeoutException
|
||||
|
||||
import network
|
||||
from logger import setup_logger
|
||||
from env import MAX_RETRY, DEFAULT_SLEEP
|
||||
from config import PROXIES, CUSTOM_DNS, DOH_SERVER, VIRTUAL_SCREEN_SIZE, RECORDING_DIR
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
network.init()
|
||||
|
||||
DRIVER = None
|
||||
DISPLAY = {
|
||||
"xvfb": None,
|
||||
"ffmpeg": None,
|
||||
}
|
||||
LAST_USED = None
|
||||
LOCKED = threading.Lock()
|
||||
TENTATIVE_CURRENT_URL = None
|
||||
|
||||
def _reset_pyautogui_display_state():
|
||||
try:
|
||||
import pyautogui
|
||||
import Xlib.display
|
||||
pyautogui._pyautogui_x11._display = (
|
||||
Xlib.display.Display(os.environ['DISPLAY'])
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error resetting pyautogui display state: {e}")
|
||||
|
||||
def _is_bypassed(sb) -> bool:
|
||||
"""Enhanced bypass detection with more comprehensive checks"""
|
||||
try:
|
||||
# Get page information with error handling
|
||||
try:
|
||||
title = sb.get_title().lower()
|
||||
except:
|
||||
title = ""
|
||||
|
||||
try:
|
||||
body = sb.get_text("body").lower()
|
||||
except:
|
||||
body = ""
|
||||
|
||||
try:
|
||||
current_url = sb.get_current_url()
|
||||
except:
|
||||
current_url = ""
|
||||
|
||||
# Enhanced verification texts for newer Cloudflare versions
|
||||
verification_texts = [
|
||||
"just a moment",
|
||||
"verify you are human",
|
||||
"verifying you are human",
|
||||
"needs to review the security of your connection before proceeding",
|
||||
"checking your browser",
|
||||
"checking connection",
|
||||
"attention required",
|
||||
"access denied",
|
||||
"needs to review the security of your connection",
|
||||
"checking the site connection security",
|
||||
"enable javascript and cookies to continue",
|
||||
"ray id",
|
||||
"cloudflare",
|
||||
"please wait",
|
||||
"ddos protection",
|
||||
"security check",
|
||||
"browser check",
|
||||
"moment please",
|
||||
"hold on",
|
||||
"loading",
|
||||
"one more step",
|
||||
"challenge"
|
||||
]
|
||||
|
||||
# Check for Cloudflare indicators
|
||||
for text in verification_texts:
|
||||
if text in title or text in body:
|
||||
logger.debug(f"Cloudflare indicator found: '{text}' in page")
|
||||
return False
|
||||
|
||||
# Additional checks for specific Cloudflare patterns
|
||||
if "cf-" in body or "cloudflare" in current_url.lower():
|
||||
logger.debug("Cloudflare patterns detected in page")
|
||||
return False
|
||||
|
||||
# Check if we're still on a challenge page (common Cloudflare pattern)
|
||||
if "/cdn-cgi/" in current_url:
|
||||
logger.debug("Still on Cloudflare CDN challenge page")
|
||||
return False
|
||||
|
||||
# If page is mostly empty, it might still be loading
|
||||
if len(body.strip()) < 50:
|
||||
logger.debug("Page content too short, might still be loading")
|
||||
return False
|
||||
|
||||
logger.debug(f"Bypass check passed - Title: '{title[:100]}', Body length: {len(body)}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking bypass status: {e}")
|
||||
# If we can't check, assume we're not bypassed
|
||||
return False
|
||||
|
||||
def _bypass_method_1(sb) -> bool:
|
||||
"""Original bypass method using uc_gui_click_captcha"""
|
||||
try:
|
||||
logger.debug("Attempting bypass method 1: uc_gui_click_captcha")
|
||||
sb.uc_gui_click_captcha()
|
||||
time.sleep(3)
|
||||
return _is_bypassed(sb)
|
||||
except Exception as e:
|
||||
logger.debug(f"Method 1 failed on first try: {e}")
|
||||
try:
|
||||
time.sleep(5)
|
||||
sb.wait_for_element_visible('body', timeout=10)
|
||||
sb.uc_gui_click_captcha()
|
||||
time.sleep(3)
|
||||
return _is_bypassed(sb)
|
||||
except Exception as e2:
|
||||
logger.debug(f"Method 1 failed on second try: {e2}")
|
||||
try:
|
||||
time.sleep(DEFAULT_SLEEP)
|
||||
sb.uc_gui_click_captcha()
|
||||
time.sleep(5)
|
||||
return _is_bypassed(sb)
|
||||
except Exception as e3:
|
||||
logger.debug(f"Method 1 completely failed: {e3}")
|
||||
return False
|
||||
|
||||
def _bypass_method_2(sb) -> bool:
|
||||
"""Alternative bypass method using longer waits and manual interaction"""
|
||||
try:
|
||||
logger.debug("Attempting bypass method 2: wait and reload")
|
||||
# Wait longer for page to load completely
|
||||
time.sleep(10)
|
||||
|
||||
# Try refreshing the page
|
||||
sb.refresh()
|
||||
time.sleep(8)
|
||||
|
||||
# Check if bypass worked after refresh
|
||||
if _is_bypassed(sb):
|
||||
return True
|
||||
|
||||
# Try clicking on the page center (sometimes helps trigger bypass)
|
||||
try:
|
||||
sb.click_if_visible("body", timeout=5)
|
||||
time.sleep(5)
|
||||
except:
|
||||
pass
|
||||
|
||||
return _is_bypassed(sb)
|
||||
except Exception as e:
|
||||
logger.debug(f"Method 2 failed: {e}")
|
||||
return False
|
||||
|
||||
def _bypass_method_3(sb) -> bool:
|
||||
"""Third bypass method using user-agent rotation and stealth mode"""
|
||||
try:
|
||||
logger.debug("Attempting bypass method 3: stealth approach")
|
||||
# Wait a random amount to appear more human
|
||||
import random
|
||||
wait_time = random.uniform(8, 15)
|
||||
time.sleep(wait_time)
|
||||
|
||||
# Try to scroll the page (human-like behavior)
|
||||
try:
|
||||
sb.scroll_to_bottom()
|
||||
time.sleep(2)
|
||||
sb.scroll_to_top()
|
||||
time.sleep(3)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Check if this helped
|
||||
if _is_bypassed(sb):
|
||||
return True
|
||||
|
||||
# Try the original captcha click as last resort
|
||||
try:
|
||||
sb.uc_gui_click_captcha()
|
||||
time.sleep(5)
|
||||
except:
|
||||
pass
|
||||
|
||||
return _is_bypassed(sb)
|
||||
except Exception as e:
|
||||
logger.debug(f"Method 3 failed: {e}")
|
||||
return False
|
||||
|
||||
def _bypass(sb, max_retries: int = MAX_RETRY) -> None:
|
||||
"""Enhanced bypass function with multiple strategies"""
|
||||
try_count = 0
|
||||
methods = [_bypass_method_1, _bypass_method_2, _bypass_method_3]
|
||||
|
||||
while not _is_bypassed(sb):
|
||||
if try_count >= max_retries:
|
||||
logger.warning("Exceeded maximum retries. Bypass failed.")
|
||||
break
|
||||
|
||||
method_index = try_count % len(methods)
|
||||
method = methods[method_index]
|
||||
|
||||
logger.info(f"Bypass attempt {try_count + 1} / {max_retries} using {method.__name__}")
|
||||
|
||||
try_count += 1
|
||||
|
||||
# Progressive backoff: wait longer between retries
|
||||
wait_time = min(DEFAULT_SLEEP * (try_count - 1), 15)
|
||||
if wait_time > 0:
|
||||
logger.info(f"Waiting {wait_time}s before trying...")
|
||||
time.sleep(wait_time)
|
||||
|
||||
try:
|
||||
if method(sb):
|
||||
logger.info(f"Bypass successful using {method.__name__}")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"Exception in {method.__name__}: {e}")
|
||||
|
||||
logger.info(f"Bypass method {method.__name__} failed.")
|
||||
|
||||
def _get_chromium_args():
|
||||
|
||||
arguments = [
|
||||
# Ignore certificate and SSL errors (similar to curl's --insecure)
|
||||
"--ignore-certificate-errors",
|
||||
"--ignore-ssl-errors",
|
||||
"--allow-running-insecure-content",
|
||||
"--ignore-certificate-errors-spki-list",
|
||||
"--ignore-certificate-errors-skip-list"
|
||||
]
|
||||
|
||||
# Conditionally add verbose logging arguments
|
||||
if DEBUG:
|
||||
arguments.extend([
|
||||
"--enable-logging", # Enable Chrome browser logging
|
||||
"--v=1", # Set verbosity level for Chrome logs
|
||||
"--log-file=" + str(LOG_DIR / "chrome_browser.log")
|
||||
])
|
||||
|
||||
# Add proxy settings if configured
|
||||
if PROXIES:
|
||||
proxy_url = PROXIES.get('https') or PROXIES.get('http')
|
||||
if proxy_url:
|
||||
arguments.append(f'--proxy-server={proxy_url}')
|
||||
|
||||
# --- Add Custom DNS settings ---
|
||||
try:
|
||||
if len(CUSTOM_DNS) > 0:
|
||||
if DOH_SERVER:
|
||||
logger.info(f"Configuring DNS over HTTPS (DoH) with server: {DOH_SERVER}")
|
||||
|
||||
# TODO: This is probably broken and a halucination,
|
||||
# but it should still default to google DOH so its fine...
|
||||
arguments.extend(['--enable-features=DnsOverHttps', '--dns-over-https-mode=secure', f'--dns-over-https-servers="{DOH_SERVER}"'])
|
||||
doh_hostname = urlparse(DOH_SERVER).hostname
|
||||
if doh_hostname:
|
||||
try:
|
||||
arguments.append(f'--host-resolver-rules=MAP {doh_hostname} {socket.gethostbyname(doh_hostname)}')
|
||||
except socket.gaierror:
|
||||
logger.warning(f"Could not resolve DoH hostname: {doh_hostname}")
|
||||
elif CUSTOM_DNS:
|
||||
resolver_rules = [f"MAP * {dns_server}" for dns_server in CUSTOM_DNS]
|
||||
if resolver_rules:
|
||||
arguments.append(f'--host-resolver-rules={",".join(resolver_rules)}')
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Error configuring DNS settings: {e}")
|
||||
return arguments
|
||||
|
||||
CHROMIUM_ARGS = _get_chromium_args()
|
||||
|
||||
def _get(url, retry : int = MAX_RETRY):
|
||||
try:
|
||||
logger.info(f"SB_GET: {url}")
|
||||
sb = _get_driver()
|
||||
|
||||
# Enhanced page loading with better error handling
|
||||
logger.debug("Opening URL with SeleniumBase...")
|
||||
sb.uc_open_with_reconnect(url, DEFAULT_SLEEP)
|
||||
time.sleep(DEFAULT_SLEEP)
|
||||
|
||||
# Log current page title and URL for debugging
|
||||
try:
|
||||
current_url = sb.get_current_url()
|
||||
current_title = sb.get_title()
|
||||
logger.debug(f"Page loaded - URL: {current_url}, Title: {current_title}")
|
||||
except Exception as debug_e:
|
||||
logger.debug(f"Could not get page info: {debug_e}")
|
||||
|
||||
# Attempt bypass
|
||||
logger.debug("Starting bypass process...")
|
||||
_bypass(sb)
|
||||
|
||||
if _is_bypassed(sb):
|
||||
logger.info("Bypass successful.")
|
||||
return sb.page_source
|
||||
else:
|
||||
logger.warning("Bypass completed but page still shows Cloudflare protection")
|
||||
# Log page content for debugging (truncated)
|
||||
try:
|
||||
page_text = sb.get_text("body")[:500] + "..." if len(sb.get_text("body")) > 500 else sb.get_text("body")
|
||||
logger.debug(f"Page content: {page_text}")
|
||||
except:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
# Enhanced error logging with full stack trace
|
||||
import traceback
|
||||
error_details = f"Exception type: {type(e).__name__}, Message: {str(e)}"
|
||||
stack_trace = traceback.format_exc()
|
||||
|
||||
if retry == 0:
|
||||
logger.error(f"Failed to initialize browser after all retries: {error_details}")
|
||||
logger.debug(f"Full stack trace: {stack_trace}")
|
||||
_reset_driver()
|
||||
raise e
|
||||
|
||||
logger.warning(f"Failed to bypass Cloudflare (retry {MAX_RETRY - retry + 1}/{MAX_RETRY}): {error_details}")
|
||||
logger.debug(f"Stack trace: {stack_trace}")
|
||||
|
||||
# Reset driver on certain errors
|
||||
if "WebDriverException" in str(type(e)) or "SessionNotCreatedException" in str(type(e)):
|
||||
logger.info("Resetting driver due to WebDriver error...")
|
||||
_reset_driver()
|
||||
|
||||
return _get(url, retry - 1)
|
||||
|
||||
def get(url, retry : int = MAX_RETRY):
|
||||
global LOCKED, TENTATIVE_CURRENT_URL, LAST_USED
|
||||
with LOCKED:
|
||||
TENTATIVE_CURRENT_URL = url
|
||||
ret = _get(url, retry)
|
||||
LAST_USED = time.time()
|
||||
return ret
|
||||
|
||||
def _init_driver():
|
||||
global DRIVER
|
||||
if DRIVER:
|
||||
_reset_driver()
|
||||
driver = Driver(uc=True, headless=False, size=f"{VIRTUAL_SCREEN_SIZE[0]},{VIRTUAL_SCREEN_SIZE[1]}", chromium_arg=CHROMIUM_ARGS)
|
||||
DRIVER = driver
|
||||
time.sleep(DEFAULT_SLEEP)
|
||||
return driver
|
||||
|
||||
def _get_driver():
|
||||
global DRIVER, DISPLAY
|
||||
global LAST_USED
|
||||
logger.info("Getting driver...")
|
||||
LAST_USED = time.time()
|
||||
if env.DOCKERMODE and env.USE_CF_BYPASS and not DISPLAY["xvfb"]:
|
||||
from pyvirtualdisplay import Display
|
||||
display = Display(visible=False, size=VIRTUAL_SCREEN_SIZE)
|
||||
display.start()
|
||||
logger.info("Display started")
|
||||
DISPLAY["xvfb"] = display
|
||||
time.sleep(DEFAULT_SLEEP)
|
||||
_reset_pyautogui_display_state()
|
||||
|
||||
if env.DEBUG:
|
||||
timestamp = datetime.now().strftime("%y%m%d-%H%M%S")
|
||||
output_file = RECORDING_DIR / f"screen_recording_{timestamp}.mp4"
|
||||
|
||||
ffmpeg_cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f", "x11grab",
|
||||
"-video_size", f"{VIRTUAL_SCREEN_SIZE[0]}x{VIRTUAL_SCREEN_SIZE[1]}",
|
||||
"-i", f":{display.display}",
|
||||
"-c:v", "libx264",
|
||||
"-preset", "ultrafast", # or "veryfast" (trade speed for slightly better compression)
|
||||
"-maxrate", "700k", # Slightly higher bitrate for text clarity
|
||||
"-bufsize", "1400k", # Buffer size (2x maxrate)
|
||||
"-crf", "36", # Adjust as needed: higher = smaller, lower = better quality (23 is visually lossless)
|
||||
"-pix_fmt", "yuv420p", # Crucial for compatibility with most players
|
||||
"-tune", "animation", # Optimize encoding for screen content
|
||||
"-x264-params", "bframes=0:deblock=-1,-1", # Optimize for text, disable b-frames and deblocking
|
||||
"-r", "15", # Reduce frame rate (if content allows)
|
||||
"-an", # Disable audio recording (if not needed)
|
||||
output_file.as_posix(),
|
||||
"-nostats", "-loglevel", "0"
|
||||
]
|
||||
logger.info("Starting FFmpeg recording to %s", output_file)
|
||||
logger.debug_trace(f"FFmpeg command: {' '.join(ffmpeg_cmd)}")
|
||||
DISPLAY["ffmpeg"] = subprocess.Popen(ffmpeg_cmd)
|
||||
if not DRIVER:
|
||||
return _init_driver()
|
||||
logger.log_resource_usage()
|
||||
return DRIVER
|
||||
|
||||
def _reset_driver():
|
||||
logger.log_resource_usage()
|
||||
logger.info("Resetting driver...")
|
||||
global DRIVER, DISPLAY
|
||||
if DRIVER:
|
||||
try:
|
||||
DRIVER.quit()
|
||||
DRIVER = None
|
||||
except Exception as e:
|
||||
logger.warning(f"Error quitting driver: {e}")
|
||||
time.sleep(0.5)
|
||||
if DISPLAY["xvfb"]:
|
||||
try:
|
||||
DISPLAY["xvfb"].stop()
|
||||
DISPLAY["xvfb"] = None
|
||||
except Exception as e:
|
||||
logger.warning(f"Error stopping display: {e}")
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
os.system("pkill -f Xvfb")
|
||||
except Exception as e:
|
||||
logger.debug(f"Error killing Xvfb: {e}")
|
||||
time.sleep(0.5)
|
||||
if DISPLAY["ffmpeg"]:
|
||||
try:
|
||||
DISPLAY["ffmpeg"].send_signal(signal.SIGINT)
|
||||
DISPLAY["ffmpeg"] = None
|
||||
except Exception as e:
|
||||
logger.debug(f"Error stopping ffmpeg: {e}")
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
os.system("pkill -f ffmpeg")
|
||||
except Exception as e:
|
||||
logger.debug(f"Error killing ffmpeg: {e}")
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
os.system("pkill -f chrom")
|
||||
except Exception as e:
|
||||
logger.debug(f"Error killing chrom: {e}")
|
||||
time.sleep(0.5)
|
||||
logger.info("Driver reset.")
|
||||
logger.log_resource_usage()
|
||||
|
||||
def _cleanup_driver():
|
||||
global LOCKED
|
||||
global LAST_USED
|
||||
with LOCKED:
|
||||
if LAST_USED:
|
||||
if time.time() - LAST_USED >= env.BYPASS_RELEASE_INACTIVE_MIN * 60:
|
||||
_reset_driver()
|
||||
LAST_USED = None
|
||||
logger.info("Driver reset due to inactivity.")
|
||||
|
||||
def _cleanup_loop():
|
||||
while True:
|
||||
_cleanup_driver()
|
||||
time.sleep(max(env.BYPASS_RELEASE_INACTIVE_MIN / 2, 1))
|
||||
|
||||
def _init_cleanup_thread():
|
||||
cleanup_thread = threading.Thread(target=_cleanup_loop)
|
||||
cleanup_thread.daemon = True
|
||||
cleanup_thread.start()
|
||||
|
||||
def wait_for_result(func, timeout : int = 10, condition : any = True):
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
result = func()
|
||||
if condition(result):
|
||||
return result
|
||||
time.sleep(0.5)
|
||||
return None
|
||||
_init_cleanup_thread()
|
||||
@@ -1,99 +0,0 @@
|
||||
"""Configuration settings for the book downloader application."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import json
|
||||
import env
|
||||
from logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
for key, value in env.__dict__.items():
|
||||
if not key.startswith('_'):
|
||||
if key == "AA_DONATOR_KEY" and value.strip() != "":
|
||||
value = "REDACTED"
|
||||
logger.info(f"{key}: {value}")
|
||||
|
||||
with open("data/book-languages.json") as file:
|
||||
_SUPPORTED_BOOK_LANGUAGE = json.load(file)
|
||||
|
||||
# Directory settings
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
logger.info(f"BASE_DIR: {BASE_DIR}")
|
||||
if env.ENABLE_LOGGING:
|
||||
env.LOG_DIR.mkdir(exist_ok=True)
|
||||
|
||||
# Create necessary directories
|
||||
env.TMP_DIR.mkdir(exist_ok=True)
|
||||
env.INGEST_DIR.mkdir(exist_ok=True)
|
||||
|
||||
CROSS_FILE_SYSTEM = os.stat(env.TMP_DIR).st_dev != os.stat(env.INGEST_DIR).st_dev
|
||||
logger.info(f"STAT TMP_DIR: {os.stat(env.TMP_DIR)}")
|
||||
logger.info(f"STAT INGEST_DIR: {os.stat(env.INGEST_DIR)}")
|
||||
logger.info(f"CROSS_FILE_SYSTEM: {CROSS_FILE_SYSTEM}")
|
||||
|
||||
# Network settings
|
||||
_custom_dns = env._CUSTOM_DNS.lower().strip()
|
||||
_doh_server = ""
|
||||
if _custom_dns == "google":
|
||||
CUSTOM_DNS = ["8.8.8.8", "8.8.4.4", "2001:4860:4860::8888", "2001:4860:4860::8844"]
|
||||
_doh_server = "https://dns.google/dns-query"
|
||||
elif _custom_dns == "quad9":
|
||||
CUSTOM_DNS = ["9.9.9.9", "149.112.112.112", "2620:fe::fe", "26620:fe::9"]
|
||||
_doh_server = "https://dns.quad9.net/dns-query"
|
||||
elif _custom_dns == "cloudflare":
|
||||
CUSTOM_DNS = ["1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001"]
|
||||
_doh_server = "https://cloudflare-dns.com/dns-query"
|
||||
elif _custom_dns == "opendns":
|
||||
CUSTOM_DNS = ["208.67.222.222", "208.67.220.220", "2620:119:35::35", "2620:119:53::53"]
|
||||
_doh_server = "https://doh.opendns.com/dns-query"
|
||||
else:
|
||||
_custom_dns_ip = _custom_dns.split(",")
|
||||
CUSTOM_DNS = [dns.strip() for dns in _custom_dns_ip if dns.replace(":", "").replace(".", "").strip().isdigit()]
|
||||
logger.info(f"CUSTOM_DNS: {CUSTOM_DNS}")
|
||||
DOH_SERVER = _doh_server
|
||||
if env.USE_DOH:
|
||||
DOH_SERVER = _doh_server
|
||||
else:
|
||||
DOH_SERVER = ""
|
||||
logger.info(f"DOH_SERVER: {DOH_SERVER}")
|
||||
|
||||
# Proxy settings
|
||||
PROXIES = {}
|
||||
if env.HTTP_PROXY:
|
||||
PROXIES["http"] = env.HTTP_PROXY
|
||||
if env.HTTPS_PROXY:
|
||||
PROXIES["https"] = env.HTTPS_PROXY
|
||||
logger.info(f"PROXIES: {PROXIES}")
|
||||
|
||||
# Anna's Archive settings
|
||||
AA_BASE_URL = env._AA_BASE_URL
|
||||
AA_AVAILABLE_URLS = ["https://annas-archive.org", "https://annas-archive.se", "https://annas-archive.li"]
|
||||
AA_AVAILABLE_URLS.extend(env._AA_ADDITIONAL_URLS.split(","))
|
||||
AA_AVAILABLE_URLS = [url.strip() for url in AA_AVAILABLE_URLS if url.strip()]
|
||||
|
||||
# File format settings
|
||||
SUPPORTED_FORMATS = env._SUPPORTED_FORMATS.split(",")
|
||||
logger.info(f"SUPPORTED_FORMATS: {SUPPORTED_FORMATS}")
|
||||
|
||||
# Complex language processing logic kept in config.py
|
||||
BOOK_LANGUAGE = env._BOOK_LANGUAGE.split(',')
|
||||
BOOK_LANGUAGE = [l for l in BOOK_LANGUAGE if l in [lang['code'] for lang in _SUPPORTED_BOOK_LANGUAGE]]
|
||||
if len(BOOK_LANGUAGE) == 0:
|
||||
BOOK_LANGUAGE = ['en']
|
||||
|
||||
# Custom script settings with validation logic
|
||||
CUSTOM_SCRIPT = env._CUSTOM_SCRIPT
|
||||
if CUSTOM_SCRIPT:
|
||||
if not os.path.exists(CUSTOM_SCRIPT):
|
||||
logger.warn(f"CUSTOM_SCRIPT {CUSTOM_SCRIPT} does not exist")
|
||||
CUSTOM_SCRIPT = ""
|
||||
elif not os.access(CUSTOM_SCRIPT, os.X_OK):
|
||||
logger.warn(f"CUSTOM_SCRIPT {CUSTOM_SCRIPT} is not executable")
|
||||
CUSTOM_SCRIPT = ""
|
||||
|
||||
# Debugging settings
|
||||
VIRTUAL_SCREEN_SIZE = (1024, 768)
|
||||
RECORDING_DIR = env.LOG_DIR / "recording"
|
||||
if env.DEBUG:
|
||||
RECORDING_DIR.mkdir(parents=True, exist_ok=True)
|
||||
@@ -0,0 +1 @@
|
||||
"""CWA Book Downloader - book search and download service."""
|
||||
@@ -0,0 +1,8 @@
|
||||
"""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))
|
||||
@@ -0,0 +1 @@
|
||||
"""API module - WebSocket handling."""
|
||||
@@ -0,0 +1,162 @@
|
||||
"""WebSocket manager for real-time status updates."""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Optional, Dict, Any, Callable, List
|
||||
|
||||
from flask_socketio import SocketIO
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebSocketManager:
|
||||
"""Manages WebSocket connections and broadcasts."""
|
||||
|
||||
def __init__(self):
|
||||
self.socketio: Optional[SocketIO] = None
|
||||
self._enabled = False
|
||||
self._connection_count = 0
|
||||
self._connection_lock = threading.Lock()
|
||||
self._on_first_connect_callbacks: List[Callable[[], None]] = []
|
||||
self._on_all_disconnect_callbacks: List[Callable[[], None]] = []
|
||||
self._needs_rewarm = False # Flag to trigger warmup callbacks on next connect
|
||||
|
||||
def init_app(self, app, socketio: SocketIO):
|
||||
"""Initialize the WebSocket manager with Flask-SocketIO instance."""
|
||||
self.socketio = socketio
|
||||
self._enabled = True
|
||||
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.
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
with self._connection_lock:
|
||||
self._needs_rewarm = True
|
||||
logger.debug("Warmup requested for next client connect")
|
||||
|
||||
def client_connected(self):
|
||||
"""Track a new client connection. Call this from the connect event handler."""
|
||||
with self._connection_lock:
|
||||
was_zero = self._connection_count == 0
|
||||
needs_rewarm = self._needs_rewarm
|
||||
self._connection_count += 1
|
||||
current_count = self._connection_count
|
||||
# Clear rewarm flag if we're going to trigger warmup
|
||||
if was_zero or needs_rewarm:
|
||||
self._needs_rewarm = False
|
||||
|
||||
logger.debug(f"Client connected. Active connections: {current_count}")
|
||||
|
||||
# Trigger warmup callbacks if this is the first connection OR if rewarm was requested
|
||||
# (rewarm is requested when bypasser shuts down due to idle while clients are connected)
|
||||
if was_zero or needs_rewarm:
|
||||
reason = "First client connected" if was_zero else "Rewarm requested after idle shutdown"
|
||||
logger.info(f"{reason}, triggering warmup callbacks...")
|
||||
for callback in self._on_first_connect_callbacks:
|
||||
try:
|
||||
# Run callbacks in a separate thread to not block the connection
|
||||
thread = threading.Thread(target=callback, daemon=True)
|
||||
thread.start()
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_first_connect callback {callback.__name__}: {e}")
|
||||
|
||||
def client_disconnected(self):
|
||||
"""Track a client disconnection. Call this from the disconnect event handler."""
|
||||
with self._connection_lock:
|
||||
self._connection_count = max(0, self._connection_count - 1)
|
||||
current_count = self._connection_count
|
||||
is_now_zero = current_count == 0
|
||||
|
||||
logger.debug(f"Client disconnected. Active connections: {current_count}")
|
||||
|
||||
# If all clients have disconnected, trigger cleanup callbacks
|
||||
if is_now_zero:
|
||||
logger.info("All clients disconnected, triggering disconnect callbacks...")
|
||||
for callback in self._on_all_disconnect_callbacks:
|
||||
try:
|
||||
callback()
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_all_disconnect callback {callback.__name__}: {e}")
|
||||
|
||||
def get_connection_count(self) -> int:
|
||||
"""Get the current number of active WebSocket connections."""
|
||||
with self._connection_lock:
|
||||
return self._connection_count
|
||||
|
||||
def has_active_connections(self) -> bool:
|
||||
"""Check if there are any active WebSocket connections."""
|
||||
return self.get_connection_count() > 0
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
"""Check if WebSocket is enabled and ready."""
|
||||
return self._enabled and self.socketio is not None
|
||||
|
||||
def broadcast_status_update(self, status_data: Dict[str, Any]):
|
||||
"""Broadcast status update to all connected clients."""
|
||||
if not self.is_enabled():
|
||||
return
|
||||
|
||||
try:
|
||||
# When calling socketio.emit() outside event handlers, it broadcasts by default
|
||||
self.socketio.emit('status_update', status_data)
|
||||
logger.debug(f"Broadcasted status update to all clients")
|
||||
except Exception as e:
|
||||
logger.error(f"Error broadcasting status update: {e}")
|
||||
|
||||
def broadcast_download_progress(self, book_id: str, progress: float, status: str):
|
||||
"""Broadcast download progress update for a specific book."""
|
||||
if not self.is_enabled():
|
||||
return
|
||||
|
||||
try:
|
||||
data = {
|
||||
'book_id': book_id,
|
||||
'progress': progress,
|
||||
'status': status
|
||||
}
|
||||
# When calling socketio.emit() outside event handlers, it broadcasts by default
|
||||
self.socketio.emit('download_progress', data)
|
||||
logger.debug(f"Broadcasted progress for book {book_id}: {progress}%")
|
||||
except Exception as e:
|
||||
logger.error(f"Error broadcasting download progress: {e}")
|
||||
|
||||
def broadcast_notification(self, message: str, notification_type: str = 'info'):
|
||||
"""Broadcast a notification message to all clients."""
|
||||
if not self.is_enabled():
|
||||
return
|
||||
|
||||
try:
|
||||
data = {
|
||||
'message': message,
|
||||
'type': notification_type
|
||||
}
|
||||
# When calling socketio.emit() outside event handlers, it broadcasts by default
|
||||
self.socketio.emit('notification', data)
|
||||
logger.debug(f"Broadcasted notification: {message}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error broadcasting notification: {e}")
|
||||
|
||||
|
||||
# Global WebSocket manager instance
|
||||
ws_manager = WebSocketManager()
|
||||
@@ -0,0 +1 @@
|
||||
"""Cloudflare bypass utilities."""
|
||||
@@ -0,0 +1,162 @@
|
||||
"""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
|
||||
@@ -0,0 +1 @@
|
||||
"""Configuration module - environment variables and settings."""
|
||||
@@ -0,0 +1,151 @@
|
||||
"""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"))
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Authentication settings registration."""
|
||||
|
||||
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 (
|
||||
register_settings,
|
||||
register_on_save,
|
||||
load_config_file,
|
||||
TextField,
|
||||
PasswordField,
|
||||
CheckboxField,
|
||||
ActionButton,
|
||||
)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def _clear_builtin_credentials() -> Dict[str, Any]:
|
||||
"""Clear built-in credentials to allow public access."""
|
||||
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:
|
||||
json.dump(config, f, indent=2)
|
||||
|
||||
logger.info("Cleared credentials")
|
||||
return {"success": True, "message": "Credentials cleared. The app is now publicly accessible."}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clear credentials: {e}")
|
||||
return {"success": False, "message": f"Failed to clear credentials: {str(e)}"}
|
||||
|
||||
|
||||
def _on_save_security(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Custom save handler for security settings.
|
||||
|
||||
Handles password validation and hashing:
|
||||
- If new password is provided, validate confirmation and hash it
|
||||
- If password fields are empty, preserve existing hash
|
||||
- Never store raw passwords
|
||||
|
||||
Returns:
|
||||
Dict with processed values to save and any validation errors.
|
||||
"""
|
||||
password = values.get("BUILTIN_PASSWORD", "")
|
||||
password_confirm = values.get("BUILTIN_PASSWORD_CONFIRM", "")
|
||||
|
||||
# Remove raw password fields - they should never be persisted
|
||||
values.pop("BUILTIN_PASSWORD", None)
|
||||
values.pop("BUILTIN_PASSWORD_CONFIRM", None)
|
||||
|
||||
# If password is provided, validate and hash it
|
||||
if password:
|
||||
if password != password_confirm:
|
||||
return {
|
||||
"error": True,
|
||||
"message": "Passwords do not match",
|
||||
"values": values
|
||||
}
|
||||
|
||||
if len(password) < 4:
|
||||
return {
|
||||
"error": True,
|
||||
"message": "Password must be at least 4 characters",
|
||||
"values": values
|
||||
}
|
||||
|
||||
# Hash the password
|
||||
values["BUILTIN_PASSWORD_HASH"] = generate_password_hash(password)
|
||||
logger.info("Password hash updated")
|
||||
|
||||
# If no password provided but username is being set, preserve existing hash
|
||||
elif "BUILTIN_USERNAME" in values:
|
||||
existing = load_config_file("security")
|
||||
if "BUILTIN_PASSWORD_HASH" in existing:
|
||||
values["BUILTIN_PASSWORD_HASH"] = existing["BUILTIN_PASSWORD_HASH"]
|
||||
|
||||
return {"error": False, "values": values}
|
||||
|
||||
|
||||
@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
|
||||
|
||||
cwa_db_available = CWA_DB_PATH and os.path.exists(CWA_DB_PATH)
|
||||
|
||||
fields = [
|
||||
TextField(
|
||||
key="BUILTIN_USERNAME",
|
||||
label="Username",
|
||||
description="Set a username and password to require login. Leave both empty for public access.",
|
||||
placeholder="Enter username",
|
||||
env_supported=False,
|
||||
disabled_when={"field": "USE_CWA_AUTH", "value": True, "reason": "Using Calibre-Web database for authentication."},
|
||||
),
|
||||
PasswordField(
|
||||
key="BUILTIN_PASSWORD",
|
||||
label="Set Password",
|
||||
description="Fill in to set or change the password.",
|
||||
placeholder="Enter new password",
|
||||
env_supported=False,
|
||||
disabled_when={"field": "USE_CWA_AUTH", "value": True, "reason": "Using Calibre-Web database for authentication."},
|
||||
),
|
||||
PasswordField(
|
||||
key="BUILTIN_PASSWORD_CONFIRM",
|
||||
label="Confirm Password",
|
||||
placeholder="Confirm new password",
|
||||
env_supported=False,
|
||||
disabled_when={"field": "USE_CWA_AUTH", "value": True, "reason": "Using Calibre-Web database for authentication."},
|
||||
),
|
||||
ActionButton(
|
||||
key="clear_credentials",
|
||||
label="Clear Credentials",
|
||||
description="Remove login requirement and make the app publicly accessible.",
|
||||
style="danger",
|
||||
callback=_clear_builtin_credentials,
|
||||
disabled_when={"field": "USE_CWA_AUTH", "value": True, "reason": "Using Calibre-Web database for authentication."},
|
||||
),
|
||||
CheckboxField(
|
||||
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."
|
||||
),
|
||||
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.",
|
||||
),
|
||||
]
|
||||
|
||||
return fields
|
||||
|
||||
|
||||
# Register the on_save handler for this tab
|
||||
register_on_save("security", _on_save_security)
|
||||
@@ -0,0 +1,871 @@
|
||||
"""Core settings registration and derived configuration values."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
from cwa_book_downloader.config import env
|
||||
from cwa_book_downloader.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}")
|
||||
|
||||
# Load supported book languages from data file
|
||||
# Path is relative to the package root, not this file
|
||||
_DATA_DIR = Path(__file__).resolve().parent.parent.parent / "data"
|
||||
with open(_DATA_DIR / "book-languages.json") as file:
|
||||
_SUPPORTED_BOOK_LANGUAGE = json.load(file)
|
||||
|
||||
# Directory settings
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
logger.debug(f"BASE_DIR: {BASE_DIR}")
|
||||
if env.ENABLE_LOGGING:
|
||||
env.LOG_DIR.mkdir(exist_ok=True)
|
||||
|
||||
# Create necessary directories
|
||||
env.TMP_DIR.mkdir(exist_ok=True)
|
||||
env.INGEST_DIR.mkdir(exist_ok=True)
|
||||
|
||||
CROSS_FILE_SYSTEM = os.stat(env.TMP_DIR).st_dev != os.stat(env.INGEST_DIR).st_dev
|
||||
logger.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"
|
||||
|
||||
|
||||
from cwa_book_downloader.core.settings_registry import (
|
||||
register_settings,
|
||||
register_group,
|
||||
TextField,
|
||||
PasswordField,
|
||||
NumberField,
|
||||
CheckboxField,
|
||||
SelectField,
|
||||
MultiSelectField,
|
||||
OrderableListField,
|
||||
HeadingField,
|
||||
ActionButton,
|
||||
)
|
||||
|
||||
|
||||
register_group(
|
||||
"direct_download",
|
||||
"Anna's Archive",
|
||||
icon="download",
|
||||
order=20
|
||||
)
|
||||
|
||||
register_group(
|
||||
"metadata_providers",
|
||||
"Metadata Providers",
|
||||
icon="book",
|
||||
order=12 # Between Network (10) and Advanced (15)
|
||||
)
|
||||
|
||||
|
||||
# Anna's Archive sort options (for Direct mode)
|
||||
_AA_SORT_OPTIONS = [
|
||||
{"value": "relevance", "label": "Most relevant"},
|
||||
{"value": "newest", "label": "Newest (publication year)"},
|
||||
{"value": "oldest", "label": "Oldest (publication year)"},
|
||||
{"value": "largest", "label": "Largest (filesize)"},
|
||||
{"value": "smallest", "label": "Smallest (filesize)"},
|
||||
{"value": "newest_added", "label": "Newest (open sourced)"},
|
||||
{"value": "oldest_added", "label": "Oldest (open sourced)"},
|
||||
]
|
||||
|
||||
_FORMAT_OPTIONS = [
|
||||
{"value": "epub", "label": "EPUB"},
|
||||
{"value": "mobi", "label": "MOBI"},
|
||||
{"value": "azw3", "label": "AZW3"},
|
||||
{"value": "pdf", "label": "PDF"},
|
||||
{"value": "fb2", "label": "FB2"},
|
||||
{"value": "djvu", "label": "DJVU"},
|
||||
{"value": "cbz", "label": "CBZ"},
|
||||
{"value": "cbr", "label": "CBR"},
|
||||
{"value": "txt", "label": "TXT"},
|
||||
{"value": "rtf", "label": "RTF"},
|
||||
{"value": "doc", "label": "DOC"},
|
||||
{"value": "docx", "label": "DOCX"},
|
||||
{"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
|
||||
|
||||
options = []
|
||||
for provider in list_providers():
|
||||
# Only show providers that are enabled
|
||||
if is_provider_enabled(provider["name"]):
|
||||
options.append({"value": provider["name"], "label": provider["display_name"]})
|
||||
|
||||
# If no providers enabled, show a placeholder option
|
||||
if not options:
|
||||
options = [
|
||||
{"value": "", "label": "No providers enabled"},
|
||||
]
|
||||
|
||||
return options
|
||||
|
||||
|
||||
def _get_release_source_options():
|
||||
"""Build release source options dynamically from registered sources."""
|
||||
from cwa_book_downloader.release_sources import list_available_sources
|
||||
|
||||
return [
|
||||
{"value": source["name"], "label": source["display_name"]}
|
||||
for source in list_available_sources()
|
||||
]
|
||||
|
||||
_LANGUAGE_OPTIONS = [{"value": lang["code"], "label": lang["language"]} for lang in _SUPPORTED_BOOK_LANGUAGE]
|
||||
|
||||
|
||||
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
|
||||
|
||||
cache = get_image_cache()
|
||||
count = cache.clear()
|
||||
|
||||
# Reset the singleton so it reinitializes with fresh state
|
||||
reset_image_cache()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Cleared {count} cached cover images.",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clear cover cache: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Failed to clear cache: {str(e)}",
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
cache = get_metadata_cache()
|
||||
stats_before = cache.stats()
|
||||
cache.clear()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Cleared {stats_before['size']} cached entries.",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clear metadata cache: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Failed to clear cache: {str(e)}",
|
||||
}
|
||||
|
||||
|
||||
@register_settings("general", "General", icon="settings", order=0)
|
||||
def general_settings():
|
||||
"""Core application settings."""
|
||||
return [
|
||||
TextField(
|
||||
key="CALIBRE_WEB_URL",
|
||||
label="Book Management App URL",
|
||||
description="Adds a navigation button to your book manager instance (Calibre-Web Automated, Booklore, etc).",
|
||||
placeholder="http://calibre-web:8083",
|
||||
),
|
||||
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.",
|
||||
),
|
||||
SelectField(
|
||||
key="SEARCH_MODE",
|
||||
label="Search Mode",
|
||||
description="How you want to search for and download books.",
|
||||
options=[
|
||||
{
|
||||
"value": "direct",
|
||||
"label": "Direct (Anna's Archive)",
|
||||
"description": "Search Anna's Archive and download directly. Works out of the box.",
|
||||
},
|
||||
{
|
||||
"value": "universal",
|
||||
"label": "Universal",
|
||||
"description": "Metadata-based search with downloads from all sources.",
|
||||
},
|
||||
],
|
||||
default="direct",
|
||||
),
|
||||
SelectField(
|
||||
key="AA_DEFAULT_SORT",
|
||||
label="Default Sort Order",
|
||||
description="Default sort order for Anna's Archive search results.",
|
||||
options=_AA_SORT_OPTIONS,
|
||||
default="relevance",
|
||||
env_supported=False, # UI-only setting
|
||||
show_when={"field": "SEARCH_MODE", "value": "direct"},
|
||||
),
|
||||
SelectField(
|
||||
key="METADATA_PROVIDER",
|
||||
label="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="DEFAULT_RELEASE_SOURCE",
|
||||
label="Default Release Source",
|
||||
description="The release source tab to open by default in the release modal.",
|
||||
options=_get_release_source_options, # Callable - evaluated lazily to avoid circular imports
|
||||
default="direct_download",
|
||||
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"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@register_settings("network", "Network", icon="globe", order=10)
|
||||
def network_settings():
|
||||
"""Network and connectivity settings."""
|
||||
# Check if Tor variant is available and if Tor is currently enabled
|
||||
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
|
||||
|
||||
return [
|
||||
SelectField(
|
||||
key="CUSTOM_DNS",
|
||||
label="DNS Provider",
|
||||
description=(
|
||||
"Managed by Tor when Tor routing is enabled."
|
||||
if tor_overrides_network
|
||||
else "DNS provider for domain resolution. 'Auto' rotates through providers on failure."
|
||||
),
|
||||
options=[
|
||||
{"value": "auto", "label": "Auto (Recommended)"},
|
||||
{"value": "system", "label": "System"},
|
||||
{"value": "google", "label": "Google"},
|
||||
{"value": "cloudflare", "label": "Cloudflare"},
|
||||
{"value": "quad9", "label": "Quad9"},
|
||||
{"value": "opendns", "label": "OpenDNS"},
|
||||
{"value": "manual", "label": "Manual"},
|
||||
],
|
||||
default="auto",
|
||||
disabled=tor_overrides_network,
|
||||
disabled_reason="DNS is managed by Tor when Tor routing is enabled.",
|
||||
),
|
||||
TextField(
|
||||
key="CUSTOM_DNS_MANUAL",
|
||||
label="Manual DNS Servers",
|
||||
description="Comma-separated list of DNS server IP addresses (e.g., 8.8.8.8, 1.1.1.1).",
|
||||
placeholder="8.8.8.8, 1.1.1.1",
|
||||
disabled=tor_overrides_network,
|
||||
disabled_reason="DNS is managed by Tor when Tor routing is enabled.",
|
||||
show_when={"field": "CUSTOM_DNS", "value": "manual"},
|
||||
),
|
||||
CheckboxField(
|
||||
key="USE_DOH",
|
||||
label="Use DNS over HTTPS",
|
||||
description=(
|
||||
"Not applicable when Tor routing is enabled."
|
||||
if tor_overrides_network
|
||||
else "Use encrypted DNS queries for improved reliability and privacy."
|
||||
),
|
||||
default=True,
|
||||
disabled=tor_overrides_network,
|
||||
disabled_reason="DNS over HTTPS is not used when Tor routing is enabled.",
|
||||
# Hide for manual and system (no DoH endpoint available for custom IPs or system DNS)
|
||||
show_when={"field": "CUSTOM_DNS", "value": ["auto", "google", "cloudflare", "quad9", "opendns"]},
|
||||
# Disable for auto (always uses DoH)
|
||||
disabled_when={
|
||||
"field": "CUSTOM_DNS",
|
||||
"value": "auto",
|
||||
"reason": "Auto mode always uses DNS over HTTPS for reliable provider rotation.",
|
||||
},
|
||||
),
|
||||
CheckboxField(
|
||||
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."
|
||||
),
|
||||
default=tor_available, # Reflects actual state: True if Tor variant, False otherwise
|
||||
disabled=True, # Always disabled - Tor state is determined by container variant
|
||||
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)."
|
||||
),
|
||||
),
|
||||
SelectField(
|
||||
key="PROXY_MODE",
|
||||
label="Proxy Mode",
|
||||
description=(
|
||||
"Not applicable when Tor routing is enabled."
|
||||
if tor_overrides_network
|
||||
else "Choose proxy type. SOCKS5 handles all traffic through a single proxy."
|
||||
),
|
||||
options=[
|
||||
{"value": "none", "label": "None (Direct Connection)"},
|
||||
{"value": "http", "label": "HTTP/HTTPS Proxy"},
|
||||
{"value": "socks5", "label": "SOCKS5 Proxy"},
|
||||
],
|
||||
default="none",
|
||||
disabled=tor_overrides_network,
|
||||
disabled_reason="Proxy settings are not used when Tor routing is enabled.",
|
||||
),
|
||||
TextField(
|
||||
key="HTTP_PROXY",
|
||||
label="HTTP Proxy",
|
||||
description="HTTP proxy URL (e.g., http://proxy:8080)",
|
||||
placeholder="http://proxy:8080",
|
||||
disabled=tor_overrides_network,
|
||||
disabled_reason="Proxy settings are not used when Tor routing is enabled.",
|
||||
show_when={"field": "PROXY_MODE", "value": "http"},
|
||||
),
|
||||
TextField(
|
||||
key="HTTPS_PROXY",
|
||||
label="HTTPS Proxy",
|
||||
description="HTTPS proxy URL (leave empty to use HTTP proxy for HTTPS)",
|
||||
placeholder="http://proxy:8080",
|
||||
disabled=tor_overrides_network,
|
||||
disabled_reason="Proxy settings are not used when Tor routing is enabled.",
|
||||
show_when={"field": "PROXY_MODE", "value": "http"},
|
||||
),
|
||||
TextField(
|
||||
key="SOCKS5_PROXY",
|
||||
label="SOCKS5 Proxy",
|
||||
description="SOCKS5 proxy URL. Supports auth: socks5://user:pass@host:port",
|
||||
placeholder="socks5://localhost:1080",
|
||||
disabled=tor_overrides_network,
|
||||
disabled_reason="Proxy settings are not used when Tor routing is enabled.",
|
||||
show_when={"field": "PROXY_MODE", "value": "socks5"},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@register_settings("downloads", "Downloads", icon="folder", order=5)
|
||||
def download_settings():
|
||||
"""Configure download behavior and file locations."""
|
||||
return [
|
||||
TextField(
|
||||
key="INGEST_DIR",
|
||||
label="Download Directory",
|
||||
description="Directory where downloaded files are saved.",
|
||||
default="/cwa-book-ingest",
|
||||
required=True,
|
||||
),
|
||||
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.",
|
||||
default=True,
|
||||
),
|
||||
CheckboxField(
|
||||
key="AUTO_OPEN_DOWNLOADS_SIDEBAR",
|
||||
label="Auto-Open Downloads Sidebar",
|
||||
description="Automatically open the downloads sidebar when a new download is queued.",
|
||||
default=False,
|
||||
env_supported=False, # UI-only setting
|
||||
),
|
||||
CheckboxField(
|
||||
key="DOWNLOAD_TO_BROWSER",
|
||||
label="Download to Browser",
|
||||
description="Automatically download completed files to your browser.",
|
||||
default=False,
|
||||
env_supported=False, # UI-only setting
|
||||
),
|
||||
NumberField(
|
||||
key="MAX_CONCURRENT_DOWNLOADS",
|
||||
label="Max Concurrent Downloads",
|
||||
description="Maximum number of simultaneous downloads.",
|
||||
default=3,
|
||||
min_value=1,
|
||||
max_value=10,
|
||||
requires_restart=True,
|
||||
),
|
||||
NumberField(
|
||||
key="STATUS_TIMEOUT",
|
||||
label="Status Timeout (seconds)",
|
||||
description="How long to keep completed/failed downloads in the queue display.",
|
||||
default=3600,
|
||||
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
|
||||
|
||||
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",
|
||||
"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": "aa-slow-nowait",
|
||||
"label": "Anna's Archive (Slowest, No Waitlist)",
|
||||
"description": "Partner servers without countdown",
|
||||
},
|
||||
{
|
||||
"id": "aa-slow-wait",
|
||||
"label": "Anna's Archive (Slow, Waitlist)",
|
||||
"description": "Partner servers with countdown timer",
|
||||
},
|
||||
{
|
||||
"id": "libgen",
|
||||
"label": "Libgen",
|
||||
"description": "Library Genesis mirrors",
|
||||
},
|
||||
{
|
||||
"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,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _get_default_source_priority():
|
||||
"""Default source priority order, respecting legacy env vars.
|
||||
|
||||
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},
|
||||
{"id": "aa-slow-nowait", "enabled": True},
|
||||
{"id": "aa-slow-wait", "enabled": True},
|
||||
{"id": "libgen", "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 [
|
||||
HeadingField(
|
||||
key="source_priority_heading",
|
||||
title="Source Priority",
|
||||
description="Configure which download sources to use and in what order.",
|
||||
),
|
||||
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(),
|
||||
),
|
||||
NumberField(
|
||||
key="MAX_RETRY",
|
||||
label="Max Retries",
|
||||
description="Maximum retry attempts for failed downloads.",
|
||||
default=10,
|
||||
min_value=1,
|
||||
max_value=50,
|
||||
),
|
||||
NumberField(
|
||||
key="DEFAULT_SLEEP",
|
||||
label="Retry Delay (seconds)",
|
||||
description="Wait time between download retry attempts.",
|
||||
default=5,
|
||||
min_value=1,
|
||||
max_value=60,
|
||||
),
|
||||
HeadingField(
|
||||
key="aa_settings_heading",
|
||||
title="Anna's Archive",
|
||||
description="Configure Anna's Archive mirror and donator settings.",
|
||||
),
|
||||
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",
|
||||
),
|
||||
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",
|
||||
),
|
||||
PasswordField(
|
||||
key="AA_DONATOR_KEY",
|
||||
label="Anna's Archive Donator Key",
|
||||
description="Optional donator key for faster downloads from Anna's Archive.",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@register_settings("cloudflare_bypass", "Cloudflare Bypass", icon="shield", order=22, group="direct_download")
|
||||
def cloudflare_bypass_settings():
|
||||
"""Settings for Cloudflare bypass behavior."""
|
||||
return [
|
||||
CheckboxField(
|
||||
key="USE_CF_BYPASS",
|
||||
label="Enable Cloudflare Bypass",
|
||||
description="Attempt to bypass Cloudflare protection on download sites.",
|
||||
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",
|
||||
description="Use FlareSolverr or similar external service instead of built-in bypasser. Caution: May have limitations with custom DNS, Tor and proxies. You may experience slower downloads and and poorer reliability compared to the internal bypasser.",
|
||||
default=False,
|
||||
requires_restart=True,
|
||||
),
|
||||
TextField(
|
||||
key="EXT_BYPASSER_URL",
|
||||
label="External Bypasser URL",
|
||||
description="URL of the external bypasser service (e.g., FlareSolverr).",
|
||||
default="http://flaresolverr:8191",
|
||||
placeholder="http://flaresolverr:8191",
|
||||
requires_restart=True,
|
||||
show_when={"field": "USING_EXTERNAL_BYPASSER", "value": True},
|
||||
),
|
||||
TextField(
|
||||
key="EXT_BYPASSER_PATH",
|
||||
label="External Bypasser Path",
|
||||
description="API path for the external bypasser.",
|
||||
default="/v1",
|
||||
placeholder="/v1",
|
||||
requires_restart=True,
|
||||
show_when={"field": "USING_EXTERNAL_BYPASSER", "value": True},
|
||||
),
|
||||
NumberField(
|
||||
key="EXT_BYPASSER_TIMEOUT",
|
||||
label="External Bypasser Timeout (ms)",
|
||||
description="Timeout for external bypasser requests in milliseconds.",
|
||||
default=60000,
|
||||
min_value=10000,
|
||||
max_value=300000,
|
||||
requires_restart=True,
|
||||
show_when={"field": "USING_EXTERNAL_BYPASSER", "value": True},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@register_settings("advanced", "Advanced", icon="cog", order=15)
|
||||
def advanced_settings():
|
||||
"""Advanced settings for power users."""
|
||||
return [
|
||||
TextField(
|
||||
key="CUSTOM_SCRIPT",
|
||||
label="Custom Script Path",
|
||||
description="Path to a script to run after each successful download. Must be executable.",
|
||||
placeholder="/path/to/script.sh",
|
||||
),
|
||||
CheckboxField(
|
||||
key="DEBUG",
|
||||
label="Debug Mode",
|
||||
description="Enable verbose logging to console and file. Not recommended for normal use.",
|
||||
default=False,
|
||||
requires_restart=True,
|
||||
),
|
||||
NumberField(
|
||||
key="MAIN_LOOP_SLEEP_TIME",
|
||||
label="Queue Check Interval (seconds)",
|
||||
description="How often the download queue is checked for new items.",
|
||||
default=5,
|
||||
min_value=1,
|
||||
max_value=60,
|
||||
requires_restart=True,
|
||||
),
|
||||
NumberField(
|
||||
key="DOWNLOAD_PROGRESS_UPDATE_INTERVAL",
|
||||
label="Progress Update Interval (seconds)",
|
||||
description="How often download progress is broadcast to the UI.",
|
||||
default=1,
|
||||
min_value=1,
|
||||
max_value=10,
|
||||
requires_restart=True,
|
||||
),
|
||||
HeadingField(
|
||||
key="covers_cache_heading",
|
||||
title="Cover Image Cache",
|
||||
description="Cache book cover images locally for faster loading. Works for both Direct Download and Universal mode.",
|
||||
),
|
||||
CheckboxField(
|
||||
key="COVERS_CACHE_ENABLED",
|
||||
label="Enable Cover Cache",
|
||||
description="Cache book covers on the server for faster loading.",
|
||||
default=True,
|
||||
),
|
||||
NumberField(
|
||||
key="COVERS_CACHE_TTL",
|
||||
label="Cache TTL (days)",
|
||||
description="How long to keep cached covers. Set to 0 to keep forever (recommended for static artwork).",
|
||||
default=0,
|
||||
min_value=0,
|
||||
max_value=365,
|
||||
),
|
||||
NumberField(
|
||||
key="COVERS_CACHE_MAX_SIZE_MB",
|
||||
label="Max Cache Size (MB)",
|
||||
description="Maximum disk space for cached covers. Oldest images are removed when limit is reached.",
|
||||
default=500,
|
||||
min_value=50,
|
||||
max_value=5000,
|
||||
),
|
||||
ActionButton(
|
||||
key="clear_covers_cache",
|
||||
label="Clear Cover Cache",
|
||||
description="Delete all cached cover images.",
|
||||
style="danger",
|
||||
callback=_clear_covers_cache,
|
||||
),
|
||||
HeadingField(
|
||||
key="metadata_cache_heading",
|
||||
title="Metadata Cache",
|
||||
description="Cache book metadata from providers (Hardcover, Open Library) to reduce API calls and speed up repeated searches.",
|
||||
),
|
||||
CheckboxField(
|
||||
key="METADATA_CACHE_ENABLED",
|
||||
label="Enable Metadata Caching",
|
||||
description="When disabled, all metadata searches hit the provider API directly.",
|
||||
default=True,
|
||||
),
|
||||
NumberField(
|
||||
key="METADATA_CACHE_SEARCH_TTL",
|
||||
label="Search Results Cache (seconds)",
|
||||
description="How long to cache search results. Default: 300 (5 minutes). Max: 604800 (7 days).",
|
||||
default=300,
|
||||
min_value=60,
|
||||
max_value=604800,
|
||||
show_when={"field": "METADATA_CACHE_ENABLED", "value": True},
|
||||
),
|
||||
NumberField(
|
||||
key="METADATA_CACHE_BOOK_TTL",
|
||||
label="Book Details Cache (seconds)",
|
||||
description="How long to cache individual book details. Default: 600 (10 minutes). Max: 604800 (7 days).",
|
||||
default=600,
|
||||
min_value=60,
|
||||
max_value=604800,
|
||||
show_when={"field": "METADATA_CACHE_ENABLED", "value": True},
|
||||
),
|
||||
ActionButton(
|
||||
key="clear_metadata_cache",
|
||||
label="Clear Metadata Cache",
|
||||
description="Clear all cached search results and book details.",
|
||||
style="danger",
|
||||
callback=_clear_metadata_cache,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""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
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Thread-safe in-memory cache with TTL support."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
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
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CacheEntry:
|
||||
"""A cached value with expiration time."""
|
||||
value: Any
|
||||
expires_at: float
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
with self._lock:
|
||||
entry = self._cache.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
|
||||
if time.time() > entry.expires_at:
|
||||
del self._cache[key]
|
||||
return None
|
||||
|
||||
return entry.value
|
||||
|
||||
def set(self, key: str, value: Any, ttl: int) -> None:
|
||||
"""Cache value with TTL.
|
||||
|
||||
Args:
|
||||
key: Cache key.
|
||||
value: Value to cache.
|
||||
ttl: Time to live in seconds.
|
||||
"""
|
||||
with self._lock:
|
||||
# Evict oldest entries if at capacity
|
||||
if len(self._cache) >= self._max_size:
|
||||
self._evict_oldest()
|
||||
|
||||
self._cache[key] = CacheEntry(
|
||||
value=value,
|
||||
expires_at=time.time() + ttl
|
||||
)
|
||||
|
||||
def invalidate(self, key: str) -> bool:
|
||||
"""Remove specific cache entry.
|
||||
|
||||
Args:
|
||||
key: Cache key to remove.
|
||||
|
||||
Returns:
|
||||
True if entry was removed, False if not found.
|
||||
"""
|
||||
with self._lock:
|
||||
if key in self._cache:
|
||||
del self._cache[key]
|
||||
return True
|
||||
return False
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all cache entries."""
|
||||
with self._lock:
|
||||
self._cache.clear()
|
||||
|
||||
def cleanup_expired(self) -> int:
|
||||
"""Remove all expired entries.
|
||||
|
||||
Returns:
|
||||
Number of entries removed.
|
||||
"""
|
||||
with self._lock:
|
||||
now = time.time()
|
||||
expired_keys = [
|
||||
key for key, entry in self._cache.items()
|
||||
if entry.expires_at < now
|
||||
]
|
||||
for key in expired_keys:
|
||||
del self._cache[key]
|
||||
return len(expired_keys)
|
||||
|
||||
def _evict_oldest(self) -> None:
|
||||
"""Evict oldest entries (by expiration time) to make room.
|
||||
|
||||
Called with lock held.
|
||||
"""
|
||||
if not self._cache:
|
||||
return
|
||||
|
||||
# Remove ~10% of entries, oldest first
|
||||
entries_to_remove = max(1, len(self._cache) // 10)
|
||||
sorted_entries = sorted(
|
||||
self._cache.items(),
|
||||
key=lambda x: x[1].expires_at
|
||||
)
|
||||
|
||||
for key, _ in sorted_entries[:entries_to_remove]:
|
||||
del self._cache[key]
|
||||
|
||||
def stats(self) -> Dict[str, int]:
|
||||
"""Get cache statistics.
|
||||
|
||||
Returns:
|
||||
Dict with size and max_size.
|
||||
"""
|
||||
with self._lock:
|
||||
return {
|
||||
"size": len(self._cache),
|
||||
"max_size": self._max_size
|
||||
}
|
||||
|
||||
|
||||
# Global cache instance for metadata providers
|
||||
_metadata_cache = CacheService(max_size=1000)
|
||||
|
||||
|
||||
def get_metadata_cache() -> CacheService:
|
||||
"""Get the global metadata cache instance."""
|
||||
return _metadata_cache
|
||||
|
||||
|
||||
def cache_key(*args, **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.
|
||||
"""
|
||||
parts = [str(arg) for arg in args]
|
||||
parts.extend(f"{k}={v}" for k, v in sorted(kwargs.items()))
|
||||
return ":".join(parts)
|
||||
|
||||
|
||||
def cacheable(
|
||||
ttl: Optional[int] = None,
|
||||
ttl_key: Optional[str] = None,
|
||||
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
|
||||
"""
|
||||
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
|
||||
|
||||
if not config.get("METADATA_CACHE_ENABLED", True):
|
||||
# Caching disabled, execute function directly
|
||||
return func(*args, **kwargs)
|
||||
|
||||
# Determine TTL: static or from config
|
||||
if ttl is not None:
|
||||
effective_ttl = ttl
|
||||
elif ttl_key:
|
||||
effective_ttl = config.get(ttl_key, ttl_default)
|
||||
else:
|
||||
effective_ttl = ttl_default
|
||||
|
||||
# Generate cache key from function name and arguments
|
||||
# Skip 'self' argument if present (first arg of method)
|
||||
cache_args = args[1:] if args and hasattr(args[0], func.__name__) else args
|
||||
|
||||
key = cache_key(
|
||||
key_prefix or func.__name__,
|
||||
*cache_args,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
# Check cache
|
||||
cached = _metadata_cache.get(key)
|
||||
if cached is not None:
|
||||
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
|
||||
if result is not None:
|
||||
_metadata_cache.set(key, result, effective_ttl)
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Configuration singleton with ENV > config file > default resolution."""
|
||||
|
||||
from threading import Lock
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
# Import lazily to avoid circular imports
|
||||
_registry_module = None
|
||||
_env_module = None
|
||||
|
||||
|
||||
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
|
||||
_registry_module = settings_registry
|
||||
return _registry_module
|
||||
|
||||
|
||||
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
|
||||
_env_module = env
|
||||
return _env_module
|
||||
|
||||
|
||||
class Config:
|
||||
"""
|
||||
Dynamic configuration singleton that provides live settings access.
|
||||
|
||||
Settings are resolved with priority: ENV var > config file > default.
|
||||
Values are cached for performance and can be refreshed when settings change.
|
||||
"""
|
||||
|
||||
_instance: Optional['Config'] = None
|
||||
_lock = Lock()
|
||||
|
||||
def __new__(cls) -> 'Config':
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._initialized:
|
||||
return
|
||||
self._cache: Dict[str, Any] = {}
|
||||
self._field_map: Dict[str, tuple] = {} # key -> (field, tab_name)
|
||||
self._cache_lock = Lock()
|
||||
self._initialized = True
|
||||
self._loaded = False
|
||||
|
||||
def _ensure_loaded(self) -> None:
|
||||
"""Ensure settings are loaded from the registry."""
|
||||
if self._loaded:
|
||||
return
|
||||
with self._cache_lock:
|
||||
if self._loaded:
|
||||
return
|
||||
self._load_settings()
|
||||
|
||||
def _load_settings(self) -> None:
|
||||
"""Load all settings from the registry."""
|
||||
# Ensure all plugin settings are registered before loading
|
||||
# This handles cases where config is accessed before plugins are imported
|
||||
try:
|
||||
import cwa_book_downloader.release_sources # noqa: F401
|
||||
import cwa_book_downloader.metadata_providers # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
registry = _get_registry()
|
||||
|
||||
# On first load, sync ENV values to config files
|
||||
# This ensures ENV values persist even if ENV vars are later removed
|
||||
if not hasattr(self, '_env_synced'):
|
||||
registry.sync_env_to_config()
|
||||
self._env_synced = True
|
||||
|
||||
# Build field map from all registered tabs
|
||||
self._field_map.clear()
|
||||
self._cache.clear()
|
||||
|
||||
for tab in registry.get_all_settings_tabs():
|
||||
for field in tab.fields:
|
||||
# Skip action buttons and headings - they don't have values
|
||||
if isinstance(field, (registry.ActionButton, registry.HeadingField)):
|
||||
continue
|
||||
|
||||
key = field.key
|
||||
self._field_map[key] = (field, tab.name)
|
||||
|
||||
# Load current value
|
||||
value = registry.get_setting_value(field, tab.name)
|
||||
self._cache[key] = value
|
||||
|
||||
self._loaded = True
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""
|
||||
Refresh all cached settings from config files.
|
||||
|
||||
Call this after settings are updated via the UI to ensure
|
||||
the config singleton reflects the new values.
|
||||
"""
|
||||
with self._cache_lock:
|
||||
self._loaded = False
|
||||
self._load_settings()
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""
|
||||
Get a setting value by key.
|
||||
|
||||
Args:
|
||||
key: The setting key (e.g., 'MAX_RETRY')
|
||||
default: Default value if setting not found
|
||||
|
||||
Returns:
|
||||
The setting value, or default if not found
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
return self._cache.get(key, default)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
"""
|
||||
Allow attribute-style access to settings.
|
||||
|
||||
Example: config.MAX_RETRY instead of config.get('MAX_RETRY')
|
||||
"""
|
||||
# Avoid recursion for internal attributes
|
||||
if name.startswith('_'):
|
||||
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
|
||||
|
||||
self._ensure_loaded()
|
||||
|
||||
if name in self._cache:
|
||||
return self._cache[name]
|
||||
|
||||
# Fallback to env module for settings not in registry
|
||||
# This ensures backward compatibility during migration
|
||||
env = _get_env()
|
||||
if hasattr(env, name):
|
||||
return getattr(env, name)
|
||||
|
||||
raise AttributeError(f"Setting '{name}' not found in config or env")
|
||||
|
||||
def is_from_env(self, key: str) -> bool:
|
||||
"""
|
||||
Check if a setting's value comes from an environment variable.
|
||||
|
||||
Args:
|
||||
key: The setting key
|
||||
|
||||
Returns:
|
||||
True if the value is set via ENV var, False otherwise
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
|
||||
if key not in self._field_map:
|
||||
return False
|
||||
|
||||
field, _ = self._field_map[key]
|
||||
registry = _get_registry()
|
||||
return registry.is_value_from_env(field)
|
||||
|
||||
def get_all(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get all cached settings as a dictionary.
|
||||
|
||||
Returns:
|
||||
Dict of all setting keys to their current values
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
return dict(self._cache)
|
||||
|
||||
|
||||
# Global singleton instance
|
||||
config = Config()
|
||||
@@ -0,0 +1,575 @@
|
||||
"""Disk-based image cache with LRU eviction."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
from cwa_book_downloader.core.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
# Image type detection via magic bytes
|
||||
IMAGE_SIGNATURES = {
|
||||
b'\xff\xd8\xff': ('image/jpeg', 'jpg'),
|
||||
b'\x89PNG\r\n\x1a\n': ('image/png', 'png'),
|
||||
b'GIF87a': ('image/gif', 'gif'),
|
||||
b'GIF89a': ('image/gif', 'gif'),
|
||||
b'RIFF': ('image/webp', 'webp'), # WebP starts with RIFF
|
||||
}
|
||||
|
||||
# HTTP headers for image fetching
|
||||
FETCH_HEADERS = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/129.0.0.0 Safari/537.36',
|
||||
'Accept': 'image/webp,image/apng,image/*,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
}
|
||||
|
||||
# Maximum image size to fetch (5 MB)
|
||||
MAX_IMAGE_SIZE = 5 * 1024 * 1024
|
||||
|
||||
# Negative cache TTL (for failed fetches) - 1 hour
|
||||
NEGATIVE_CACHE_TTL = 3600
|
||||
|
||||
# Transient failure cache TTL (for timeouts/connection errors) - 60 seconds
|
||||
# Short enough to retry soon, long enough to prevent spam during one page view
|
||||
TRANSIENT_CACHE_TTL = 60
|
||||
|
||||
|
||||
def _detect_image_type(data: bytes) -> Optional[Tuple[str, str]]:
|
||||
"""Detect image type from magic bytes.
|
||||
|
||||
Args:
|
||||
data: Image data bytes
|
||||
|
||||
Returns:
|
||||
Tuple of (content_type, extension) or None if not recognized
|
||||
"""
|
||||
for signature, (content_type, ext) in IMAGE_SIGNATURES.items():
|
||||
if data.startswith(signature):
|
||||
return content_type, ext
|
||||
|
||||
# Special case for WebP - check for WEBP after RIFF
|
||||
if data.startswith(b'RIFF') and len(data) > 12 and data[8:12] == b'WEBP':
|
||||
return 'image/webp', 'webp'
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class ImageCacheService:
|
||||
"""Persistent image cache with LRU eviction and TTL support."""
|
||||
|
||||
def __init__(self, cache_dir: Path, max_size_mb: int = 500, ttl_seconds: int = 0):
|
||||
"""Initialize the image cache.
|
||||
|
||||
Args:
|
||||
cache_dir: Directory to store cached images
|
||||
max_size_mb: Maximum cache size in megabytes
|
||||
ttl_seconds: Time-to-live in seconds (0 = forever)
|
||||
"""
|
||||
self.cache_dir = cache_dir
|
||||
self.max_size_bytes = max_size_mb * 1024 * 1024
|
||||
self.ttl_seconds = ttl_seconds
|
||||
self.index_path = cache_dir / "cache_index.json"
|
||||
self._lock = threading.RLock()
|
||||
self._index: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# Stats tracking
|
||||
self._hits = 0
|
||||
self._misses = 0
|
||||
|
||||
# Ensure cache directory exists
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Load existing index and sync with files on disk (once at startup)
|
||||
self._load_index()
|
||||
self._sync_index_with_files()
|
||||
|
||||
def _load_index(self) -> None:
|
||||
"""Load cache index from disk."""
|
||||
try:
|
||||
if self.index_path.exists():
|
||||
with open(self.index_path, 'r') as f:
|
||||
self._index = json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
self._index = {}
|
||||
|
||||
def _sync_index_with_files(self) -> None:
|
||||
"""Sync cache index with actual files on disk.
|
||||
|
||||
- Adds entries for files that exist but aren't in index
|
||||
- Removes entries for files that no longer exist (non-negative only)
|
||||
- Preserves negative cache entries (they have no files)
|
||||
"""
|
||||
image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
|
||||
added_count = 0
|
||||
removed_count = 0
|
||||
|
||||
# Build set of files that exist on disk
|
||||
existing_files: Dict[str, Path] = {}
|
||||
for file_path in self.cache_dir.iterdir():
|
||||
if not file_path.is_file():
|
||||
continue
|
||||
if file_path.suffix.lower() not in image_extensions:
|
||||
continue
|
||||
existing_files[file_path.stem] = file_path
|
||||
|
||||
# Add files that aren't in the index
|
||||
for cache_id, file_path in existing_files.items():
|
||||
if cache_id in self._index:
|
||||
continue
|
||||
|
||||
ext = file_path.suffix.lstrip('.')
|
||||
stat = file_path.stat()
|
||||
|
||||
# Detect content type
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
header = f.read(16)
|
||||
detected = _detect_image_type(header)
|
||||
content_type = detected[0] if detected else f'image/{ext}'
|
||||
except IOError:
|
||||
content_type = f'image/{ext}'
|
||||
|
||||
self._index[cache_id] = {
|
||||
'ext': ext,
|
||||
'content_type': content_type,
|
||||
'size': stat.st_size,
|
||||
'cached_at': stat.st_mtime,
|
||||
'accessed_at': stat.st_mtime,
|
||||
}
|
||||
added_count += 1
|
||||
|
||||
# Remove index entries for missing files (skip negative cache entries)
|
||||
stale_entries = []
|
||||
for cache_id, entry in self._index.items():
|
||||
if entry.get('negative', False):
|
||||
continue # Negative entries don't have files
|
||||
if cache_id not in existing_files:
|
||||
stale_entries.append(cache_id)
|
||||
|
||||
for cache_id in stale_entries:
|
||||
del self._index[cache_id]
|
||||
removed_count += 1
|
||||
|
||||
if added_count > 0 or removed_count > 0:
|
||||
self._save_index()
|
||||
|
||||
def _save_index(self) -> None:
|
||||
"""Save cache index to disk."""
|
||||
try:
|
||||
# Write to temp file first, then rename for atomicity
|
||||
temp_path = self.index_path.with_suffix('.tmp')
|
||||
with open(temp_path, 'w') as f:
|
||||
json.dump(self._index, f)
|
||||
temp_path.rename(self.index_path)
|
||||
except IOError:
|
||||
pass
|
||||
|
||||
def _get_image_path(self, cache_id: str, ext: str) -> Path:
|
||||
"""Get the file path for a cached image."""
|
||||
return self.cache_dir / f"{cache_id}.{ext}"
|
||||
|
||||
def _is_expired(self, entry: Dict[str, Any]) -> bool:
|
||||
"""Check if a cache entry is expired."""
|
||||
if self.ttl_seconds == 0:
|
||||
return False
|
||||
|
||||
cached_at = entry.get('cached_at', 0)
|
||||
return (time.time() - cached_at) > self.ttl_seconds
|
||||
|
||||
def _is_negative_expired(self, entry: Dict[str, Any]) -> bool:
|
||||
"""Check if a negative cache entry is expired.
|
||||
|
||||
Transient failures (timeouts) expire after TRANSIENT_CACHE_TTL (60s).
|
||||
Permanent failures (404s) expire after NEGATIVE_CACHE_TTL (1 hour).
|
||||
"""
|
||||
if not entry.get('negative', False):
|
||||
return False
|
||||
|
||||
cached_at = entry.get('cached_at', 0)
|
||||
|
||||
# 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
|
||||
|
||||
def _calculate_total_size(self) -> int:
|
||||
"""Calculate total size of cached images."""
|
||||
return sum(entry.get('size', 0) for entry in self._index.values())
|
||||
|
||||
def _evict_if_needed(self, required_space: int = 0) -> None:
|
||||
"""Evict old entries if cache is over size limit.
|
||||
|
||||
Uses LRU eviction based on accessed_at timestamp.
|
||||
"""
|
||||
current_size = self._calculate_total_size()
|
||||
target_size = self.max_size_bytes - required_space
|
||||
|
||||
if current_size <= target_size:
|
||||
return
|
||||
|
||||
# Sort entries by accessed_at (oldest first)
|
||||
sorted_entries = sorted(
|
||||
self._index.items(),
|
||||
key=lambda x: x[1].get('accessed_at', 0)
|
||||
)
|
||||
|
||||
evicted_count = 0
|
||||
for cache_id, entry in sorted_entries:
|
||||
if current_size <= target_size:
|
||||
break
|
||||
|
||||
# Delete the image file
|
||||
ext = entry.get('ext', 'jpg')
|
||||
image_path = self._get_image_path(cache_id, ext)
|
||||
try:
|
||||
if image_path.exists():
|
||||
image_path.unlink()
|
||||
except IOError:
|
||||
pass
|
||||
|
||||
# Update tracking
|
||||
current_size -= entry.get('size', 0)
|
||||
del self._index[cache_id]
|
||||
evicted_count += 1
|
||||
|
||||
if evicted_count > 0:
|
||||
self._save_index()
|
||||
|
||||
def get(self, cache_id: str) -> Optional[Tuple[bytes, str]]:
|
||||
"""Get a cached image.
|
||||
|
||||
Args:
|
||||
cache_id: Cache key (book ID or composite key)
|
||||
|
||||
Returns:
|
||||
Tuple of (image_data, content_type) or None if not cached/expired
|
||||
"""
|
||||
with self._lock:
|
||||
entry = self._index.get(cache_id)
|
||||
|
||||
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
|
||||
|
||||
# Check for negative cache (failed fetch)
|
||||
if entry.get('negative', False):
|
||||
if self._is_negative_expired(entry):
|
||||
# Negative cache expired, allow retry
|
||||
del self._index[cache_id]
|
||||
self._save_index()
|
||||
self._misses += 1
|
||||
return None
|
||||
# Still in negative cache, return None (don't retry)
|
||||
return None
|
||||
|
||||
# Check for expired entry
|
||||
if self._is_expired(entry):
|
||||
# Remove expired entry
|
||||
ext = entry.get('ext', 'jpg')
|
||||
image_path = self._get_image_path(cache_id, ext)
|
||||
try:
|
||||
if image_path.exists():
|
||||
image_path.unlink()
|
||||
except IOError:
|
||||
pass
|
||||
del self._index[cache_id]
|
||||
self._save_index()
|
||||
self._misses += 1
|
||||
return None
|
||||
|
||||
# Try to read the cached image
|
||||
ext = entry.get('ext', 'jpg')
|
||||
content_type = entry.get('content_type', 'image/jpeg')
|
||||
image_path = self._get_image_path(cache_id, ext)
|
||||
|
||||
try:
|
||||
if not image_path.exists():
|
||||
# File missing, remove from index
|
||||
del self._index[cache_id]
|
||||
self._save_index()
|
||||
self._misses += 1
|
||||
return None
|
||||
|
||||
with open(image_path, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
# Update accessed time
|
||||
entry['accessed_at'] = time.time()
|
||||
self._save_index()
|
||||
|
||||
self._hits += 1
|
||||
return data, content_type
|
||||
|
||||
except IOError:
|
||||
self._misses += 1
|
||||
return None
|
||||
|
||||
def put(self, cache_id: str, data: bytes, content_type: str) -> bool:
|
||||
"""Store an image in the cache.
|
||||
|
||||
Args:
|
||||
cache_id: Cache key
|
||||
data: Image data bytes
|
||||
content_type: MIME type of the image
|
||||
|
||||
Returns:
|
||||
True if stored successfully
|
||||
"""
|
||||
with self._lock:
|
||||
# Detect image type for extension
|
||||
detected = _detect_image_type(data)
|
||||
if detected:
|
||||
content_type, ext = detected
|
||||
else:
|
||||
# Fall back to content-type header
|
||||
if 'jpeg' in content_type or 'jpg' in content_type:
|
||||
ext = 'jpg'
|
||||
elif 'png' in content_type:
|
||||
ext = 'png'
|
||||
elif 'gif' in content_type:
|
||||
ext = 'gif'
|
||||
elif 'webp' in content_type:
|
||||
ext = 'webp'
|
||||
else:
|
||||
ext = 'jpg' # Default
|
||||
|
||||
image_size = len(data)
|
||||
|
||||
# Evict if needed to make room
|
||||
self._evict_if_needed(image_size)
|
||||
|
||||
# Write image to disk
|
||||
image_path = self._get_image_path(cache_id, ext)
|
||||
try:
|
||||
with open(image_path, 'wb') as f:
|
||||
f.write(data)
|
||||
except IOError:
|
||||
return False
|
||||
|
||||
# Update index
|
||||
now = time.time()
|
||||
self._index[cache_id] = {
|
||||
'ext': ext,
|
||||
'content_type': content_type,
|
||||
'size': image_size,
|
||||
'cached_at': now,
|
||||
'accessed_at': now,
|
||||
'negative': False,
|
||||
}
|
||||
self._save_index()
|
||||
return True
|
||||
|
||||
def put_negative(self, cache_id: str, transient: bool = False) -> None:
|
||||
"""Store a negative cache entry (failed fetch).
|
||||
|
||||
Args:
|
||||
cache_id: Cache key
|
||||
transient: If True, uses shorter TTL (for timeouts/connection errors)
|
||||
"""
|
||||
with self._lock:
|
||||
self._index[cache_id] = {
|
||||
'negative': True,
|
||||
'transient': transient,
|
||||
'cached_at': time.time(),
|
||||
}
|
||||
self._save_index()
|
||||
|
||||
def delete(self, cache_id: str) -> bool:
|
||||
"""Delete a single cache entry.
|
||||
|
||||
Args:
|
||||
cache_id: Cache key
|
||||
|
||||
Returns:
|
||||
True if entry existed and was deleted
|
||||
"""
|
||||
with self._lock:
|
||||
entry = self._index.get(cache_id)
|
||||
if not entry:
|
||||
return False
|
||||
|
||||
# Delete file if it exists
|
||||
if not entry.get('negative', False):
|
||||
ext = entry.get('ext', 'jpg')
|
||||
image_path = self._get_image_path(cache_id, ext)
|
||||
try:
|
||||
if image_path.exists():
|
||||
image_path.unlink()
|
||||
except IOError:
|
||||
pass
|
||||
|
||||
del self._index[cache_id]
|
||||
self._save_index()
|
||||
return True
|
||||
|
||||
def clear(self) -> int:
|
||||
"""Clear all cached images.
|
||||
|
||||
Returns:
|
||||
Number of entries cleared
|
||||
"""
|
||||
with self._lock:
|
||||
count = len(self._index)
|
||||
|
||||
# Delete all image files
|
||||
for cache_id, entry in self._index.items():
|
||||
if not entry.get('negative', False):
|
||||
ext = entry.get('ext', 'jpg')
|
||||
image_path = self._get_image_path(cache_id, ext)
|
||||
try:
|
||||
if image_path.exists():
|
||||
image_path.unlink()
|
||||
except IOError:
|
||||
pass
|
||||
|
||||
# Clear index
|
||||
self._index = {}
|
||||
self._save_index()
|
||||
|
||||
# Reset stats
|
||||
self._hits = 0
|
||||
self._misses = 0
|
||||
|
||||
return count
|
||||
|
||||
def stats(self) -> Dict[str, Any]:
|
||||
"""Get cache statistics.
|
||||
|
||||
Returns:
|
||||
Dict with size, count, hit rate, etc.
|
||||
"""
|
||||
with self._lock:
|
||||
total_size = self._calculate_total_size()
|
||||
entry_count = len(self._index)
|
||||
negative_count = sum(1 for e in self._index.values() if e.get('negative', False))
|
||||
total_requests = self._hits + self._misses
|
||||
hit_rate = (self._hits / total_requests * 100) if total_requests > 0 else 0
|
||||
|
||||
return {
|
||||
'entry_count': entry_count,
|
||||
'negative_count': negative_count,
|
||||
'total_size_bytes': total_size,
|
||||
'total_size_mb': round(total_size / (1024 * 1024), 2),
|
||||
'max_size_mb': self.max_size_bytes / (1024 * 1024),
|
||||
'hits': self._hits,
|
||||
'misses': self._misses,
|
||||
'hit_rate': round(hit_rate, 1),
|
||||
}
|
||||
|
||||
def fetch_and_cache(self, cache_id: str, url: str) -> Optional[Tuple[bytes, str]]:
|
||||
"""Fetch an image from URL and cache it.
|
||||
|
||||
Args:
|
||||
cache_id: Cache key
|
||||
url: URL to fetch from
|
||||
|
||||
Returns:
|
||||
Tuple of (image_data, content_type) or None on failure
|
||||
"""
|
||||
try:
|
||||
|
||||
response = requests.get(
|
||||
url,
|
||||
timeout=(5, 10),
|
||||
headers=FETCH_HEADERS,
|
||||
stream=True,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Validate content type
|
||||
content_type = response.headers.get('content-type', '')
|
||||
if not content_type.startswith('image/'):
|
||||
self.put_negative(cache_id)
|
||||
return None
|
||||
|
||||
# Read with size limit
|
||||
data = BytesIO()
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
data.write(chunk)
|
||||
if data.tell() > MAX_IMAGE_SIZE:
|
||||
self.put_negative(cache_id)
|
||||
return None
|
||||
|
||||
image_data = data.getvalue()
|
||||
|
||||
if not image_data:
|
||||
self.put_negative(cache_id)
|
||||
return None
|
||||
|
||||
# Store in cache
|
||||
if self.put(cache_id, image_data, content_type):
|
||||
# Get the actual content type from detection
|
||||
detected = _detect_image_type(image_data)
|
||||
if detected:
|
||||
content_type = detected[0]
|
||||
return image_data, content_type
|
||||
|
||||
return None
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
self.put_negative(cache_id, transient=True)
|
||||
return None
|
||||
except requests.exceptions.ConnectionError:
|
||||
self.put_negative(cache_id, transient=True)
|
||||
return None
|
||||
except requests.exceptions.HTTPError as e:
|
||||
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)
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# Singleton instance (initialized lazily when config is available)
|
||||
_instance: Optional[ImageCacheService] = None
|
||||
_instance_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_image_cache() -> ImageCacheService:
|
||||
"""Get the singleton image cache instance.
|
||||
|
||||
Lazily initializes using config values.
|
||||
"""
|
||||
global _instance
|
||||
|
||||
if _instance is None:
|
||||
with _instance_lock:
|
||||
if _instance is None:
|
||||
from cwa_book_downloader.core.config import config
|
||||
from cwa_book_downloader.config.env import CONFIG_DIR
|
||||
|
||||
cache_dir = CONFIG_DIR / "covers"
|
||||
max_size_mb = config.get("COVERS_CACHE_MAX_SIZE_MB", 500)
|
||||
ttl_days = config.get("COVERS_CACHE_TTL", 0)
|
||||
ttl_seconds = ttl_days * 86400 if ttl_days > 0 else 0
|
||||
|
||||
_instance = ImageCacheService(
|
||||
cache_dir=cache_dir,
|
||||
max_size_mb=max_size_mb,
|
||||
ttl_seconds=ttl_seconds,
|
||||
)
|
||||
logger.info(f"Initialized image cache: {cache_dir} (max {max_size_mb}MB, TTL {ttl_days} days)")
|
||||
|
||||
return _instance
|
||||
|
||||
|
||||
def reset_image_cache() -> None:
|
||||
"""Reset the singleton instance (for testing or config changes)."""
|
||||
global _instance
|
||||
with _instance_lock:
|
||||
_instance = None
|
||||
@@ -1,35 +1,43 @@
|
||||
"""Centralized logging configuration for the book downloader application."""
|
||||
"""Logging configuration and custom logger with error tracing."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from env import LOG_FILE, ENABLE_LOGGING, LOG_LEVEL
|
||||
from typing import Any
|
||||
|
||||
from cwa_book_downloader.config.env import LOG_FILE, ENABLE_LOGGING, LOG_LEVEL
|
||||
|
||||
|
||||
class CustomLogger(logging.Logger):
|
||||
"""Custom logger class with additional error_trace method."""
|
||||
|
||||
|
||||
def error_trace(self, msg: Any, *args: Any, **kwargs: Any) -> None:
|
||||
"""Log an error message with full stack trace."""
|
||||
self.log_resource_usage()
|
||||
kwargs.pop('exc_info', None)
|
||||
self.error(msg, *args, exc_info=True, **kwargs)
|
||||
|
||||
def warning_trace(self, msg: Any, *args: Any, **kwargs: Any) -> None:
|
||||
"""Log a warning message with full stack trace."""
|
||||
self.log_resource_usage()
|
||||
kwargs.pop('exc_info', None)
|
||||
self.warning(msg, *args, exc_info=True, **kwargs)
|
||||
|
||||
|
||||
def info_trace(self, msg: Any, *args: Any, **kwargs: Any) -> None:
|
||||
"""Log an info message with full stack trace."""
|
||||
self.log_resource_usage()
|
||||
self.info(msg, *args, exc_info=True, **kwargs)
|
||||
|
||||
"""Log an info message (stack trace only if exception active)."""
|
||||
kwargs.pop('exc_info', None)
|
||||
# Only include exc_info if there's actually an exception
|
||||
has_exception = sys.exc_info()[0] is not None
|
||||
self.info(msg, *args, exc_info=has_exception, **kwargs)
|
||||
|
||||
def debug_trace(self, msg: Any, *args: Any, **kwargs: Any) -> None:
|
||||
"""Log a debug message with full stack trace."""
|
||||
self.log_resource_usage()
|
||||
self.debug(msg, *args, exc_info=True, **kwargs)
|
||||
|
||||
"""Log a debug message (stack trace only if exception active)."""
|
||||
kwargs.pop('exc_info', None)
|
||||
# Only include exc_info if there's actually an exception
|
||||
has_exception = sys.exc_info()[0] is not None
|
||||
self.debug(msg, *args, exc_info=has_exception, **kwargs)
|
||||
|
||||
def log_resource_usage(self):
|
||||
import psutil
|
||||
memory = psutil.virtual_memory()
|
||||
@@ -41,17 +49,17 @@ class CustomLogger(logging.Logger):
|
||||
|
||||
def setup_logger(name: str, log_file: Path = LOG_FILE) -> CustomLogger:
|
||||
"""Set up and configure a logger instance.
|
||||
|
||||
|
||||
Args:
|
||||
name: The name of the logger instance
|
||||
log_file: Optional path to log file. If None, logs only to stdout/stderr
|
||||
|
||||
|
||||
Returns:
|
||||
CustomLogger: Configured logger instance with error_trace method
|
||||
"""
|
||||
# Register our custom logger class
|
||||
logging.setLoggerClass(CustomLogger)
|
||||
|
||||
|
||||
# Create logger as CustomLogger instance
|
||||
logger = CustomLogger(name)
|
||||
log_level = logging.INFO
|
||||
@@ -66,7 +74,7 @@ def setup_logger(name: str, log_file: Path = LOG_FILE) -> CustomLogger:
|
||||
elif LOG_LEVEL == "CRITICAL":
|
||||
log_level = logging.CRITICAL
|
||||
logger.setLevel(log_level)
|
||||
|
||||
|
||||
formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s'
|
||||
)
|
||||
@@ -77,13 +85,13 @@ def setup_logger(name: str, log_file: Path = LOG_FILE) -> CustomLogger:
|
||||
console_handler.setLevel(log_level)
|
||||
console_handler.addFilter(lambda record: record.levelno < logging.ERROR) # Only allow logs below ERROR to stdout
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
|
||||
# Error handler for stderr
|
||||
error_handler = logging.StreamHandler(sys.stderr)
|
||||
error_handler.setLevel(logging.ERROR) # Error and above go to stderr
|
||||
error_handler.setFormatter(formatter)
|
||||
logger.addHandler(error_handler)
|
||||
|
||||
|
||||
# File handler if log file is specified
|
||||
try:
|
||||
if ENABLE_LOGGING:
|
||||
@@ -101,4 +109,3 @@ def setup_logger(name: str, log_file: Path = LOG_FILE) -> CustomLogger:
|
||||
logger.error_trace(f"Failed to create log file: {e}", exc_info=True)
|
||||
|
||||
return logger
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Data structures and models used across the application."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from enum import Enum
|
||||
import re
|
||||
import time
|
||||
|
||||
|
||||
def build_filename(
|
||||
title: str,
|
||||
author: Optional[str] = None,
|
||||
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)
|
||||
parts.append(" - ")
|
||||
parts.append(title)
|
||||
if year:
|
||||
parts.append(f" ({year})")
|
||||
|
||||
filename = "".join(parts)
|
||||
filename = re.sub(r'[\\/:*?"<>|]', '_', filename.strip())[:245]
|
||||
|
||||
if fmt:
|
||||
filename = f"{filename}.{fmt}"
|
||||
|
||||
return filename
|
||||
|
||||
|
||||
class QueueStatus(str, Enum):
|
||||
"""Enum for possible book queue statuses."""
|
||||
QUEUED = "queued"
|
||||
RESOLVING = "resolving"
|
||||
DOWNLOADING = "downloading"
|
||||
COMPLETE = "complete"
|
||||
AVAILABLE = "available"
|
||||
ERROR = "error"
|
||||
DONE = "done"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueueItem:
|
||||
"""Queue item with priority and metadata."""
|
||||
book_id: str
|
||||
priority: int
|
||||
added_time: float
|
||||
|
||||
def __lt__(self, other):
|
||||
"""Compare items for priority queue (lower priority number = higher precedence)."""
|
||||
if self.priority != other.priority:
|
||||
return self.priority < other.priority
|
||||
return self.added_time < other.added_time
|
||||
|
||||
|
||||
@dataclass
|
||||
class 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
|
||||
format: Optional[str] = None
|
||||
size: Optional[str] = None
|
||||
preview: Optional[str] = None
|
||||
content_type: Optional[str] = None # "book (fiction)", "audiobook", "magazine", etc.
|
||||
|
||||
# Runtime state
|
||||
priority: int = 0
|
||||
added_time: float = field(default_factory=time.time)
|
||||
progress: float = 0.0
|
||||
status: QueueStatus = QueueStatus.QUEUED
|
||||
status_message: Optional[str] = None
|
||||
download_path: Optional[str] = None
|
||||
|
||||
def __lt__(self, other):
|
||||
"""Compare tasks for priority queue (lower priority number = higher precedence)."""
|
||||
if self.priority != other.priority:
|
||||
return self.priority < other.priority
|
||||
return self.added_time < other.added_time
|
||||
|
||||
def get_filename(self) -> str:
|
||||
"""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)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BookInfo:
|
||||
"""Data class representing book information."""
|
||||
id: str
|
||||
title: str
|
||||
preview: Optional[str] = None
|
||||
author: Optional[str] = None
|
||||
publisher: Optional[str] = None
|
||||
year: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
format: Optional[str] = None
|
||||
size: Optional[str] = None
|
||||
info: Optional[Dict[str, List[str]]] = None
|
||||
description: Optional[str] = None
|
||||
download_urls: List[str] = field(default_factory=list)
|
||||
download_path: Optional[str] = None
|
||||
priority: int = 0
|
||||
progress: Optional[float] = None
|
||||
status_message: Optional[str] = None # Detailed status message for UI display
|
||||
added_time: Optional[float] = None # Timestamp when added to queue
|
||||
source: str = "direct_download" # Release source handler to use for downloads
|
||||
|
||||
def get_filename(self, fallback_url: Optional[str] = None) -> str:
|
||||
"""Build sanitized filename: 'Author - Title (Year).format'
|
||||
|
||||
Resolves format from self.format, download_urls, or fallback_url.
|
||||
|
||||
Args:
|
||||
fallback_url: URL to extract format from if not already known
|
||||
|
||||
Returns:
|
||||
Sanitized filename safe for filesystem use
|
||||
"""
|
||||
# Resolve format if needed
|
||||
if not self.format:
|
||||
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
|
||||
|
||||
return build_filename(self.title, self.author, self.year, self.format)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchFilters:
|
||||
"""Filters for book search queries."""
|
||||
isbn: Optional[List[str]] = None
|
||||
author: Optional[List[str]] = None
|
||||
title: Optional[List[str]] = None
|
||||
lang: Optional[List[str]] = None
|
||||
sort: Optional[str] = None
|
||||
content: Optional[List[str]] = None
|
||||
format: Optional[List[str]] = None
|
||||
@@ -0,0 +1,365 @@
|
||||
"""Thread-safe download queue manager with priority support and cancellation."""
|
||||
|
||||
import queue
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from threading import Lock, Event
|
||||
from typing import Dict, List, Optional, Tuple, Any
|
||||
|
||||
from cwa_book_downloader.core.config import config as app_config
|
||||
from cwa_book_downloader.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.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._queue: queue.PriorityQueue[QueueItem] = queue.PriorityQueue()
|
||||
self._lock = Lock()
|
||||
self._status: dict[str, QueueStatus] = {}
|
||||
self._task_data: dict[str, DownloadTask] = {}
|
||||
self._status_timestamps: dict[str, datetime] = {} # Track when each status was last updated
|
||||
self._cancel_flags: dict[str, Event] = {} # Cancellation flags for active downloads
|
||||
self._active_downloads: dict[str, bool] = {} # Track currently downloading tasks
|
||||
|
||||
@property
|
||||
def _status_timeout(self) -> timedelta:
|
||||
"""Get status timeout from config (allows live updates)."""
|
||||
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
|
||||
"""
|
||||
with self._lock:
|
||||
task_id = task.task_id
|
||||
|
||||
# Don't add if already exists and not in error/done state
|
||||
if task_id in self._status and self._status[task_id] not in [QueueStatus.ERROR, QueueStatus.DONE, QueueStatus.CANCELLED]:
|
||||
return False
|
||||
|
||||
# Ensure added_time is set
|
||||
if task.added_time == 0:
|
||||
task.added_time = time.time()
|
||||
|
||||
queue_item = QueueItem(task_id, task.priority, task.added_time)
|
||||
self._queue.put(queue_item)
|
||||
self._task_data[task_id] = task
|
||||
self._update_status(task_id, QueueStatus.QUEUED)
|
||||
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
|
||||
"""
|
||||
# Use iterative approach to avoid stack overflow if many items are cancelled
|
||||
while True:
|
||||
try:
|
||||
queue_item = self._queue.get_nowait()
|
||||
task_id = queue_item.book_id # QueueItem uses book_id as the ID field
|
||||
|
||||
with self._lock:
|
||||
# Check if task was cancelled while in queue
|
||||
if task_id in self._status and self._status[task_id] == QueueStatus.CANCELLED:
|
||||
continue # Skip cancelled items, try next
|
||||
|
||||
# Create cancellation flag for this download
|
||||
cancel_flag = Event()
|
||||
self._cancel_flags[task_id] = cancel_flag
|
||||
self._active_downloads[task_id] = True
|
||||
|
||||
return task_id, cancel_flag
|
||||
except queue.Empty:
|
||||
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
|
||||
"""
|
||||
with self._lock:
|
||||
return self._task_data.get(task_id)
|
||||
|
||||
def _update_status(self, book_id: str, status: QueueStatus) -> None:
|
||||
"""Internal method to update status and timestamp."""
|
||||
self._status[book_id] = status
|
||||
self._status_timestamps[book_id] = datetime.now()
|
||||
|
||||
def update_status(self, book_id: str, status: QueueStatus) -> None:
|
||||
"""Update status of a book in the queue."""
|
||||
with self._lock:
|
||||
self._update_status(book_id, status)
|
||||
|
||||
# Clean up active download tracking when finished
|
||||
if status in [QueueStatus.COMPLETE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.DONE, QueueStatus.CANCELLED]:
|
||||
self._active_downloads.pop(book_id, None)
|
||||
self._cancel_flags.pop(book_id, None)
|
||||
|
||||
def update_download_path(self, task_id: str, download_path: str) -> None:
|
||||
"""Update the download path of a task in the queue."""
|
||||
with self._lock:
|
||||
if task_id in self._task_data:
|
||||
self._task_data[task_id].download_path = download_path
|
||||
|
||||
def update_progress(self, task_id: str, progress: float) -> None:
|
||||
"""Update download progress for a task."""
|
||||
with self._lock:
|
||||
if task_id in self._task_data:
|
||||
self._task_data[task_id].progress = progress
|
||||
|
||||
def update_status_message(self, task_id: str, message: str) -> None:
|
||||
"""Update detailed status message for a task."""
|
||||
with self._lock:
|
||||
if task_id in self._task_data:
|
||||
self._task_data[task_id].status_message = message
|
||||
|
||||
def get_status(self) -> Dict[QueueStatus, Dict[str, DownloadTask]]:
|
||||
"""Get current queue status grouped by status."""
|
||||
self.refresh()
|
||||
with self._lock:
|
||||
result: Dict[QueueStatus, Dict[str, DownloadTask]] = {status: {} for status in QueueStatus}
|
||||
for task_id, status in self._status.items():
|
||||
if task_id in self._task_data:
|
||||
result[status][task_id] = self._task_data[task_id]
|
||||
return result
|
||||
|
||||
def get_queue_order(self) -> List[Dict[str, Any]]:
|
||||
"""Get current queue order for display."""
|
||||
with self._lock:
|
||||
queue_items = []
|
||||
|
||||
# Get items from priority queue without removing them
|
||||
temp_items = []
|
||||
while not self._queue.empty():
|
||||
try:
|
||||
item = self._queue.get_nowait()
|
||||
temp_items.append(item)
|
||||
task_id = item.book_id # QueueItem uses book_id as the ID field
|
||||
if task_id in self._task_data:
|
||||
task = self._task_data[task_id]
|
||||
queue_items.append({
|
||||
'id': task_id,
|
||||
'title': task.title,
|
||||
'author': task.author,
|
||||
'priority': item.priority,
|
||||
'added_time': item.added_time,
|
||||
'status': self._status.get(task_id, QueueStatus.QUEUED)
|
||||
})
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Put items back in queue
|
||||
for item in temp_items:
|
||||
self._queue.put(item)
|
||||
|
||||
return sorted(queue_items, key=lambda x: (x['priority'], x['added_time']))
|
||||
|
||||
def cancel_download(self, 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
|
||||
"""
|
||||
with self._lock:
|
||||
current_status = self._status.get(task_id)
|
||||
|
||||
# Allow cancellation during any active state
|
||||
if current_status in [QueueStatus.RESOLVING, QueueStatus.DOWNLOADING]:
|
||||
# Signal active download to stop
|
||||
if task_id in self._cancel_flags:
|
||||
self._cancel_flags[task_id].set()
|
||||
self._update_status(task_id, QueueStatus.CANCELLED)
|
||||
return True
|
||||
elif current_status == QueueStatus.QUEUED:
|
||||
# Remove from queue and mark as cancelled
|
||||
self._update_status(task_id, QueueStatus.CANCELLED)
|
||||
return True
|
||||
elif current_status in [QueueStatus.COMPLETE, QueueStatus.DONE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.CANCELLED]:
|
||||
# Clear completed/errored/cancelled items from tracking
|
||||
self._status.pop(task_id, None)
|
||||
self._status_timestamps.pop(task_id, None)
|
||||
self._task_data.pop(task_id, None)
|
||||
self._cancel_flags.pop(task_id, None)
|
||||
self._active_downloads.pop(task_id, None)
|
||||
return True
|
||||
|
||||
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
|
||||
"""
|
||||
with self._lock:
|
||||
if task_id not in self._status or self._status[task_id] != QueueStatus.QUEUED:
|
||||
return False
|
||||
|
||||
# Remove task from queue and re-add with new priority
|
||||
temp_items = []
|
||||
found = False
|
||||
|
||||
while not self._queue.empty():
|
||||
try:
|
||||
item = self._queue.get_nowait()
|
||||
if item.book_id == task_id: # QueueItem uses book_id as the ID field
|
||||
# Create new item with updated priority
|
||||
new_item = QueueItem(task_id, new_priority, item.added_time)
|
||||
temp_items.append(new_item)
|
||||
found = True
|
||||
# Update task data priority
|
||||
if task_id in self._task_data:
|
||||
self._task_data[task_id].priority = new_priority
|
||||
else:
|
||||
temp_items.append(item)
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Put all items back
|
||||
for item in temp_items:
|
||||
self._queue.put(item)
|
||||
|
||||
return found
|
||||
|
||||
def reorder_queue(self, 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
|
||||
"""
|
||||
with self._lock:
|
||||
# Extract all items from queue
|
||||
all_items = []
|
||||
while not self._queue.empty():
|
||||
try:
|
||||
item = self._queue.get_nowait()
|
||||
task_id = item.book_id # QueueItem uses book_id as the ID field
|
||||
# Update priority if specified
|
||||
if task_id in task_priorities:
|
||||
new_priority = task_priorities[task_id]
|
||||
item = QueueItem(task_id, new_priority, item.added_time)
|
||||
# Update task data priority
|
||||
if task_id in self._task_data:
|
||||
self._task_data[task_id].priority = new_priority
|
||||
all_items.append(item)
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Put all items back with updated priorities
|
||||
for item in all_items:
|
||||
self._queue.put(item)
|
||||
|
||||
return True
|
||||
|
||||
def get_active_downloads(self) -> List[str]:
|
||||
"""Get list of currently active download task IDs."""
|
||||
with self._lock:
|
||||
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
|
||||
"""
|
||||
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
|
||||
|
||||
def clear_completed(self) -> int:
|
||||
"""Remove all completed, errored, or cancelled tasks from tracking.
|
||||
|
||||
Returns:
|
||||
int: Number of tasks removed
|
||||
"""
|
||||
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)
|
||||
|
||||
removed_count = len(to_remove)
|
||||
for task_id in to_remove:
|
||||
self._status.pop(task_id, None)
|
||||
self._status_timestamps.pop(task_id, None)
|
||||
self._task_data.pop(task_id, None)
|
||||
self._cancel_flags.pop(task_id, None)
|
||||
self._active_downloads.pop(task_id, None)
|
||||
|
||||
return removed_count
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Remove any tasks that are done downloading or have stale status."""
|
||||
with self._lock:
|
||||
current_time = datetime.now()
|
||||
|
||||
# Create a list of items to remove to avoid modifying dict during iteration
|
||||
to_remove = []
|
||||
|
||||
for task_id, status in self._status.items():
|
||||
task = self._task_data.get(task_id)
|
||||
if not task:
|
||||
continue
|
||||
|
||||
path = task.download_path
|
||||
if path and not Path(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)
|
||||
|
||||
# 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]:
|
||||
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]
|
||||
|
||||
# Global instance of BookQueue
|
||||
book_queue = BookQueue()
|
||||
@@ -0,0 +1,784 @@
|
||||
"""Plugin settings registry with config file persistence."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass, field, asdict
|
||||
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
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FieldBase:
|
||||
"""Base class for all settings fields."""
|
||||
key: str # Environment variable / config key
|
||||
label: str # Display label in UI
|
||||
description: str = "" # Help text
|
||||
default: Any = None # Default value if not set
|
||||
required: bool = False # Whether field must have a value
|
||||
env_var: Optional[str] = None # Override env var name (defaults to key)
|
||||
env_supported: bool = True # Whether this setting can be set via ENV var (False = UI-only)
|
||||
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"}
|
||||
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
|
||||
|
||||
def get_env_var_name(self) -> str:
|
||||
"""Get the environment variable name for this field."""
|
||||
return self.env_var or self.key
|
||||
|
||||
def get_field_type(self) -> str:
|
||||
"""Get the field type name for serialization."""
|
||||
return self.__class__.__name__
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextField(FieldBase):
|
||||
"""Single-line text input."""
|
||||
placeholder: str = ""
|
||||
max_length: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PasswordField(FieldBase):
|
||||
"""Password input (masked in UI, not returned in API responses)."""
|
||||
placeholder: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class NumberField(FieldBase):
|
||||
"""Numeric input."""
|
||||
min_value: Optional[float] = None
|
||||
max_value: Optional[float] = None
|
||||
step: float = 1
|
||||
default: float = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckboxField(FieldBase):
|
||||
"""Boolean checkbox."""
|
||||
default: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class SelectField(FieldBase):
|
||||
"""Single-choice dropdown."""
|
||||
# Options can be a list or a callable that returns a list (for lazy evaluation)
|
||||
options: Any = field(default_factory=list) # [{value: "", label: ""}] or callable
|
||||
|
||||
|
||||
@dataclass
|
||||
class MultiSelectField(FieldBase):
|
||||
"""Multiple-choice selection."""
|
||||
# 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)
|
||||
|
||||
|
||||
@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?}
|
||||
options: Any = field(default_factory=list)
|
||||
# Default value: [{id, enabled}, ...] in priority order
|
||||
default: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
@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
|
||||
style: str = "default" # "default", "primary", "danger"
|
||||
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"}
|
||||
disabled_when: Optional[Dict[str, Any]] = None # Conditional disable: {"field": "key", "value": "expected", "reason": "..."}
|
||||
|
||||
def get_field_type(self) -> str:
|
||||
return "ActionButton"
|
||||
|
||||
|
||||
@dataclass
|
||||
class HeadingField:
|
||||
"""
|
||||
Display-only heading with title and description.
|
||||
|
||||
Used to add section titles and descriptive text to settings pages.
|
||||
Not an input field - purely for display.
|
||||
"""
|
||||
key: str # Unique identifier
|
||||
title: str # Heading title
|
||||
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"}
|
||||
|
||||
def get_field_type(self) -> str:
|
||||
return "HeadingField"
|
||||
|
||||
|
||||
# Type alias for all field types
|
||||
SettingsField = Union[TextField, PasswordField, NumberField, CheckboxField, SelectField, MultiSelectField, OrderableListField, ActionButton, HeadingField]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SettingsTab:
|
||||
"""A tab/section in the settings UI."""
|
||||
name: str # Internal name (used in URLs)
|
||||
display_name: str # Display name in UI
|
||||
fields: List[SettingsField] = field(default_factory=list)
|
||||
icon: Optional[str] = None # Icon name for UI
|
||||
order: int = 100 # Sort order (lower = earlier)
|
||||
group: Optional[str] = None # Group name this tab belongs to
|
||||
|
||||
|
||||
@dataclass
|
||||
class SettingsGroup:
|
||||
"""A collapsible group of settings tabs in the UI."""
|
||||
name: str # Internal name
|
||||
display_name: str # Display name in UI
|
||||
icon: Optional[str] = None # Icon name for UI
|
||||
order: int = 100 # Sort order (lower = earlier)
|
||||
|
||||
|
||||
_SETTINGS_REGISTRY: Dict[str, SettingsTab] = {}
|
||||
_GROUPS_REGISTRY: Dict[str, SettingsGroup] = {}
|
||||
_ON_SAVE_HANDLERS: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {}
|
||||
_REGISTRY_LOCK = Lock()
|
||||
|
||||
|
||||
def register_group(
|
||||
name: str,
|
||||
display_name: str,
|
||||
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,
|
||||
display_name=display_name,
|
||||
icon=icon,
|
||||
order=order,
|
||||
)
|
||||
_GROUPS_REGISTRY[name] = group
|
||||
logger.debug(f"Registered settings group: {name}")
|
||||
|
||||
|
||||
def register_settings(
|
||||
name: str,
|
||||
display_name: str,
|
||||
icon: Optional[str] = None,
|
||||
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()
|
||||
tab = SettingsTab(
|
||||
name=name,
|
||||
display_name=display_name,
|
||||
fields=fields,
|
||||
icon=icon,
|
||||
order=order,
|
||||
group=group,
|
||||
)
|
||||
_SETTINGS_REGISTRY[name] = tab
|
||||
logger.debug(f"Registered settings tab: {name} ({len(fields)} fields)" +
|
||||
(f" in group {group}" if group else ""))
|
||||
return func
|
||||
return decorator
|
||||
|
||||
|
||||
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}")
|
||||
|
||||
|
||||
def get_on_save_handler(tab_name: str) -> Optional[Callable[[Dict[str, Any]], Dict[str, Any]]]:
|
||||
"""Get the on_save handler for a settings tab, if any."""
|
||||
return _ON_SAVE_HANDLERS.get(tab_name)
|
||||
|
||||
|
||||
def get_settings_tab(name: str) -> Optional[SettingsTab]:
|
||||
"""Get a specific settings tab by name."""
|
||||
return _SETTINGS_REGISTRY.get(name)
|
||||
|
||||
|
||||
def get_all_settings_tabs() -> List[SettingsTab]:
|
||||
"""Get all registered settings tabs, sorted by order."""
|
||||
return sorted(_SETTINGS_REGISTRY.values(), key=lambda t: (t.order, t.name))
|
||||
|
||||
|
||||
def list_registered_settings() -> List[str]:
|
||||
"""List all registered settings tab names."""
|
||||
return list(_SETTINGS_REGISTRY.keys())
|
||||
|
||||
|
||||
def _get_config_dir() -> Path:
|
||||
"""Get the config directory path."""
|
||||
from cwa_book_downloader.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":
|
||||
return config_dir / "settings.json"
|
||||
else:
|
||||
plugins_dir = config_dir / "plugins"
|
||||
return plugins_dir / f"{tab_name}.json"
|
||||
|
||||
|
||||
def _ensure_config_dir(tab_name: str) -> None:
|
||||
"""Ensure the config directory exists."""
|
||||
config_path = _get_config_file_path(tab_name)
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
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():
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(config_path, 'r') as f:
|
||||
return json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Invalid JSON in config file {config_path}: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# Load existing config and merge
|
||||
existing = load_config_file(tab_name)
|
||||
existing.update(values)
|
||||
|
||||
with open(config_path, 'w') as f:
|
||||
json.dump(existing, f, indent=2)
|
||||
|
||||
logger.info(f"Saved settings to {config_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving config file for {tab_name}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
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 = {}
|
||||
|
||||
for field in tab.fields:
|
||||
# Skip non-value fields
|
||||
if isinstance(field, (ActionButton, HeadingField)):
|
||||
continue
|
||||
|
||||
# Skip fields that don't support ENV vars
|
||||
if not getattr(field, 'env_supported', True):
|
||||
continue
|
||||
|
||||
# Check if ENV var is set
|
||||
env_var_name = field.get_env_var_name()
|
||||
env_value = os.environ.get(env_var_name)
|
||||
|
||||
if env_value is not None:
|
||||
# Parse the ENV value to the appropriate type
|
||||
parsed_value = _parse_env_value(env_value, field)
|
||||
values_to_sync[field.key] = parsed_value
|
||||
|
||||
# Save synced values to config file (merge with existing)
|
||||
if values_to_sync:
|
||||
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())}")
|
||||
|
||||
|
||||
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
|
||||
|
||||
# 1. Check environment variable (if supported for this field)
|
||||
if field.env_supported:
|
||||
env_var_name = field.get_env_var_name()
|
||||
env_value = os.environ.get(env_var_name)
|
||||
if env_value is not None:
|
||||
return _parse_env_value(env_value, field)
|
||||
|
||||
# 2. Check config file
|
||||
config = load_config_file(tab_name)
|
||||
if field.key in config:
|
||||
return config[field.key]
|
||||
|
||||
# 3. Return default
|
||||
return field.default
|
||||
|
||||
|
||||
def _parse_env_value(value: str, field: SettingsField) -> Any:
|
||||
"""Parse an environment variable value to the appropriate type."""
|
||||
if isinstance(field, CheckboxField):
|
||||
return value.lower() in ('true', '1', 'yes', 'on')
|
||||
elif isinstance(field, NumberField):
|
||||
try:
|
||||
if '.' in value:
|
||||
return float(value)
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return field.default
|
||||
elif isinstance(field, MultiSelectField):
|
||||
return [v.strip() for v in value.split(',') if v.strip()]
|
||||
elif isinstance(field, OrderableListField):
|
||||
# Parse JSON array: [{"id": "...", "enabled": true}, ...]
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Invalid JSON for {field.key}, using default")
|
||||
return field.default
|
||||
else:
|
||||
return value
|
||||
|
||||
|
||||
def is_value_from_env(field: SettingsField) -> bool:
|
||||
"""Check if a field's value comes from an environment variable."""
|
||||
if isinstance(field, (ActionButton, HeadingField)):
|
||||
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:
|
||||
return False
|
||||
env_var_name = field.get_env_var_name()
|
||||
return env_var_name in os.environ
|
||||
|
||||
|
||||
def serialize_field(field: SettingsField, tab_name: str, include_value: bool = True) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize a field for API response.
|
||||
|
||||
Args:
|
||||
field: The settings field.
|
||||
tab_name: The settings tab name.
|
||||
include_value: Whether to include the current value.
|
||||
|
||||
Returns:
|
||||
Dict representation of the field.
|
||||
"""
|
||||
# HeadingField has a different structure - handle separately
|
||||
if isinstance(field, HeadingField):
|
||||
result = {
|
||||
"key": field.key,
|
||||
"type": field.get_field_type(),
|
||||
"title": field.title,
|
||||
"description": field.description,
|
||||
}
|
||||
if field.link_url:
|
||||
result["linkUrl"] = field.link_url
|
||||
result["linkText"] = field.link_text or field.link_url
|
||||
if field.show_when:
|
||||
result["showWhen"] = field.show_when
|
||||
return result
|
||||
|
||||
result = {
|
||||
"key": field.key,
|
||||
"label": field.label,
|
||||
"type": field.get_field_type(),
|
||||
"description": getattr(field, 'description', ''),
|
||||
"required": getattr(field, 'required', False),
|
||||
"disabled": getattr(field, 'disabled', False),
|
||||
"disabledReason": getattr(field, 'disabled_reason', ''),
|
||||
"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 type-specific properties
|
||||
if isinstance(field, TextField):
|
||||
result["placeholder"] = field.placeholder
|
||||
if field.max_length:
|
||||
result["maxLength"] = field.max_length
|
||||
elif isinstance(field, PasswordField):
|
||||
result["placeholder"] = field.placeholder
|
||||
elif isinstance(field, NumberField):
|
||||
result["min"] = field.min_value
|
||||
result["max"] = field.max_value
|
||||
result["step"] = field.step
|
||||
elif isinstance(field, (SelectField, MultiSelectField)):
|
||||
# Support callable options for lazy evaluation (avoids circular imports)
|
||||
options = field.options() if callable(field.options) else field.options
|
||||
result["options"] = options
|
||||
elif isinstance(field, OrderableListField):
|
||||
# Support callable options for lazy evaluation (avoids circular imports)
|
||||
options = field.options() if callable(field.options) else field.options
|
||||
result["options"] = options
|
||||
elif isinstance(field, ActionButton):
|
||||
result["style"] = field.style
|
||||
result["description"] = field.description
|
||||
|
||||
if include_value and not isinstance(field, (ActionButton, HeadingField)):
|
||||
value = get_setting_value(field, tab_name)
|
||||
result["value"] = value if value is not None else ""
|
||||
result["fromEnv"] = is_value_from_env(field)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def serialize_tab(tab: SettingsTab, include_values: bool = True) -> Dict[str, Any]:
|
||||
"""Serialize a settings tab for API response."""
|
||||
return {
|
||||
"name": tab.name,
|
||||
"displayName": tab.display_name,
|
||||
"icon": tab.icon,
|
||||
"order": tab.order,
|
||||
"group": tab.group,
|
||||
"fields": [serialize_field(f, tab.name, include_values) for f in tab.fields],
|
||||
}
|
||||
|
||||
|
||||
def serialize_group(group: SettingsGroup) -> Dict[str, Any]:
|
||||
"""Serialize a settings group for API response."""
|
||||
return {
|
||||
"name": group.name,
|
||||
"displayName": group.display_name,
|
||||
"icon": group.icon,
|
||||
"order": group.order,
|
||||
}
|
||||
|
||||
|
||||
def get_all_groups() -> List[SettingsGroup]:
|
||||
"""Get all registered settings groups, sorted by order."""
|
||||
return sorted(_GROUPS_REGISTRY.values(), key=lambda g: (g.order, g.name))
|
||||
|
||||
|
||||
def serialize_all_settings(include_values: bool = True) -> Dict[str, Any]:
|
||||
"""Serialize all settings for API response."""
|
||||
tabs = get_all_settings_tabs()
|
||||
groups = get_all_groups()
|
||||
return {
|
||||
"tabs": [serialize_tab(t, include_values) for t in tabs],
|
||||
"groups": [serialize_group(g) for g in groups],
|
||||
}
|
||||
|
||||
|
||||
def execute_action(tab_name: str, action_key: str, current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute an action button's callback.
|
||||
|
||||
Args:
|
||||
tab_name: The settings tab name.
|
||||
action_key: The action key to execute.
|
||||
current_values: Optional dict of current form values (unsaved).
|
||||
Passed to callbacks that accept it.
|
||||
|
||||
Returns:
|
||||
Dict with "success" (bool) and "message" (str).
|
||||
"""
|
||||
import inspect
|
||||
|
||||
tab = get_settings_tab(tab_name)
|
||||
if not tab:
|
||||
return {"success": False, "message": f"Unknown settings tab: {tab_name}"}
|
||||
|
||||
for field in tab.fields:
|
||||
if isinstance(field, ActionButton) and field.key == action_key:
|
||||
if field.callback:
|
||||
try:
|
||||
# Check if callback accepts current_values parameter
|
||||
sig = inspect.signature(field.callback)
|
||||
if 'current_values' in sig.parameters:
|
||||
return field.callback(current_values=current_values or {})
|
||||
else:
|
||||
return field.callback()
|
||||
except Exception as e:
|
||||
logger.error(f"Action {action_key} failed: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
else:
|
||||
return {"success": False, "message": "Action has no callback defined"}
|
||||
|
||||
return {"success": False, "message": f"Unknown action: {action_key}"}
|
||||
|
||||
|
||||
def _sync_metadata_provider_selection() -> None:
|
||||
"""
|
||||
Sync the METADATA_PROVIDER setting based on enabled providers.
|
||||
|
||||
Called after saving metadata provider settings to auto-select
|
||||
the first enabled provider if the current selection is invalid.
|
||||
"""
|
||||
try:
|
||||
from cwa_book_downloader.metadata_providers import sync_metadata_provider_selection
|
||||
sync_metadata_provider_selection()
|
||||
except ImportError:
|
||||
pass # Metadata providers module not available
|
||||
|
||||
|
||||
def _apply_dns_settings(config) -> None:
|
||||
"""
|
||||
Apply DNS settings changes to the network module.
|
||||
|
||||
This ensures DNS changes take effect immediately without requiring
|
||||
a container restart.
|
||||
"""
|
||||
try:
|
||||
from cwa_book_downloader.download import network
|
||||
|
||||
provider = config.get("CUSTOM_DNS", "auto")
|
||||
use_doh = config.get("USE_DOH", False)
|
||||
manual_servers = None
|
||||
|
||||
if provider == "manual":
|
||||
manual_dns = config.get("CUSTOM_DNS_MANUAL", "")
|
||||
if manual_dns:
|
||||
# Parse comma-separated server list
|
||||
manual_servers = [s.strip() for s in manual_dns.split(",") if s.strip()]
|
||||
|
||||
network.set_dns_provider(provider, manual_servers, use_doh=use_doh)
|
||||
except ImportError:
|
||||
pass # Network module not available
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to apply DNS settings: {e}")
|
||||
|
||||
|
||||
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}
|
||||
|
||||
# Build a map of field keys to fields (exclude non-value fields)
|
||||
field_map = {f.key: f for f in tab.fields if not isinstance(f, (ActionButton, HeadingField))}
|
||||
|
||||
# Filter out values that are set via env vars or unknown
|
||||
values_to_save = {}
|
||||
skipped_env = []
|
||||
skipped_unknown = []
|
||||
restart_required_keys = []
|
||||
|
||||
for key, value in values.items():
|
||||
if key not in field_map:
|
||||
skipped_unknown.append(key)
|
||||
continue
|
||||
|
||||
field = field_map[key]
|
||||
if is_value_from_env(field):
|
||||
skipped_env.append(key)
|
||||
continue
|
||||
|
||||
# Handle password fields - only update if a new value is provided
|
||||
if isinstance(field, PasswordField) and not value:
|
||||
continue
|
||||
|
||||
values_to_save[key] = value
|
||||
|
||||
# Track if this field requires restart
|
||||
if getattr(field, 'requires_restart', False):
|
||||
restart_required_keys.append(key)
|
||||
|
||||
if not values_to_save:
|
||||
message = "No settings to update"
|
||||
if skipped_env:
|
||||
message += f". Skipped (set via env): {', '.join(skipped_env)}"
|
||||
return {"success": True, "message": message, "updated": [], "requiresRestart": False}
|
||||
|
||||
# Call on_save handler if registered (for custom validation/transformation)
|
||||
on_save_handler = get_on_save_handler(tab_name)
|
||||
if on_save_handler:
|
||||
try:
|
||||
result = on_save_handler(values_to_save.copy())
|
||||
if result.get("error"):
|
||||
return {
|
||||
"success": False,
|
||||
"message": result.get("message", "Validation failed"),
|
||||
"updated": [],
|
||||
"requiresRestart": False
|
||||
}
|
||||
# Use the transformed values
|
||||
values_to_save = result.get("values", values_to_save)
|
||||
except Exception as e:
|
||||
logger.error(f"on_save handler for {tab_name} failed: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Save handler error: {str(e)}",
|
||||
"updated": [],
|
||||
"requiresRestart": False
|
||||
}
|
||||
|
||||
# Save to config file
|
||||
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
|
||||
config.refresh()
|
||||
except ImportError:
|
||||
pass # Config module not yet available during initial setup
|
||||
|
||||
# Apply DNS settings changes live (network tab)
|
||||
dns_keys = {"CUSTOM_DNS", "CUSTOM_DNS_MANUAL", "USE_DOH"}
|
||||
if tab_name == "network" and dns_keys.intersection(values_to_save.keys()):
|
||||
_apply_dns_settings(config)
|
||||
|
||||
# Sync metadata provider selection when a provider's enabled state changes
|
||||
tab = get_settings_tab(tab_name)
|
||||
if tab and tab.group == "metadata_providers":
|
||||
_sync_metadata_provider_selection()
|
||||
|
||||
message = f"Updated {len(values_to_save)} setting(s)"
|
||||
if skipped_env:
|
||||
message += f". Skipped (set via env): {', '.join(skipped_env)}"
|
||||
|
||||
requires_restart = len(restart_required_keys) > 0
|
||||
return {
|
||||
"success": True,
|
||||
"message": message,
|
||||
"updated": list(values_to_save.keys()),
|
||||
"requiresRestart": requires_restart,
|
||||
"restartRequiredFor": restart_required_keys,
|
||||
}
|
||||
else:
|
||||
return {"success": False, "message": "Failed to save settings", "updated": [], "requiresRestart": False}
|
||||
@@ -0,0 +1 @@
|
||||
"""Download module - HTTP downloads, network, and orchestration."""
|
||||
@@ -0,0 +1,354 @@
|
||||
"""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}",
|
||||
)
|
||||
@@ -0,0 +1,417 @@
|
||||
"""HTTP download with retry, resume, and Cloudflare bypass support."""
|
||||
|
||||
import random
|
||||
import time
|
||||
from io import BytesIO
|
||||
from threading import Event
|
||||
from typing import Callable, Optional
|
||||
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
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
# 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)
|
||||
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',
|
||||
}
|
||||
|
||||
|
||||
def parse_size_string(size: str) -> Optional[float]:
|
||||
"""Parse a human-readable size string (e.g., '10.5 MB') into bytes."""
|
||||
if not size:
|
||||
return None
|
||||
try:
|
||||
normalized = size.strip().replace(" ", "").replace(",", ".").upper()
|
||||
multipliers = {"GB": 1024**3, "MB": 1024**2, "KB": 1024}
|
||||
for suffix, mult in multipliers.items():
|
||||
if normalized.endswith(suffix):
|
||||
return float(normalized[:-2]) * mult
|
||||
return float(normalized)
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
def _backoff_delay(attempt: int, base: float = 0.25, cap: float = 3.0) -> float:
|
||||
"""Exponential backoff with jitter."""
|
||||
return min(cap, base * (2 ** (attempt - 1))) + random.random() * base
|
||||
|
||||
|
||||
def _get_status_code(e: Exception) -> Optional[int]:
|
||||
"""Extract HTTP status code from an exception, or None if not applicable."""
|
||||
if isinstance(e, requests.exceptions.HTTPError) and e.response is not None:
|
||||
return e.response.status_code
|
||||
return None
|
||||
|
||||
def _is_retryable_error(e: Exception) -> bool:
|
||||
"""Check if error is retryable (connection error or retryable HTTP status)."""
|
||||
if isinstance(e, CONNECTION_ERRORS):
|
||||
return True
|
||||
status = _get_status_code(e)
|
||||
return status in RETRYABLE_CODES if status else False
|
||||
|
||||
|
||||
def _try_rotation(original_url: str, current_url: str, selector: network.AAMirrorSelector) -> Optional[str]:
|
||||
"""Try mirror/DNS rotation. Returns new URL or None."""
|
||||
if current_url.startswith(network.get_aa_base_url()):
|
||||
new_base, action = selector.next_mirror_or_rotate_dns()
|
||||
if action in ("mirror", "dns") and new_base:
|
||||
new_url = selector.rewrite(original_url)
|
||||
logger.info(f"[{action}] switching to: {new_url}")
|
||||
return new_url
|
||||
elif network.should_rotate_dns_for_url(current_url) and network.rotate_dns_provider():
|
||||
logger.info(f"[dns-rotate] retrying: {original_url}")
|
||||
return original_url
|
||||
return None
|
||||
|
||||
|
||||
def html_get_page(
|
||||
url: str,
|
||||
retry: Optional[int] = None,
|
||||
use_bypasser: bool = False,
|
||||
selector: Optional[network.AAMirrorSelector] = None,
|
||||
cancel_flag: Optional[Event] = None,
|
||||
) -> str:
|
||||
"""Fetch HTML content from a URL with retry mechanism."""
|
||||
retry = retry if retry is not None else app_config.MAX_RETRY
|
||||
selector = selector or network.AAMirrorSelector()
|
||||
original_url = url
|
||||
current_url = selector.rewrite(original_url)
|
||||
use_bypasser_now = use_bypasser
|
||||
|
||||
for attempt in range(1, retry + 1):
|
||||
# Check for cancellation before each attempt
|
||||
if cancel_flag and cancel_flag.is_set():
|
||||
logger.info(f"html_get_page cancelled before attempt {attempt}")
|
||||
return ""
|
||||
|
||||
try:
|
||||
if use_bypasser_now and USE_CF_BYPASS:
|
||||
logger.info(f"GET (bypasser): {current_url}")
|
||||
try:
|
||||
result = get_bypassed_page(current_url, selector, cancel_flag)
|
||||
return result or ""
|
||||
except Exception as e:
|
||||
logger.warning(f"Bypasser error: {type(e).__name__}: {e}")
|
||||
return ""
|
||||
|
||||
logger.info(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)
|
||||
response.raise_for_status()
|
||||
time.sleep(1)
|
||||
return response.text
|
||||
|
||||
except Exception as e:
|
||||
status = _get_status_code(e)
|
||||
|
||||
# 403 = Cloudflare/DDoS-Guard protection
|
||||
if status == 403:
|
||||
if USE_CF_BYPASS 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)
|
||||
fresh_cookies = get_cf_cookies_for_domain(parsed.hostname or "")
|
||||
if fresh_cookies and not cookies:
|
||||
# Cookies are now available - retry with cookies before using bypasser
|
||||
logger.debug(f"403 but cookies now available - retrying with cookies: {current_url}")
|
||||
continue
|
||||
logger.info(f"403 detected; switching to bypasser: {current_url}")
|
||||
use_bypasser_now = True
|
||||
continue
|
||||
logger.warning(f"403 error, giving up: {current_url}")
|
||||
return ""
|
||||
|
||||
# 404 = Not found
|
||||
if status == 404:
|
||||
logger.warning(f"404 error: {current_url}")
|
||||
return ""
|
||||
|
||||
# Try mirror/DNS rotation on retryable errors
|
||||
if _is_retryable_error(e):
|
||||
new_url = _try_rotation(original_url, current_url, selector)
|
||||
if new_url:
|
||||
current_url = new_url
|
||||
continue
|
||||
|
||||
# Retry with backoff
|
||||
if attempt < retry:
|
||||
logger.warning(f"Retry {attempt}/{retry} for {current_url}: {type(e).__name__}: {e}")
|
||||
time.sleep(_backoff_delay(attempt))
|
||||
else:
|
||||
logger.error(f"Giving up after {retry} attempts: {current_url}")
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def download_url(
|
||||
link: str,
|
||||
size: str = "",
|
||||
progress_callback: Optional[Callable[[float], None]] = None,
|
||||
cancel_flag: Optional[Event] = None,
|
||||
_selector: Optional[network.AAMirrorSelector] = None,
|
||||
status_callback: Optional[Callable[[str, Optional[str]], None]] = None,
|
||||
referer: Optional[str] = None,
|
||||
) -> Optional[BytesIO]:
|
||||
"""Download content from URL with automatic retry and resume support."""
|
||||
selector = _selector or network.AAMirrorSelector()
|
||||
current_url = selector.rewrite(link)
|
||||
|
||||
# Build headers with optional referer
|
||||
headers = DOWNLOAD_HEADERS.copy()
|
||||
if referer:
|
||||
headers['Referer'] = referer
|
||||
total_size = parse_size_string(size) or 0
|
||||
|
||||
attempt = 0
|
||||
zlib_cookie_refresh_attempted = False
|
||||
|
||||
while attempt < MAX_DOWNLOAD_RETRIES:
|
||||
if cancel_flag and cancel_flag.is_set():
|
||||
return None
|
||||
|
||||
buffer = BytesIO()
|
||||
bytes_downloaded = 0
|
||||
|
||||
try:
|
||||
if attempt > 0 and status_callback:
|
||||
status_callback("resolving", f"Connecting (Attempt {attempt + 1}/{MAX_DOWNLOAD_RETRIES})")
|
||||
|
||||
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)
|
||||
response.raise_for_status()
|
||||
|
||||
if status_callback:
|
||||
status_callback("downloading", "")
|
||||
|
||||
total_size = total_size or float(response.headers.get('content-length', 0))
|
||||
pbar = tqdm(total=total_size, unit='B', unit_scale=True, desc='Downloading')
|
||||
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
buffer.write(chunk)
|
||||
bytes_downloaded += len(chunk)
|
||||
pbar.update(len(chunk))
|
||||
if progress_callback and total_size > 0:
|
||||
progress_callback(bytes_downloaded * 100.0 / total_size)
|
||||
if cancel_flag and cancel_flag.is_set():
|
||||
pbar.close()
|
||||
return None
|
||||
pbar.close()
|
||||
|
||||
# Validate - check we didn't get HTML instead of file
|
||||
if total_size > 0 and bytes_downloaded < total_size * 0.9:
|
||||
if response.headers.get('content-type', '').startswith('text/html'):
|
||||
logger.warning(f"Received HTML instead of file: {current_url}")
|
||||
return None
|
||||
|
||||
logger.debug(f"Download completed: {bytes_downloaded} bytes")
|
||||
return buffer
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
status = _get_status_code(e)
|
||||
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:
|
||||
parsed = urlparse(current_url)
|
||||
if parsed.hostname and 'z-lib' in parsed.hostname and referer:
|
||||
zlib_cookie_refresh_attempted = True
|
||||
logger.info(f"Z-Library 403 - refreshing cookies via referer: {referer}")
|
||||
try:
|
||||
get_bypassed_page(referer, selector, cancel_flag)
|
||||
time.sleep(0.5)
|
||||
# Retry with fresh cookies (don't increment attempt)
|
||||
continue
|
||||
except Exception as cookie_err:
|
||||
logger.warning(f"Z-Library cookie refresh failed: {cookie_err}")
|
||||
|
||||
# Non-retryable errors
|
||||
if status in (403, 404):
|
||||
logger.warning(f"Download failed ({status}): {current_url}")
|
||||
return None
|
||||
|
||||
# Rate limited - skip to next source immediately
|
||||
# (waiting doesn't help with concurrent downloads hitting the same server)
|
||||
if status == 429:
|
||||
logger.info(f"Rate limited (429) - trying next source")
|
||||
if status_callback:
|
||||
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...")
|
||||
return None
|
||||
|
||||
# Try to resume if we got some data
|
||||
if bytes_downloaded > 0 and retryable:
|
||||
resumed = _try_resume(current_url, buffer, bytes_downloaded, total_size, progress_callback, cancel_flag, headers)
|
||||
if resumed:
|
||||
return resumed
|
||||
|
||||
# Try mirror/DNS rotation if nothing downloaded yet
|
||||
if bytes_downloaded == 0 and retryable:
|
||||
new_url = _try_rotation(link, current_url, selector)
|
||||
if new_url:
|
||||
current_url = new_url
|
||||
attempt += 1
|
||||
continue
|
||||
|
||||
logger.warning(f"Download error: {type(e).__name__}: {e}")
|
||||
if attempt < MAX_DOWNLOAD_RETRIES - 1:
|
||||
time.sleep(_backoff_delay(attempt + 1))
|
||||
attempt += 1
|
||||
|
||||
logger.error(f"Download failed after {MAX_DOWNLOAD_RETRIES} attempts: {link}")
|
||||
return None
|
||||
|
||||
|
||||
def _try_resume(
|
||||
url: str,
|
||||
buffer: BytesIO,
|
||||
start_byte: int,
|
||||
total_size: float,
|
||||
progress_callback: Optional[Callable[[float], None]],
|
||||
cancel_flag: Optional[Event],
|
||||
base_headers: Optional[dict] = None,
|
||||
) -> Optional[BytesIO]:
|
||||
"""Try to resume an interrupted download."""
|
||||
for attempt in range(MAX_RESUME_ATTEMPTS):
|
||||
logger.info(f"Resuming from {start_byte} bytes (attempt {attempt + 1}/{MAX_RESUME_ATTEMPTS})")
|
||||
time.sleep(_backoff_delay(attempt + 1, base=0.5, cap=5.0))
|
||||
|
||||
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
|
||||
response = requests.get(
|
||||
url, stream=True, proxies=_get_proxies(), timeout=REQUEST_TIMEOUT,
|
||||
headers=resume_headers, cookies=cookies
|
||||
)
|
||||
|
||||
# Check resume support
|
||||
if response.status_code == 200: # Server doesn't support resume
|
||||
logger.info("Server doesn't support resume")
|
||||
return None
|
||||
if response.status_code == 416: # Range not satisfiable
|
||||
logger.warning("Range not satisfiable")
|
||||
return None
|
||||
if response.status_code != 206:
|
||||
response.raise_for_status()
|
||||
|
||||
pbar = tqdm(total=total_size, initial=start_byte, unit='B', unit_scale=True, desc='Resuming')
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
buffer.write(chunk)
|
||||
start_byte += len(chunk)
|
||||
pbar.update(len(chunk))
|
||||
if progress_callback and total_size > 0:
|
||||
progress_callback(start_byte * 100.0 / total_size)
|
||||
if cancel_flag and cancel_flag.is_set():
|
||||
pbar.close()
|
||||
return None
|
||||
pbar.close()
|
||||
|
||||
logger.info(f"Resume completed: {start_byte} bytes")
|
||||
return buffer
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.debug(f"Resume attempt {attempt + 1} failed: {e}")
|
||||
|
||||
logger.warning(f"Resume failed after {MAX_RESUME_ATTEMPTS} attempts")
|
||||
return None
|
||||
|
||||
|
||||
def get_absolute_url(base_url: str, url: str) -> str:
|
||||
"""Convert a relative URL to absolute using the base URL."""
|
||||
url = url.strip()
|
||||
if not url or url == "#" or url.startswith("http"):
|
||||
return url if url.startswith("http") else ""
|
||||
parsed = urlparse(url)
|
||||
base = urlparse(base_url)
|
||||
if not parsed.netloc or not parsed.scheme:
|
||||
parsed = parsed._replace(netloc=base.netloc, scheme=base.scheme)
|
||||
return parsed.geturl()
|
||||
@@ -0,0 +1,798 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,55 @@
|
||||
"""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
|
||||
@@ -0,0 +1,318 @@
|
||||
# Metadata Providers
|
||||
|
||||
This module provides a plugin architecture for fetching book metadata from various sources with a unified interface.
|
||||
|
||||
## Overview
|
||||
|
||||
Metadata providers allow searching for books and retrieving detailed metadata (title, authors, cover images, descriptions, etc.) from external services. The system uses a decorator-based registration pattern, making it easy to add new providers.
|
||||
|
||||
## Available Providers
|
||||
|
||||
| Provider | Auth Required | Description |
|
||||
|----------|---------------|-------------|
|
||||
| **Hardcover** | Yes (API key) | Modern book tracking platform with GraphQL API. Get your key at [hardcover.app/account/api](https://hardcover.app/account/api) |
|
||||
| **Open Library** | No | Free, open-source library catalog from the Internet Archive. Rate limited to ~100 requests/minute |
|
||||
|
||||
## Core Components
|
||||
|
||||
### BookMetadata
|
||||
|
||||
Dataclass representing a book from a metadata provider:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class BookMetadata:
|
||||
provider: str # Internal provider name (e.g., "hardcover")
|
||||
provider_id: str # ID in that provider's system
|
||||
title: str
|
||||
|
||||
# Optional fields
|
||||
provider_display_name: str # Human-readable name (e.g., "Hardcover")
|
||||
authors: List[str]
|
||||
isbn_10: str
|
||||
isbn_13: str
|
||||
cover_url: str
|
||||
description: str
|
||||
publisher: str
|
||||
publish_year: int
|
||||
language: str
|
||||
genres: List[str]
|
||||
source_url: str # Link to book on provider's site
|
||||
display_fields: List[DisplayField] # Provider-specific display data
|
||||
```
|
||||
|
||||
### DisplayField
|
||||
|
||||
Provider-specific metadata for UI cards (ratings, page counts, reader counts, etc.):
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class DisplayField:
|
||||
label: str # e.g., "Rating", "Pages", "Readers"
|
||||
value: str # e.g., "4.5", "496", "8,041"
|
||||
icon: str # Icon name: "star", "book", "users", "editions"
|
||||
```
|
||||
|
||||
### MetadataSearchOptions
|
||||
|
||||
Unified search options that work across all providers:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class MetadataSearchOptions:
|
||||
query: str
|
||||
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
|
||||
page: int = 1
|
||||
```
|
||||
|
||||
### SortOrder
|
||||
|
||||
Available sort options (provider support varies):
|
||||
|
||||
| Sort Order | Description | Hardcover | Open Library |
|
||||
|------------|-------------|-----------|--------------|
|
||||
| `RELEVANCE` | Best match first (default) | ✓ | ✓ |
|
||||
| `POPULARITY` | Most popular first | ✓ | ✗ |
|
||||
| `RATING` | Highest rated first | ✓ | ✗ |
|
||||
| `NEWEST` | Most recently published | ✓ | ✓ |
|
||||
| `OLDEST` | Oldest published first | ✓ | ✓ |
|
||||
|
||||
### MetadataProvider (Abstract Base Class)
|
||||
|
||||
All providers must implement this interface:
|
||||
|
||||
```python
|
||||
class MetadataProvider(ABC):
|
||||
name: str # Internal identifier
|
||||
display_name: str # Human-readable name
|
||||
requires_auth: bool # True if API key required
|
||||
supported_sorts: List[SortOrder] # Supported sort options
|
||||
|
||||
@abstractmethod
|
||||
def search(self, options: MetadataSearchOptions) -> List[BookMetadata]:
|
||||
"""Search for books using the provided options."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_book(self, book_id: str) -> Optional[BookMetadata]:
|
||||
"""Get a specific book by provider ID."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def search_by_isbn(self, isbn: str) -> Optional[BookMetadata]:
|
||||
"""Search for a book by ISBN."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def is_available(self) -> bool:
|
||||
"""Check if this provider is configured and available."""
|
||||
pass
|
||||
```
|
||||
|
||||
## Registry Functions
|
||||
|
||||
### Provider Registration
|
||||
|
||||
```python
|
||||
from cwa_book_downloader.metadata_providers import register_provider
|
||||
|
||||
@register_provider("my_provider")
|
||||
class MyProvider(MetadataProvider):
|
||||
...
|
||||
```
|
||||
|
||||
### Getting Providers
|
||||
|
||||
```python
|
||||
from cwa_book_downloader.metadata_providers import (
|
||||
get_provider,
|
||||
get_configured_provider,
|
||||
get_provider_kwargs,
|
||||
list_providers,
|
||||
is_provider_registered,
|
||||
)
|
||||
|
||||
# Get specific provider with kwargs
|
||||
provider = get_provider("hardcover", api_key="...")
|
||||
|
||||
# Get currently configured provider (from settings)
|
||||
provider = get_configured_provider()
|
||||
|
||||
# Get provider-specific kwargs from config
|
||||
kwargs = get_provider_kwargs("hardcover") # {"api_key": "..."}
|
||||
|
||||
# List all registered providers
|
||||
providers = list_providers()
|
||||
# [{"name": "hardcover", "display_name": "Hardcover", "requires_auth": True}, ...]
|
||||
|
||||
# Check if provider exists
|
||||
exists = is_provider_registered("hardcover") # True
|
||||
```
|
||||
|
||||
### Sort Options
|
||||
|
||||
```python
|
||||
from cwa_book_downloader.metadata_providers import get_provider_sort_options
|
||||
|
||||
# Get sort options for a specific provider
|
||||
options = get_provider_sort_options("hardcover")
|
||||
# [{"value": "relevance", "label": "Most relevant"}, ...]
|
||||
|
||||
# Get sort options for configured provider
|
||||
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`)
|
||||
|
||||
2. Implement the provider:
|
||||
|
||||
```python
|
||||
from cwa_book_downloader.metadata_providers import (
|
||||
BookMetadata,
|
||||
DisplayField,
|
||||
MetadataProvider,
|
||||
MetadataSearchOptions,
|
||||
SearchType,
|
||||
SortOrder,
|
||||
register_provider,
|
||||
)
|
||||
from cwa_book_downloader.core.settings_registry import (
|
||||
register_settings,
|
||||
HeadingField,
|
||||
PasswordField,
|
||||
ActionButton,
|
||||
)
|
||||
from cwa_book_downloader.core.config import config
|
||||
|
||||
|
||||
@register_provider("my_provider")
|
||||
class MyProvider(MetadataProvider):
|
||||
name = "my_provider"
|
||||
display_name = "My Provider"
|
||||
requires_auth = True
|
||||
supported_sorts = [SortOrder.RELEVANCE, SortOrder.NEWEST]
|
||||
|
||||
def __init__(self, api_key: str = None):
|
||||
self.api_key = api_key or config.get("MY_PROVIDER_API_KEY", "")
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return bool(self.api_key)
|
||||
|
||||
def search(self, options: MetadataSearchOptions) -> List[BookMetadata]:
|
||||
# Handle ISBN search separately
|
||||
if options.search_type == SearchType.ISBN:
|
||||
result = self.search_by_isbn(options.query)
|
||||
return [result] if result else []
|
||||
|
||||
# Implement search logic...
|
||||
return []
|
||||
|
||||
def get_book(self, book_id: str) -> Optional[BookMetadata]:
|
||||
# Implement get book logic...
|
||||
return None
|
||||
|
||||
def search_by_isbn(self, isbn: str) -> Optional[BookMetadata]:
|
||||
# Implement ISBN search logic...
|
||||
return None
|
||||
|
||||
|
||||
# Settings for the UI
|
||||
@register_settings("my_provider", "My Provider", icon="book", order=53, group="metadata_providers")
|
||||
def my_provider_settings():
|
||||
return [
|
||||
HeadingField(
|
||||
key="my_provider_heading",
|
||||
title="My Provider",
|
||||
description="Description of your provider",
|
||||
link_url="https://myprovider.com",
|
||||
link_text="myprovider.com",
|
||||
),
|
||||
PasswordField(
|
||||
key="MY_PROVIDER_API_KEY",
|
||||
label="API Key",
|
||||
description="Your API key",
|
||||
required=True,
|
||||
),
|
||||
ActionButton(
|
||||
key="test_connection",
|
||||
label="Test Connection",
|
||||
style="primary",
|
||||
callback=_test_connection,
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
3. Import your provider in `__init__.py`:
|
||||
|
||||
```python
|
||||
try:
|
||||
from cwa_book_downloader.metadata_providers import my_provider # noqa: F401
|
||||
except ImportError:
|
||||
pass # Provider is optional
|
||||
```
|
||||
|
||||
4. Add provider kwargs to `get_provider_kwargs()` in `__init__.py`:
|
||||
|
||||
```python
|
||||
def get_provider_kwargs(provider_name: str) -> Dict:
|
||||
kwargs: Dict = {}
|
||||
if provider_name == "hardcover":
|
||||
kwargs["api_key"] = app_config.get("HARDCOVER_API_KEY", "")
|
||||
elif provider_name == "my_provider":
|
||||
kwargs["api_key"] = app_config.get("MY_PROVIDER_API_KEY", "")
|
||||
return kwargs
|
||||
```
|
||||
|
||||
## Caching
|
||||
|
||||
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 (
|
||||
METADATA_CACHE_SEARCH_TTL,
|
||||
METADATA_CACHE_BOOK_TTL,
|
||||
)
|
||||
|
||||
@cacheable(ttl=METADATA_CACHE_SEARCH_TTL, key_prefix="myprovider:search")
|
||||
def _search_cached(self, cache_key: str, options: MetadataSearchOptions):
|
||||
# Cached search implementation
|
||||
pass
|
||||
|
||||
@cacheable(ttl=METADATA_CACHE_BOOK_TTL, key_prefix="myprovider:book")
|
||||
def get_book(self, book_id: str):
|
||||
# Cached book lookup
|
||||
pass
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
For providers with rate limits (like Open Library), implement a rate limiter:
|
||||
|
||||
```python
|
||||
from cwa_book_downloader.metadata_providers.openlibrary import RateLimiter
|
||||
|
||||
# 90 requests per 60 seconds
|
||||
rate_limiter = RateLimiter(max_requests=90, window_seconds=60)
|
||||
|
||||
def make_request(self):
|
||||
rate_limiter.wait_if_needed() # Blocks if rate limited
|
||||
# ... make request
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Provider settings are stored in `CONFIG_DIR/plugins/<provider_name>.json` and managed via the Settings UI. See [Plugin Settings Guide](../../docs/plugin-settings.md) for detailed documentation on adding settings to your provider.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `METADATA_PROVIDER` | `""` | Active metadata provider name |
|
||||
| `METADATA_CACHE_SEARCH_TTL` | `3600` | Search cache TTL in seconds |
|
||||
| `METADATA_CACHE_BOOK_TTL` | `86400` | Book lookup cache TTL in seconds |
|
||||
@@ -0,0 +1,481 @@
|
||||
"""Metadata provider plugin system - base classes and registry."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Dict, Type, Literal, Any, Union
|
||||
|
||||
|
||||
class SearchType(str, Enum):
|
||||
"""Type of search to perform."""
|
||||
GENERAL = "general" # Search all fields (title, author, ISBN, etc.)
|
||||
TITLE = "title" # Search by title only
|
||||
AUTHOR = "author" # Search by author only
|
||||
ISBN = "isbn" # Search by ISBN
|
||||
|
||||
|
||||
class SortOrder(str, Enum):
|
||||
"""Sort order for search results."""
|
||||
RELEVANCE = "relevance" # Best match first (default)
|
||||
POPULARITY = "popularity" # Most popular first
|
||||
RATING = "rating" # Highest rated first
|
||||
NEWEST = "newest" # Most recently published first
|
||||
OLDEST = "oldest" # Oldest published first
|
||||
|
||||
|
||||
# Display labels for sort options
|
||||
SORT_LABELS: Dict[SortOrder, str] = {
|
||||
SortOrder.RELEVANCE: "Most relevant",
|
||||
SortOrder.POPULARITY: "Most popular",
|
||||
SortOrder.RATING: "Highest rated",
|
||||
SortOrder.NEWEST: "Newest",
|
||||
SortOrder.OLDEST: "Oldest",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextSearchField:
|
||||
"""Text input search field."""
|
||||
key: str # Field identifier (e.g., "author", "publisher")
|
||||
label: str # Display label in UI
|
||||
placeholder: str = "" # Placeholder text
|
||||
description: str = "" # Help text
|
||||
|
||||
|
||||
@dataclass
|
||||
class NumberSearchField:
|
||||
"""Numeric input search field."""
|
||||
key: str
|
||||
label: str
|
||||
placeholder: str = ""
|
||||
description: str = ""
|
||||
min_value: Optional[int] = None
|
||||
max_value: Optional[int] = None
|
||||
step: int = 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class SelectSearchField:
|
||||
"""Single-choice dropdown search field."""
|
||||
key: str
|
||||
label: str
|
||||
options: List[Dict[str, str]] = field(default_factory=list) # [{value: "", label: ""}]
|
||||
placeholder: str = ""
|
||||
description: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckboxSearchField:
|
||||
"""Boolean checkbox search field."""
|
||||
key: str
|
||||
label: str
|
||||
description: str = ""
|
||||
default: bool = False
|
||||
|
||||
|
||||
# Type alias for all search field types
|
||||
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.
|
||||
"""
|
||||
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 "",
|
||||
}
|
||||
|
||||
# Add type-specific properties
|
||||
if isinstance(search_field, NumberSearchField):
|
||||
result["min"] = search_field.min_value
|
||||
result["max"] = search_field.max_value
|
||||
result["step"] = search_field.step
|
||||
elif isinstance(search_field, SelectSearchField):
|
||||
result["options"] = search_field.options
|
||||
elif isinstance(search_field, CheckboxSearchField):
|
||||
result["default"] = search_field.default
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@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.
|
||||
"""
|
||||
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
|
||||
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.
|
||||
"""
|
||||
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"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BookMetadata:
|
||||
"""Book from metadata provider (not a specific release)."""
|
||||
provider: str # Which provider this came from (internal name)
|
||||
provider_id: str # ID in that provider's system
|
||||
title: str
|
||||
|
||||
# Provider display name for UI (e.g., "Open Library" instead of "openlibrary")
|
||||
provider_display_name: Optional[str] = None
|
||||
|
||||
# Optional - not all providers have all fields
|
||||
authors: List[str] = field(default_factory=list)
|
||||
isbn_10: Optional[str] = None
|
||||
isbn_13: Optional[str] = None
|
||||
cover_url: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
publisher: Optional[str] = None
|
||||
publish_year: Optional[int] = None
|
||||
language: Optional[str] = None
|
||||
genres: List[str] = field(default_factory=list)
|
||||
source_url: Optional[str] = None # Link to book on provider's site
|
||||
|
||||
# Provider-specific display fields for cards/lists
|
||||
display_fields: List[DisplayField] = field(default_factory=list)
|
||||
|
||||
|
||||
class MetadataProvider(ABC):
|
||||
"""Interface for metadata providers.
|
||||
|
||||
All metadata providers must implement this interface. The search method
|
||||
accepts MetadataSearchOptions for unified search across providers.
|
||||
|
||||
Attributes:
|
||||
name: Internal identifier (e.g., "hardcover")
|
||||
display_name: Human-readable name (e.g., "Hardcover")
|
||||
requires_auth: True if API key/authentication is required
|
||||
supported_sorts: List of SortOrder values this provider supports
|
||||
search_fields: List of provider-specific search fields
|
||||
"""
|
||||
name: str
|
||||
display_name: str
|
||||
requires_auth: bool
|
||||
supported_sorts: List[SortOrder] = [SortOrder.RELEVANCE]
|
||||
search_fields: List[SearchField] = []
|
||||
|
||||
@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)
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_book(self, book_id: str) -> Optional[BookMetadata]:
|
||||
"""Get a specific book by provider ID."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def search_by_isbn(self, isbn: str) -> Optional[BookMetadata]:
|
||||
"""Search for a book by ISBN."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def is_available(self) -> bool:
|
||||
"""Check if this provider is configured and available."""
|
||||
pass
|
||||
|
||||
|
||||
# Provider registry
|
||||
_PROVIDERS: Dict[str, Type[MetadataProvider]] = {}
|
||||
_PROVIDER_KWARGS_FACTORIES: Dict[str, Any] = {} # Callable[[], Dict]
|
||||
|
||||
|
||||
def register_provider(name: str):
|
||||
"""Decorator to register a metadata provider."""
|
||||
def decorator(cls):
|
||||
_PROVIDERS[name] = cls
|
||||
return cls
|
||||
return decorator
|
||||
|
||||
|
||||
def register_provider_kwargs(name: str):
|
||||
"""Decorator to register a provider's kwargs factory.
|
||||
|
||||
The decorated function should return a Dict of kwargs to pass to the
|
||||
provider constructor. This allows each provider to define its own
|
||||
configuration requirements without polluting the core module.
|
||||
|
||||
Example:
|
||||
@register_provider_kwargs("hardcover")
|
||||
def _hardcover_kwargs() -> Dict:
|
||||
from cwa_book_downloader.core.config import config
|
||||
return {"api_key": config.get("HARDCOVER_API_KEY", "")}
|
||||
"""
|
||||
def decorator(fn):
|
||||
_PROVIDER_KWARGS_FACTORIES[name] = fn
|
||||
return fn
|
||||
return decorator
|
||||
|
||||
|
||||
def get_provider(name: str, **kwargs) -> MetadataProvider:
|
||||
"""Factory - instantiate any registered provider."""
|
||||
if name not in _PROVIDERS:
|
||||
raise ValueError(f"Unknown metadata provider: {name}")
|
||||
return _PROVIDERS[name](**kwargs)
|
||||
|
||||
|
||||
def list_providers() -> List[dict]:
|
||||
"""For settings UI - list available providers with their requirements."""
|
||||
return [
|
||||
{"name": n, "display_name": c.display_name, "requires_auth": c.requires_auth}
|
||||
for n, c in _PROVIDERS.items()
|
||||
]
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
factory = _PROVIDER_KWARGS_FACTORIES.get(provider_name)
|
||||
if factory:
|
||||
return factory()
|
||||
return {}
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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
|
||||
|
||||
# Refresh config to get latest settings
|
||||
app_config.refresh()
|
||||
|
||||
# Check the provider-specific enabled flag
|
||||
enabled_key = f"{provider_name.upper()}_ENABLED"
|
||||
return app_config.get(enabled_key, False) is True
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
# Refresh config to ensure we have the latest saved settings
|
||||
app_config.refresh()
|
||||
|
||||
metadata_provider = app_config.get("METADATA_PROVIDER", "")
|
||||
if not metadata_provider:
|
||||
return None
|
||||
|
||||
if metadata_provider not in _PROVIDERS:
|
||||
return None
|
||||
|
||||
# Check if the provider is enabled
|
||||
if not is_provider_enabled(metadata_provider):
|
||||
return None
|
||||
|
||||
kwargs = get_provider_kwargs(metadata_provider)
|
||||
return get_provider(metadata_provider, **kwargs)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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", "")
|
||||
|
||||
if provider_name and provider_name in _PROVIDERS:
|
||||
provider_class = _PROVIDERS[provider_name]
|
||||
supported = getattr(provider_class, 'supported_sorts', [SortOrder.RELEVANCE])
|
||||
else:
|
||||
supported = [SortOrder.RELEVANCE]
|
||||
|
||||
return [
|
||||
{"value": sort.value, "label": SORT_LABELS.get(sort, sort.value.title())}
|
||||
for sort in supported
|
||||
]
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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", "")
|
||||
|
||||
if provider_name and provider_name in _PROVIDERS:
|
||||
provider_class = _PROVIDERS[provider_name]
|
||||
fields = getattr(provider_class, 'search_fields', [])
|
||||
else:
|
||||
fields = []
|
||||
|
||||
return [serialize_search_field(f) for f in fields]
|
||||
|
||||
|
||||
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
|
||||
|
||||
if provider_name is None:
|
||||
app_config.refresh()
|
||||
provider_name = app_config.get("METADATA_PROVIDER", "")
|
||||
|
||||
if not provider_name:
|
||||
return "relevance"
|
||||
|
||||
# Look up provider-specific default sort setting
|
||||
setting_key = f"{provider_name.upper()}_DEFAULT_SORT"
|
||||
return app_config.get(setting_key, "relevance")
|
||||
|
||||
|
||||
def sync_metadata_provider_selection() -> None:
|
||||
"""Sync the METADATA_PROVIDER setting based on enabled providers.
|
||||
|
||||
If the currently selected provider is not enabled (or nothing is selected),
|
||||
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
|
||||
|
||||
app_config.refresh()
|
||||
|
||||
current_provider = app_config.get("METADATA_PROVIDER", "")
|
||||
enabled = get_enabled_providers()
|
||||
|
||||
# If current provider is valid and enabled, nothing to do
|
||||
if current_provider and current_provider in enabled:
|
||||
return
|
||||
|
||||
# Auto-select first enabled provider (or clear if none)
|
||||
new_provider = enabled[0] if enabled else ""
|
||||
|
||||
if new_provider != current_provider:
|
||||
# Update the general settings config
|
||||
general_config = load_config_file("general")
|
||||
general_config["METADATA_PROVIDER"] = new_provider
|
||||
save_config_file("general", general_config)
|
||||
app_config.refresh()
|
||||
|
||||
|
||||
# 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
|
||||
except ImportError:
|
||||
pass # Hardcover provider is optional
|
||||
|
||||
try:
|
||||
from cwa_book_downloader.metadata_providers import openlibrary # noqa: F401, E402
|
||||
except ImportError:
|
||||
pass # Open Library provider is optional
|
||||
@@ -0,0 +1,752 @@
|
||||
"""Hardcover.app metadata provider. Requires API key."""
|
||||
|
||||
import requests
|
||||
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 (
|
||||
register_settings,
|
||||
CheckboxField,
|
||||
PasswordField,
|
||||
SelectField,
|
||||
ActionButton,
|
||||
HeadingField,
|
||||
)
|
||||
from cwa_book_downloader.core.config import config as app_config
|
||||
from cwa_book_downloader.metadata_providers import (
|
||||
BookMetadata,
|
||||
DisplayField,
|
||||
MetadataProvider,
|
||||
MetadataSearchOptions,
|
||||
SearchType,
|
||||
SortOrder,
|
||||
register_provider,
|
||||
register_provider_kwargs,
|
||||
TextSearchField,
|
||||
)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
HARDCOVER_API_URL = "https://api.hardcover.app/v1/graphql"
|
||||
|
||||
|
||||
# Mapping from abstract sort order to Hardcover sort parameter
|
||||
# Note: release_year is more consistently populated than release_date_i
|
||||
SORT_MAPPING: Dict[SortOrder, str] = {
|
||||
SortOrder.RELEVANCE: "_text_match:desc,users_count:desc",
|
||||
SortOrder.POPULARITY: "users_count:desc",
|
||||
SortOrder.RATING: "rating:desc",
|
||||
SortOrder.NEWEST: "release_year:desc",
|
||||
SortOrder.OLDEST: "release_year:asc",
|
||||
}
|
||||
|
||||
# Mapping from abstract search type to Hardcover fields parameter
|
||||
SEARCH_TYPE_FIELDS: Dict[SearchType, str] = {
|
||||
SearchType.GENERAL: "title,isbns,series_names,author_names,alternative_titles",
|
||||
SearchType.TITLE: "title,alternative_titles",
|
||||
SearchType.AUTHOR: "author_names",
|
||||
# ISBN is handled separately via search_by_isbn()
|
||||
}
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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 None
|
||||
|
||||
|
||||
@register_provider_kwargs("hardcover")
|
||||
def _hardcover_kwargs() -> Dict[str, Any]:
|
||||
"""Provide Hardcover-specific constructor kwargs."""
|
||||
return {"api_key": app_config.get("HARDCOVER_API_KEY", "")}
|
||||
|
||||
|
||||
@register_provider("hardcover")
|
||||
class HardcoverProvider(MetadataProvider):
|
||||
"""Hardcover.app metadata provider using GraphQL API."""
|
||||
|
||||
name = "hardcover"
|
||||
display_name = "Hardcover"
|
||||
requires_auth = True
|
||||
supported_sorts = [
|
||||
SortOrder.RELEVANCE,
|
||||
SortOrder.POPULARITY,
|
||||
SortOrder.RATING,
|
||||
SortOrder.NEWEST,
|
||||
SortOrder.OLDEST,
|
||||
]
|
||||
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 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", "")
|
||||
self.session = requests.Session()
|
||||
if self.api_key:
|
||||
self.session.headers.update({
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
|
||||
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 Hardcover's search API.
|
||||
|
||||
Args:
|
||||
options: Search options (query, type, sort, pagination, fields).
|
||||
|
||||
Returns:
|
||||
List of BookMetadata objects.
|
||||
"""
|
||||
if not self.api_key:
|
||||
logger.warning("Hardcover 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 options (include fields 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}"
|
||||
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.
|
||||
"""
|
||||
# Determine query and fields based on custom search fields
|
||||
# Field-first search: when a specific field has a value, search that field
|
||||
author_value = options.fields.get("author", "").strip()
|
||||
title_value = options.fields.get("title", "").strip()
|
||||
|
||||
logger.debug(f"Field-first search check: author_value='{author_value}', title_value='{title_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
|
||||
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
|
||||
) {
|
||||
results
|
||||
}
|
||||
}
|
||||
"""
|
||||
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
|
||||
) {
|
||||
results
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
# Map abstract sort order to Hardcover's sort parameter
|
||||
sort_param = SORT_MAPPING.get(options.sort, SORT_MAPPING[SortOrder.RELEVANCE])
|
||||
|
||||
variables = {
|
||||
"query": query,
|
||||
"limit": options.limit,
|
||||
"page": options.page,
|
||||
"sort": sort_param,
|
||||
}
|
||||
|
||||
if search_fields:
|
||||
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 []
|
||||
|
||||
search_data = result.get("search", {})
|
||||
|
||||
# Results is a Typesense response object with hits array
|
||||
results_obj = search_data.get("results", {})
|
||||
if isinstance(results_obj, dict):
|
||||
hits = results_obj.get("hits", [])
|
||||
else:
|
||||
hits = results_obj if isinstance(results_obj, list) else []
|
||||
|
||||
# Parse the search results - each hit has a 'document' field
|
||||
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)
|
||||
|
||||
logger.info(f"Hardcover search '{query}' (fields={search_fields}) returned {len(books)} results")
|
||||
return books
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Hardcover search error: {e}")
|
||||
return []
|
||||
|
||||
@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.
|
||||
"""
|
||||
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
|
||||
graphql_query = """
|
||||
query GetBook($id: Int!) {
|
||||
books(where: {id: {_eq: $id}}, limit: 1) {
|
||||
id
|
||||
title
|
||||
slug
|
||||
release_date
|
||||
headline
|
||||
description
|
||||
pages
|
||||
cached_image
|
||||
cached_contributors
|
||||
cached_tags
|
||||
default_physical_edition {
|
||||
isbn_10
|
||||
isbn_13
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
try:
|
||||
book_id_int = int(book_id)
|
||||
result = self._execute_query(graphql_query, {"id": book_id_int})
|
||||
if not result:
|
||||
return None
|
||||
|
||||
books = result.get("books", [])
|
||||
if not books:
|
||||
return None
|
||||
|
||||
return self._parse_book(books[0])
|
||||
|
||||
except ValueError:
|
||||
logger.error(f"Invalid book ID: {book_id}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Hardcover get_book error: {e}")
|
||||
return None
|
||||
|
||||
@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.
|
||||
"""
|
||||
if not self.api_key:
|
||||
logger.warning("Hardcover API key not configured")
|
||||
return None
|
||||
|
||||
# Clean ISBN (remove hyphens)
|
||||
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
|
||||
graphql_query = """
|
||||
query SearchByISBN($isbn: String!) {
|
||||
editions(
|
||||
where: {
|
||||
_or: [
|
||||
{isbn_10: {_eq: $isbn}},
|
||||
{isbn_13: {_eq: $isbn}}
|
||||
]
|
||||
},
|
||||
limit: 1
|
||||
) {
|
||||
isbn_10
|
||||
isbn_13
|
||||
book {
|
||||
id
|
||||
title
|
||||
slug
|
||||
release_date
|
||||
headline
|
||||
description
|
||||
pages
|
||||
cached_image
|
||||
cached_contributors
|
||||
cached_tags
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
try:
|
||||
result = self._execute_query(graphql_query, {"isbn": clean_isbn})
|
||||
if not result:
|
||||
return None
|
||||
|
||||
editions = result.get("editions", [])
|
||||
if not editions:
|
||||
logger.debug(f"No Hardcover book found for ISBN: {isbn}")
|
||||
return None
|
||||
|
||||
edition = editions[0]
|
||||
book_data = edition.get("book", {})
|
||||
if not book_data:
|
||||
return None
|
||||
|
||||
# Add ISBN data from edition to book data
|
||||
book_data["isbn_10"] = edition.get("isbn_10")
|
||||
book_data["isbn_13"] = edition.get("isbn_13")
|
||||
|
||||
return self._parse_book(book_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Hardcover ISBN search error: {e}")
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
response = self.session.post(
|
||||
HARDCOVER_API_URL,
|
||||
json={"query": query, "variables": variables},
|
||||
timeout=15
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
if "errors" in data:
|
||||
logger.error(f"GraphQL errors: {data['errors']}")
|
||||
return None
|
||||
|
||||
return data.get("data")
|
||||
|
||||
except requests.Timeout:
|
||||
logger.warning("Hardcover API request timed out")
|
||||
return None
|
||||
except requests.HTTPError as e:
|
||||
if e.response.status_code == 401:
|
||||
logger.error("Hardcover API key is invalid")
|
||||
else:
|
||||
logger.error(f"Hardcover API HTTP error: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Hardcover API request failed: {e}")
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
book_id = item.get("id") or item.get("document", {}).get("id")
|
||||
title = item.get("title") or item.get("document", {}).get("title")
|
||||
|
||||
if not book_id or not title:
|
||||
return None
|
||||
|
||||
# Extract authors from various possible fields
|
||||
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")
|
||||
|
||||
# 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
|
||||
|
||||
slug = item.get("slug", "")
|
||||
source_url = f"https://hardcover.app/books/{slug}" if slug else None
|
||||
|
||||
# Build display fields from Hardcover-specific data
|
||||
display_fields = []
|
||||
|
||||
# Rating (e.g., "4.5 (3,764)")
|
||||
rating = item.get("rating")
|
||||
ratings_count = item.get("ratings_count")
|
||||
if rating is not None:
|
||||
rating_str = f"{rating:.1f}"
|
||||
if ratings_count:
|
||||
rating_str += f" ({ratings_count:,})"
|
||||
display_fields.append(DisplayField(label="Rating", value=rating_str, icon="star"))
|
||||
|
||||
# Readers (users who have this book)
|
||||
users_count = item.get("users_count")
|
||||
if users_count:
|
||||
display_fields.append(DisplayField(label="Readers", value=f"{users_count:,}", icon="users"))
|
||||
|
||||
# Combine headline and description if both present
|
||||
headline = item.get("headline")
|
||||
description = item.get("description")
|
||||
full_description = _combine_headline_description(headline, description)
|
||||
|
||||
return BookMetadata(
|
||||
provider="hardcover",
|
||||
provider_id=str(book_id),
|
||||
title=title,
|
||||
provider_display_name="Hardcover",
|
||||
authors=authors,
|
||||
cover_url=cover_url,
|
||||
description=full_description,
|
||||
publish_year=publish_year,
|
||||
source_url=source_url,
|
||||
display_fields=display_fields,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse Hardcover search result: {e}")
|
||||
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
|
||||
authors = []
|
||||
if book.get("cached_contributors"):
|
||||
for contrib in book["cached_contributors"]:
|
||||
if isinstance(contrib, dict) and 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")
|
||||
|
||||
# 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
|
||||
|
||||
# Extract genres from cached_tags
|
||||
genres = []
|
||||
for tag in book.get("cached_tags", []):
|
||||
if isinstance(tag, dict) and tag.get("tag"):
|
||||
genres.append(tag["tag"])
|
||||
elif isinstance(tag, str):
|
||||
genres.append(tag)
|
||||
|
||||
# Get ISBN from direct fields, default_physical_edition, or editions
|
||||
isbn_10 = book.get("isbn_10")
|
||||
isbn_13 = book.get("isbn_13")
|
||||
|
||||
if not isbn_10 and not isbn_13:
|
||||
# Try default_physical_edition first
|
||||
edition = book.get("default_physical_edition")
|
||||
if edition:
|
||||
isbn_10 = edition.get("isbn_10")
|
||||
isbn_13 = edition.get("isbn_13")
|
||||
|
||||
# Fallback to editions array
|
||||
if not isbn_10 and not isbn_13 and book.get("editions"):
|
||||
for ed in book["editions"]:
|
||||
if not isbn_10 and ed.get("isbn_10"):
|
||||
isbn_10 = ed["isbn_10"]
|
||||
if not isbn_13 and ed.get("isbn_13"):
|
||||
isbn_13 = ed["isbn_13"]
|
||||
if isbn_10 and isbn_13:
|
||||
break
|
||||
|
||||
slug = book.get("slug", "")
|
||||
source_url = f"https://hardcover.app/books/{slug}" if slug else None
|
||||
|
||||
# Combine headline and description if both present
|
||||
headline = book.get("headline")
|
||||
description = book.get("description")
|
||||
full_description = _combine_headline_description(headline, description)
|
||||
|
||||
return BookMetadata(
|
||||
provider="hardcover",
|
||||
provider_id=str(book["id"]),
|
||||
title=book["title"],
|
||||
provider_display_name="Hardcover",
|
||||
authors=authors,
|
||||
isbn_10=isbn_10,
|
||||
isbn_13=isbn_13,
|
||||
cover_url=cover_url,
|
||||
description=full_description,
|
||||
publish_year=publish_year,
|
||||
genres=genres,
|
||||
source_url=source_url,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
# Refresh config to pick up any recently saved settings
|
||||
app_config.refresh()
|
||||
|
||||
kwargs = get_provider_kwargs("hardcover")
|
||||
api_key = kwargs.get("api_key")
|
||||
|
||||
# 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}")
|
||||
|
||||
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."}
|
||||
|
||||
if key_len < 100:
|
||||
return {"success": False, "message": f"API key seems too short ({key_len} chars). Expected 500+ chars."}
|
||||
|
||||
try:
|
||||
provider = HardcoverProvider(api_key=api_key)
|
||||
# Use the 'me' query to test connection (recommended by API docs)
|
||||
result = provider._execute_query(
|
||||
"query { me { id, username } }",
|
||||
{}
|
||||
)
|
||||
if result is not None:
|
||||
# Handle both single object and array response formats
|
||||
me_data = result.get("me", {})
|
||||
if isinstance(me_data, list) and me_data:
|
||||
me_data = me_data[0]
|
||||
username = me_data.get("username", "Unknown") if isinstance(me_data, dict) else "Unknown"
|
||||
|
||||
# Save the username for persistent display
|
||||
_save_connected_username(username)
|
||||
|
||||
return {"success": True, "message": f"Connected as: {username}"}
|
||||
else:
|
||||
_save_connected_username(None)
|
||||
return {"success": False, "message": "API request failed - check your API key"}
|
||||
except Exception as e:
|
||||
logger.exception("Hardcover connection test failed")
|
||||
_save_connected_username(None)
|
||||
return {"success": False, "message": f"Connection failed: {str(e)}"}
|
||||
|
||||
|
||||
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
|
||||
|
||||
config = load_config_file("hardcover")
|
||||
if username:
|
||||
config["_connected_username"] = username
|
||||
else:
|
||||
config.pop("_connected_username", None)
|
||||
save_config_file("hardcover", config)
|
||||
|
||||
|
||||
def _get_connected_username() -> Optional[str]:
|
||||
"""Get the stored connected username."""
|
||||
from cwa_book_downloader.core.settings_registry import load_config_file
|
||||
|
||||
config = load_config_file("hardcover")
|
||||
return config.get("_connected_username")
|
||||
|
||||
|
||||
# Hardcover sort options for settings UI
|
||||
_HARDCOVER_SORT_OPTIONS = [
|
||||
{"value": "relevance", "label": "Most relevant"},
|
||||
{"value": "popularity", "label": "Most popular"},
|
||||
{"value": "rating", "label": "Highest rated"},
|
||||
{"value": "newest", "label": "Newest"},
|
||||
{"value": "oldest", "label": "Oldest"},
|
||||
]
|
||||
|
||||
|
||||
@register_settings("hardcover", "Hardcover", icon="book", order=51, group="metadata_providers")
|
||||
def hardcover_settings():
|
||||
"""Hardcover metadata provider settings."""
|
||||
# Check for connected username to show status
|
||||
connected_user = _get_connected_username()
|
||||
test_button_description = f"Connected as: {connected_user}" if connected_user else "Verify your API key works"
|
||||
|
||||
return [
|
||||
HeadingField(
|
||||
key="hardcover_heading",
|
||||
title="Hardcover",
|
||||
description="A modern book tracking and discovery platform with a comprehensive API.",
|
||||
link_url="https://hardcover.app",
|
||||
link_text="hardcover.app",
|
||||
),
|
||||
CheckboxField(
|
||||
key="HARDCOVER_ENABLED",
|
||||
label="Enable Hardcover",
|
||||
description="Enable Hardcover as a metadata provider for book searches",
|
||||
default=False,
|
||||
),
|
||||
PasswordField(
|
||||
key="HARDCOVER_API_KEY",
|
||||
label="API Key",
|
||||
description="Get your API key from hardcover.app/account/api",
|
||||
required=True,
|
||||
env_supported=False, # UI-only setting, no ENV var support
|
||||
),
|
||||
ActionButton(
|
||||
key="test_connection",
|
||||
label="Test Connection",
|
||||
description=test_button_description,
|
||||
style="primary",
|
||||
callback=_test_hardcover_connection,
|
||||
),
|
||||
SelectField(
|
||||
key="HARDCOVER_DEFAULT_SORT",
|
||||
label="Default Sort Order",
|
||||
description="Default sort order for Hardcover search results.",
|
||||
options=_HARDCOVER_SORT_OPTIONS,
|
||||
default="relevance",
|
||||
env_supported=False, # UI-only setting
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,638 @@
|
||||
"""Open Library metadata provider. No API key required, rate limited."""
|
||||
|
||||
import time
|
||||
import threading
|
||||
from collections import deque
|
||||
from typing import Any, Deque, Dict, List, Optional, Union
|
||||
|
||||
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 (
|
||||
register_settings,
|
||||
CheckboxField,
|
||||
SelectField,
|
||||
ActionButton,
|
||||
HeadingField,
|
||||
)
|
||||
from cwa_book_downloader.metadata_providers import (
|
||||
BookMetadata,
|
||||
DisplayField,
|
||||
MetadataProvider,
|
||||
MetadataSearchOptions,
|
||||
SearchType,
|
||||
SortOrder,
|
||||
register_provider,
|
||||
TextSearchField,
|
||||
)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
OPENLIBRARY_BASE_URL = "https://openlibrary.org"
|
||||
COVERS_BASE_URL = "https://covers.openlibrary.org"
|
||||
|
||||
# Rate limiting: Open Library allows ~100 requests per minute
|
||||
# We use a sliding window with 90 requests per 60 seconds for safety margin
|
||||
RATE_LIMIT_REQUESTS = 90
|
||||
RATE_LIMIT_WINDOW_SECONDS = 60
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
wait_time = 0
|
||||
|
||||
# Calculate wait time with lock held
|
||||
with self.lock:
|
||||
now = time.time()
|
||||
cutoff = now - self.window_seconds
|
||||
|
||||
# Remove timestamps outside the window
|
||||
while self.timestamps and self.timestamps[0] < cutoff:
|
||||
self.timestamps.popleft()
|
||||
|
||||
if len(self.timestamps) >= self.max_requests:
|
||||
# Calculate wait time until oldest request falls outside window
|
||||
wait_time = self.timestamps[0] + self.window_seconds - now
|
||||
|
||||
# Sleep outside the lock to avoid blocking other threads
|
||||
if wait_time > 0:
|
||||
logger.debug(f"Rate limited, waiting {wait_time:.2f}s")
|
||||
time.sleep(wait_time)
|
||||
|
||||
# Re-acquire lock and record request
|
||||
with self.lock:
|
||||
# Re-clean timestamps after sleeping
|
||||
now = time.time()
|
||||
cutoff = now - self.window_seconds
|
||||
while self.timestamps and self.timestamps[0] < cutoff:
|
||||
self.timestamps.popleft()
|
||||
|
||||
# Record this request
|
||||
self.timestamps.append(time.time())
|
||||
|
||||
|
||||
# Global rate limiter for Open Library
|
||||
_rate_limiter = RateLimiter(RATE_LIMIT_REQUESTS, RATE_LIMIT_WINDOW_SECONDS)
|
||||
|
||||
|
||||
# Mapping from abstract sort order to Open Library sort parameter
|
||||
# Note: Open Library only supports relevance (default), new, old, random
|
||||
SORT_MAPPING: Dict[str, Optional[str]] = {
|
||||
SortOrder.RELEVANCE: None, # Default (no sort param)
|
||||
SortOrder.NEWEST: "new",
|
||||
SortOrder.OLDEST: "old",
|
||||
# POPULARITY and RATING not supported - will fall back to relevance
|
||||
}
|
||||
|
||||
|
||||
@register_provider("openlibrary")
|
||||
class OpenLibraryProvider(MetadataProvider):
|
||||
"""Open Library metadata provider using REST API."""
|
||||
|
||||
name = "openlibrary"
|
||||
display_name = "Open Library"
|
||||
requires_auth = False
|
||||
supported_sorts = [
|
||||
SortOrder.RELEVANCE,
|
||||
SortOrder.NEWEST,
|
||||
SortOrder.OLDEST,
|
||||
]
|
||||
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):
|
||||
"""Initialize provider."""
|
||||
self.session = requests.Session()
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Open Library is always available (no auth required)."""
|
||||
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.
|
||||
"""
|
||||
# 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 options (include fields 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.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="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.
|
||||
"""
|
||||
_rate_limiter.wait_if_needed()
|
||||
|
||||
# Build query params
|
||||
params: Dict[str, Any] = {
|
||||
"limit": options.limit,
|
||||
"page": options.page,
|
||||
"fields": "key,title,author_name,first_publish_year,cover_i,isbn,publisher,language,subject,ratings_average,ratings_count",
|
||||
}
|
||||
|
||||
# Field-first search: use custom field values when provided
|
||||
author_value = options.fields.get("author", "").strip()
|
||||
title_value = options.fields.get("title", "").strip()
|
||||
|
||||
if author_value or title_value:
|
||||
# Use field-specific search params (Open Library supports both simultaneously)
|
||||
if author_value:
|
||||
params["author"] = author_value
|
||||
if title_value:
|
||||
params["title"] = title_value
|
||||
# Also add general query if provided (for additional filtering)
|
||||
if options.query.strip():
|
||||
params["q"] = options.query
|
||||
elif options.search_type == SearchType.TITLE:
|
||||
params["title"] = options.query
|
||||
elif options.search_type == SearchType.AUTHOR:
|
||||
params["author"] = options.query
|
||||
else:
|
||||
# General search
|
||||
params["q"] = options.query
|
||||
|
||||
# Add sort if supported (fallback to relevance/default if not)
|
||||
sort = SORT_MAPPING.get(options.sort)
|
||||
if sort:
|
||||
params["sort"] = sort
|
||||
|
||||
# Add language preference if specified
|
||||
if options.language:
|
||||
params["lang"] = options.language
|
||||
|
||||
try:
|
||||
response = self.session.get(
|
||||
f"{OPENLIBRARY_BASE_URL}/search.json",
|
||||
params=params,
|
||||
timeout=15
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
books = []
|
||||
for doc in data.get("docs", []):
|
||||
book = self._parse_search_doc(doc)
|
||||
if book:
|
||||
books.append(book)
|
||||
|
||||
logger.info(f"Open Library search '{options.query}' returned {len(books)} results")
|
||||
return books
|
||||
|
||||
except requests.Timeout:
|
||||
logger.warning("Open Library search timed out")
|
||||
return []
|
||||
except requests.HTTPError as e:
|
||||
if e.response.status_code == 503:
|
||||
logger.warning("Open Library service unavailable (503)")
|
||||
else:
|
||||
logger.error(f"Open Library HTTP error: {e}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Open Library search error: {e}")
|
||||
return []
|
||||
|
||||
@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.
|
||||
"""
|
||||
_rate_limiter.wait_if_needed()
|
||||
|
||||
# Normalize the book_id format
|
||||
if not book_id.startswith("OL"):
|
||||
book_id = f"OL{book_id}"
|
||||
if not book_id.endswith("W"):
|
||||
book_id = f"{book_id}W"
|
||||
|
||||
try:
|
||||
response = self.session.get(
|
||||
f"{OPENLIBRARY_BASE_URL}/works/{book_id}.json",
|
||||
timeout=15
|
||||
)
|
||||
response.raise_for_status()
|
||||
work = response.json()
|
||||
|
||||
return self._parse_work(work, book_id)
|
||||
|
||||
except requests.Timeout:
|
||||
logger.warning("Open Library get_book timed out")
|
||||
return None
|
||||
except requests.HTTPError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.debug(f"Open Library work not found: {book_id}")
|
||||
else:
|
||||
logger.error(f"Open Library HTTP error: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Open Library get_book error: {e}")
|
||||
return None
|
||||
|
||||
@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.
|
||||
"""
|
||||
# Clean ISBN
|
||||
clean_isbn = isbn.replace("-", "").strip()
|
||||
|
||||
_rate_limiter.wait_if_needed()
|
||||
|
||||
try:
|
||||
# First try the ISBN API which returns edition data
|
||||
response = self.session.get(
|
||||
f"{OPENLIBRARY_BASE_URL}/isbn/{clean_isbn}.json",
|
||||
timeout=15
|
||||
)
|
||||
response.raise_for_status()
|
||||
edition = response.json()
|
||||
|
||||
# Get the work key for full book info
|
||||
works = edition.get("works", [])
|
||||
if works:
|
||||
work_key = works[0].get("key", "")
|
||||
work_id = work_key.split("/")[-1] if work_key else None
|
||||
|
||||
if work_id:
|
||||
# Fetch full work data
|
||||
book = self.get_book(work_id)
|
||||
if book:
|
||||
# Update with ISBN from edition if not present
|
||||
# Use dataclasses.replace() to avoid mutating cached object
|
||||
from dataclasses import replace
|
||||
updates = {}
|
||||
if not book.isbn_10:
|
||||
isbn_10_list = edition.get("isbn_10", [])
|
||||
if isbn_10_list:
|
||||
updates["isbn_10"] = isbn_10_list[0]
|
||||
if not book.isbn_13:
|
||||
isbn_13_list = edition.get("isbn_13", [])
|
||||
if isbn_13_list:
|
||||
updates["isbn_13"] = isbn_13_list[0]
|
||||
if updates:
|
||||
return replace(book, **updates)
|
||||
return book
|
||||
|
||||
# Fallback: parse edition data directly
|
||||
return self._parse_edition(edition, clean_isbn)
|
||||
|
||||
except requests.HTTPError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.debug(f"Open Library ISBN not found: {isbn}")
|
||||
else:
|
||||
logger.error(f"Open Library ISBN search HTTP error: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Open Library ISBN search error: {e}")
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
# Extract work ID from key
|
||||
key = doc.get("key", "")
|
||||
work_id = key.split("/")[-1] if key else None
|
||||
|
||||
if not work_id or not doc.get("title"):
|
||||
return None
|
||||
|
||||
# Get authors
|
||||
authors = doc.get("author_name", [])
|
||||
if not isinstance(authors, list):
|
||||
authors = [authors] if authors else []
|
||||
|
||||
# Get ISBNs
|
||||
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
|
||||
|
||||
# Get cover URL
|
||||
cover_id = doc.get("cover_i")
|
||||
cover_url = f"{COVERS_BASE_URL}/b/id/{cover_id}-L.jpg" if cover_id else None
|
||||
|
||||
# Get publishers (take first one)
|
||||
publishers = doc.get("publisher", [])
|
||||
publisher = publishers[0] if publishers else None
|
||||
|
||||
# Get languages (take first one)
|
||||
languages = doc.get("language", [])
|
||||
language = languages[0] if languages else None
|
||||
|
||||
# Get subjects as genres (take first 5)
|
||||
subjects = doc.get("subject", [])
|
||||
genres = subjects[:5] if subjects else []
|
||||
|
||||
# Build display fields from Open Library-specific data
|
||||
display_fields = []
|
||||
|
||||
# Rating (if available - not always present)
|
||||
ratings_avg = doc.get("ratings_average")
|
||||
ratings_count = doc.get("ratings_count")
|
||||
if ratings_avg is not None and ratings_avg > 0:
|
||||
rating_str = f"{ratings_avg:.1f}"
|
||||
if ratings_count:
|
||||
rating_str += f" ({ratings_count:,})"
|
||||
display_fields.append(DisplayField(label="Rating", value=rating_str, icon="star"))
|
||||
|
||||
return BookMetadata(
|
||||
provider="openlibrary",
|
||||
provider_id=work_id,
|
||||
title=doc["title"],
|
||||
provider_display_name="Open Library",
|
||||
authors=authors,
|
||||
isbn_10=isbn_10,
|
||||
isbn_13=isbn_13,
|
||||
cover_url=cover_url,
|
||||
publisher=publisher,
|
||||
publish_year=doc.get("first_publish_year"),
|
||||
language=language,
|
||||
genres=genres,
|
||||
source_url=f"{OPENLIBRARY_BASE_URL}/works/{work_id}",
|
||||
display_fields=display_fields,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse Open Library search doc: {e}")
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
title = work.get("title")
|
||||
if not title:
|
||||
return None
|
||||
|
||||
# Get description
|
||||
description = work.get("description")
|
||||
if isinstance(description, dict):
|
||||
description = description.get("value")
|
||||
|
||||
# Get authors (requires additional API calls)
|
||||
authors = []
|
||||
for author_ref in work.get("authors", []):
|
||||
author_key = None
|
||||
if isinstance(author_ref, dict):
|
||||
author_key = author_ref.get("author", {}).get("key")
|
||||
if author_key:
|
||||
author_name = self._get_author_name(author_key)
|
||||
if author_name:
|
||||
authors.append(author_name)
|
||||
|
||||
# Get cover URL from covers array
|
||||
cover_url = None
|
||||
covers = work.get("covers", [])
|
||||
if covers:
|
||||
cover_id = covers[0]
|
||||
cover_url = f"{COVERS_BASE_URL}/b/id/{cover_id}-L.jpg"
|
||||
|
||||
# Get subjects as genres
|
||||
subjects = work.get("subjects", [])
|
||||
genres = subjects[:5] if subjects else []
|
||||
|
||||
return BookMetadata(
|
||||
provider="openlibrary",
|
||||
provider_id=work_id,
|
||||
title=title,
|
||||
provider_display_name="Open Library",
|
||||
authors=authors,
|
||||
cover_url=cover_url,
|
||||
description=description,
|
||||
genres=genres,
|
||||
source_url=f"{OPENLIBRARY_BASE_URL}/works/{work_id}",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse Open Library work: {e}")
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
title = edition.get("title")
|
||||
if not title:
|
||||
return None
|
||||
|
||||
# Get the edition key as ID
|
||||
key = edition.get("key", "")
|
||||
edition_id = key.split("/")[-1] if key else isbn
|
||||
|
||||
# Get ISBNs
|
||||
isbn_10_list = edition.get("isbn_10", [])
|
||||
isbn_13_list = edition.get("isbn_13", [])
|
||||
isbn_10 = isbn_10_list[0] if isbn_10_list else None
|
||||
isbn_13 = isbn_13_list[0] if isbn_13_list else None
|
||||
|
||||
# Get publishers
|
||||
publishers = edition.get("publishers", [])
|
||||
publisher = publishers[0] if publishers else None
|
||||
|
||||
# Get cover URL
|
||||
cover_url = None
|
||||
covers = edition.get("covers", [])
|
||||
if covers:
|
||||
cover_id = covers[0]
|
||||
cover_url = f"{COVERS_BASE_URL}/b/id/{cover_id}-L.jpg"
|
||||
|
||||
# Get publish date and try to extract year
|
||||
publish_year = None
|
||||
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())
|
||||
|
||||
return BookMetadata(
|
||||
provider="openlibrary",
|
||||
provider_id=edition_id,
|
||||
title=title,
|
||||
provider_display_name="Open Library",
|
||||
isbn_10=isbn_10,
|
||||
isbn_13=isbn_13,
|
||||
cover_url=cover_url,
|
||||
publisher=publisher,
|
||||
publish_year=publish_year,
|
||||
source_url=f"{OPENLIBRARY_BASE_URL}{key}" if key else None,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse Open Library edition: {e}")
|
||||
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.
|
||||
"""
|
||||
_rate_limiter.wait_if_needed()
|
||||
|
||||
try:
|
||||
response = self.session.get(
|
||||
f"{OPENLIBRARY_BASE_URL}{author_key}.json",
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
author = response.json()
|
||||
return author.get("name")
|
||||
|
||||
except Exception:
|
||||
# Don't log errors for author lookups - they're supplementary
|
||||
return None
|
||||
|
||||
|
||||
def _test_openlibrary_connection() -> Dict[str, Any]:
|
||||
"""Test the Open Library API connection."""
|
||||
try:
|
||||
provider = OpenLibraryProvider()
|
||||
# Simple API call to test connectivity
|
||||
response = provider.session.get(
|
||||
f"{OPENLIBRARY_BASE_URL}/search.json",
|
||||
params={"q": "test", "limit": 1},
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if "docs" in data:
|
||||
return {"success": True, "message": "Successfully connected to Open Library API"}
|
||||
else:
|
||||
return {"success": False, "message": "Unexpected response from API"}
|
||||
except requests.Timeout:
|
||||
return {"success": False, "message": "Connection timed out"}
|
||||
except requests.RequestException as e:
|
||||
return {"success": False, "message": f"Connection failed: {str(e)}"}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"Error: {str(e)}"}
|
||||
|
||||
|
||||
# Open Library sort options for settings UI
|
||||
_OPENLIBRARY_SORT_OPTIONS = [
|
||||
{"value": "relevance", "label": "Most relevant"},
|
||||
{"value": "newest", "label": "Newest"},
|
||||
{"value": "oldest", "label": "Oldest"},
|
||||
]
|
||||
|
||||
|
||||
@register_settings("openlibrary", "Open Library", icon="library", order=52, group="metadata_providers")
|
||||
def openlibrary_settings():
|
||||
"""Open Library metadata provider settings."""
|
||||
return [
|
||||
HeadingField(
|
||||
key="openlibrary_heading",
|
||||
title="Open Library",
|
||||
description="An initiative of the Internet Archive. A free, open-source library catalog with millions of books. No API key required.",
|
||||
link_url="https://openlibrary.org",
|
||||
link_text="openlibrary.org",
|
||||
),
|
||||
CheckboxField(
|
||||
key="OPENLIBRARY_ENABLED",
|
||||
label="Enable Open Library",
|
||||
description="Enable Open Library as a metadata provider for book searches",
|
||||
default=False,
|
||||
),
|
||||
ActionButton(
|
||||
key="test_connection",
|
||||
label="Test Connection",
|
||||
description="Verify Open Library API is accessible",
|
||||
style="primary",
|
||||
callback=_test_openlibrary_connection,
|
||||
),
|
||||
SelectField(
|
||||
key="OPENLIBRARY_DEFAULT_SORT",
|
||||
label="Default Sort Order",
|
||||
description="Default sort order for Open Library search results.",
|
||||
options=_OPENLIBRARY_SORT_OPTIONS,
|
||||
default="relevance",
|
||||
env_supported=False, # UI-only setting
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,340 @@
|
||||
"""Release source plugin system - base classes and registry."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field, asdict
|
||||
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
|
||||
|
||||
|
||||
class ReleaseProtocol(str, Enum):
|
||||
"""Protocol for downloading a release."""
|
||||
HTTP = "http" # Direct HTTP download
|
||||
TORRENT = "torrent" # BitTorrent
|
||||
NZB = "nzb" # Usenet NZB
|
||||
DCC = "dcc" # IRC DCC
|
||||
|
||||
|
||||
@dataclass
|
||||
class Release:
|
||||
"""A downloadable release - all sources return this same structure."""
|
||||
source: str # "direct", "prowlarr", "irc", etc.
|
||||
source_id: str # ID within that source
|
||||
title: str
|
||||
format: Optional[str] = None
|
||||
language: Optional[str] = None # ISO 639-1 code (e.g., "en", "de", "fr")
|
||||
size: Optional[str] = None
|
||||
size_bytes: Optional[int] = None
|
||||
download_url: Optional[str] = None
|
||||
info_url: Optional[str] = None # Link to release info page (e.g., tracker) - makes title clickable
|
||||
protocol: Optional[ReleaseProtocol] = None
|
||||
indexer: Optional[str] = None # Source name for display
|
||||
seeders: Optional[int] = None # For torrents
|
||||
peers: Optional[str] = None # For torrents: "seeders/leechers" display string
|
||||
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
|
||||
"""
|
||||
status: str # "queued", "resolving", "downloading", "complete", "failed"
|
||||
progress: float # 0-100
|
||||
status_message: Optional[str] = None
|
||||
download_speed: Optional[int] = None
|
||||
eta: Optional[int] = None
|
||||
save_path: Optional[str] = None
|
||||
|
||||
|
||||
# --- Column Schema for Plugin-Driven UI ---
|
||||
|
||||
class ColumnRenderType(str, Enum):
|
||||
"""How the frontend should render the column value."""
|
||||
TEXT = "text" # Plain text
|
||||
BADGE = "badge" # Colored badge (format, language)
|
||||
SIZE = "size" # File size formatting
|
||||
NUMBER = "number" # Numeric value
|
||||
PEERS = "peers" # Peers display: "S/L" with color based on seeder count
|
||||
|
||||
|
||||
class ColumnAlign(str, Enum):
|
||||
"""Column alignment options."""
|
||||
LEFT = "left"
|
||||
CENTER = "center"
|
||||
RIGHT = "right"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ColumnColorHint:
|
||||
"""Color hint for badge-type columns."""
|
||||
type: Literal["map", "static"] # "map" uses frontend colorMaps, "static" is fixed class
|
||||
value: str # Map name ("format", "language") or Tailwind class
|
||||
|
||||
|
||||
@dataclass
|
||||
class ColumnSchema:
|
||||
"""Definition for a single column in the release list."""
|
||||
key: str # Data path (e.g., "format", "extra.language")
|
||||
label: str # Accessibility label
|
||||
render_type: ColumnRenderType = ColumnRenderType.TEXT
|
||||
align: ColumnAlign = ColumnAlign.LEFT
|
||||
width: str = "auto" # CSS width (e.g., "80px", "minmax(0,2fr)")
|
||||
hide_mobile: bool = False # Hide on small screens
|
||||
color_hint: Optional[ColumnColorHint] = None # For BADGE render type
|
||||
fallback: str = "-" # Value to show when data is missing
|
||||
uppercase: bool = False # Force uppercase display
|
||||
|
||||
|
||||
class LeadingCellType(str, Enum):
|
||||
"""Type of leading cell to display in release rows."""
|
||||
THUMBNAIL = "thumbnail" # Show book cover image
|
||||
BADGE = "badge" # Show colored badge (e.g., "Torrent", "Usenet")
|
||||
NONE = "none" # No leading cell
|
||||
|
||||
|
||||
@dataclass
|
||||
class LeadingCellConfig:
|
||||
"""Configuration for the leading cell in release rows."""
|
||||
type: LeadingCellType = LeadingCellType.THUMBNAIL
|
||||
key: Optional[str] = None # Field path for data (e.g., "extra.preview" or "extra.download_type")
|
||||
color_hint: Optional[ColumnColorHint] = None # For badge type - maps values to colors
|
||||
uppercase: bool = False # Force uppercase for badge text
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
def serialize_column_config(config: ReleaseColumnConfig) -> Dict[str, Any]:
|
||||
"""Serialize column configuration for API response."""
|
||||
result: Dict[str, Any] = {
|
||||
"columns": [
|
||||
{
|
||||
"key": col.key,
|
||||
"label": col.label,
|
||||
"render_type": col.render_type.value,
|
||||
"align": col.align.value,
|
||||
"width": col.width,
|
||||
"hide_mobile": col.hide_mobile,
|
||||
"color_hint": {
|
||||
"type": col.color_hint.type,
|
||||
"value": col.color_hint.value
|
||||
} if col.color_hint else None,
|
||||
"fallback": col.fallback,
|
||||
"uppercase": col.uppercase,
|
||||
}
|
||||
for col in config.columns
|
||||
],
|
||||
"grid_template": config.grid_template,
|
||||
}
|
||||
|
||||
# Include leading_cell config if specified
|
||||
if config.leading_cell:
|
||||
result["leading_cell"] = {
|
||||
"type": config.leading_cell.type.value,
|
||||
"key": config.leading_cell.key,
|
||||
"color_hint": {
|
||||
"type": config.leading_cell.color_hint.type,
|
||||
"value": config.leading_cell.color_hint.value
|
||||
} if config.leading_cell.color_hint else None,
|
||||
"uppercase": config.leading_cell.uppercase,
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _default_column_config() -> ReleaseColumnConfig:
|
||||
"""Default column configuration used when source doesn't define its own."""
|
||||
return ReleaseColumnConfig(
|
||||
columns=[
|
||||
ColumnSchema(
|
||||
key="extra.language",
|
||||
label="Language",
|
||||
render_type=ColumnRenderType.BADGE,
|
||||
align=ColumnAlign.CENTER,
|
||||
width="60px",
|
||||
hide_mobile=False, # Language shown on mobile
|
||||
color_hint=ColumnColorHint(type="map", value="language"),
|
||||
uppercase=True,
|
||||
),
|
||||
ColumnSchema(
|
||||
key="format",
|
||||
label="Format",
|
||||
render_type=ColumnRenderType.BADGE,
|
||||
align=ColumnAlign.CENTER,
|
||||
width="80px",
|
||||
hide_mobile=False, # Format shown on mobile
|
||||
color_hint=ColumnColorHint(type="map", value="format"),
|
||||
uppercase=True,
|
||||
),
|
||||
ColumnSchema(
|
||||
key="size",
|
||||
label="Size",
|
||||
render_type=ColumnRenderType.SIZE,
|
||||
align=ColumnAlign.CENTER,
|
||||
width="80px",
|
||||
hide_mobile=False, # Size shown on mobile
|
||||
),
|
||||
],
|
||||
grid_template="minmax(0,2fr) 60px 80px 80px"
|
||||
)
|
||||
|
||||
|
||||
class ReleaseSource(ABC):
|
||||
"""Interface for searching a release source."""
|
||||
name: str # "direct", "prowlarr"
|
||||
display_name: str # "Direct Download", "Prowlarr"
|
||||
|
||||
@abstractmethod
|
||||
def search(self, book: BookMetadata) -> List[Release]:
|
||||
"""Search for releases of a book."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def is_available(self) -> bool:
|
||||
"""Check if this source is configured and reachable."""
|
||||
pass
|
||||
|
||||
@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).
|
||||
"""
|
||||
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
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def download(
|
||||
self,
|
||||
task: DownloadTask,
|
||||
cancel_flag: Event,
|
||||
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
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def cancel(self, task_id: str) -> bool:
|
||||
"""Cancel an in-progress download."""
|
||||
pass
|
||||
|
||||
|
||||
# --- Registry ---
|
||||
|
||||
_SOURCES: Dict[str, Type[ReleaseSource]] = {}
|
||||
_HANDLERS: Dict[str, Type[DownloadHandler]] = {}
|
||||
|
||||
|
||||
def register_source(name: str):
|
||||
"""Decorator to register a release source."""
|
||||
def decorator(cls):
|
||||
_SOURCES[name] = cls
|
||||
return cls
|
||||
return decorator
|
||||
|
||||
|
||||
def register_handler(name: str):
|
||||
"""Decorator to register a download handler."""
|
||||
def decorator(cls):
|
||||
_HANDLERS[name] = cls
|
||||
return cls
|
||||
return decorator
|
||||
|
||||
|
||||
def get_source(name: str) -> ReleaseSource:
|
||||
"""Get a release source instance by name."""
|
||||
if name not in _SOURCES:
|
||||
raise ValueError(f"Unknown release source: {name}")
|
||||
return _SOURCES[name]()
|
||||
|
||||
|
||||
def get_handler(name: str) -> DownloadHandler:
|
||||
"""Get a download handler instance by name."""
|
||||
if name not in _HANDLERS:
|
||||
raise ValueError(f"Unknown download handler: {name}")
|
||||
return _HANDLERS[name]()
|
||||
|
||||
|
||||
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()
|
||||
]
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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
|
||||
@@ -1,3 +1,4 @@
|
||||
# Local development - builds from source with debug enabled
|
||||
services:
|
||||
calibre-web-automated-book-downloader-dev:
|
||||
extends:
|
||||
@@ -9,9 +10,8 @@ services:
|
||||
target: cwa-bd
|
||||
environment:
|
||||
DEBUG: true
|
||||
APP_ENV: dev
|
||||
USE_DOH: true
|
||||
CUSTOM_DNS: cloudflare
|
||||
volumes:
|
||||
- /tmp/cwa-book-downloader:/tmp/cwa-book-downloader
|
||||
- /tmp/cwa-book-downloader-log:/var/log/cwa-book-downloader
|
||||
- ./.local/config:/config
|
||||
- ./.local/ingest:/cwa-book-ingest
|
||||
- ./.local/log:/var/log/cwa-book-downloader
|
||||
- ./.local/tmp:/tmp/cwa-book-downloader
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# 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
|
||||
@@ -0,0 +1,20 @@
|
||||
# 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
|
||||
@@ -1,3 +1,4 @@
|
||||
# Local development - Tor variant
|
||||
services:
|
||||
calibre-web-automated-book-downloader-tor-dev:
|
||||
extends:
|
||||
@@ -9,7 +10,8 @@ services:
|
||||
target: cwa-bd-tor
|
||||
environment:
|
||||
DEBUG: true
|
||||
APP_ENV: dev
|
||||
volumes:
|
||||
- /tmp/cwa-book-downloader:/tmp/cwa-book-downloader
|
||||
- /tmp/cwa-book-downloader-log:/var/log/cwa-book-downloader
|
||||
- ./.local/config:/config
|
||||
- ./.local/ingest:/cwa-book-ingest
|
||||
- ./.local/log:/var/log/cwa-book-downloader
|
||||
- ./.local/tmp:/tmp/cwa-book-downloader
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
# 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
|
||||
LOG_LEVEL: info
|
||||
BOOK_LANGUAGE: en
|
||||
USE_BOOK_TITLE: true
|
||||
TZ: America/New_York
|
||||
USING_TOR: true
|
||||
APP_ENV: prod
|
||||
# CWA_DB_PATH: /auth/app.db
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
@@ -16,6 +14,6 @@ services:
|
||||
- 8084:8084
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# This is where the books will be downloaded to, usually it would be
|
||||
# the same as whatever you gave in "calibre-web-automated"
|
||||
- /tmp/data/calibre-web/ingest:/cwa-book-ingest
|
||||
- /path/to/config:/config
|
||||
# - /cwa/config/path/app.db:/auth/app.db:ro
|
||||
|
||||
@@ -1,30 +1,15 @@
|
||||
services:
|
||||
calibre-web-automated-book-downloader:
|
||||
image: ghcr.io/calibrain/calibre-web-automated-book-downloader:latest
|
||||
# Uncomment to build the image from the Dockerfile for local testing changes.
|
||||
# Remember to comment out the image line above.
|
||||
#build: .
|
||||
container_name: calibre-web-automated-book-downloader
|
||||
environment:
|
||||
FLASK_PORT: 8084
|
||||
LOG_LEVEL: info
|
||||
BOOK_LANGUAGE: en
|
||||
USE_BOOK_TITLE: true
|
||||
TZ: America/New_York
|
||||
APP_ENV: prod
|
||||
UID: 1000
|
||||
GID: 100
|
||||
# CWA_DB_PATH: /auth/app.db # Comment out to disable authentication
|
||||
# Queue management settings
|
||||
MAX_CONCURRENT_DOWNLOADS: 3
|
||||
DOWNLOAD_PROGRESS_UPDATE_INTERVAL: 5
|
||||
# UID: 1000
|
||||
# GID: 100
|
||||
# CWA_DB_PATH: /auth/app.db
|
||||
ports:
|
||||
- 8084:8084
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# This is where the books will be downloaded to, usually it would be
|
||||
# the same as whatever you gave in "calibre-web-automated"
|
||||
- /tmp/data/calibre-web/ingest:/cwa-book-ingest
|
||||
# This is the location of CWA's app.db, which contains authentication
|
||||
# details. Comment out to disable authentication
|
||||
#- /cwa/config/path/app.db:/auth/app.db:ro
|
||||
- /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
|
||||
|
||||
@@ -0,0 +1,628 @@
|
||||
# Plugin Settings Integration Guide
|
||||
|
||||
This guide explains how to add configuration settings to plugins (Metadata Providers and Release Sources) so they appear in the Settings UI.
|
||||
|
||||
## Overview
|
||||
|
||||
The settings system uses a decorator-based registration pattern. Plugins register their settings when their module is imported, and the frontend dynamically renders the appropriate UI based on the schema provided by the backend.
|
||||
|
||||
**Key features:**
|
||||
- Settings are defined in Python and automatically rendered in the React frontend
|
||||
- Values persist across container restarts via JSON config files
|
||||
- Changes take effect immediately without restart (unless marked otherwise)
|
||||
|
||||
## Quick Start
|
||||
|
||||
Add settings to your plugin in 3 steps:
|
||||
|
||||
```python
|
||||
from cwa_book_downloader.core.settings_registry import (
|
||||
register_settings,
|
||||
TextField,
|
||||
PasswordField,
|
||||
ActionButton,
|
||||
)
|
||||
|
||||
@register_settings(
|
||||
name="my_plugin", # Unique identifier
|
||||
display_name="My Plugin", # Shown in sidebar
|
||||
icon="wrench", # Icon name
|
||||
order=100, # Sort order (lower = higher in list)
|
||||
group="metadata_providers" # Optional: group in sidebar
|
||||
)
|
||||
def my_plugin_settings():
|
||||
return [
|
||||
PasswordField(
|
||||
key="MY_PLUGIN_API_KEY",
|
||||
label="API Key",
|
||||
description="Your API key from the provider",
|
||||
required=True,
|
||||
),
|
||||
ActionButton(
|
||||
key="test_connection",
|
||||
label="Test Connection",
|
||||
style="primary",
|
||||
callback=_test_connection,
|
||||
),
|
||||
]
|
||||
|
||||
def _test_connection():
|
||||
# Perform connection test
|
||||
return {"success": True, "message": "Connected successfully!"}
|
||||
```
|
||||
|
||||
## Available Field Types
|
||||
|
||||
### TextField
|
||||
|
||||
Single-line text input for strings.
|
||||
|
||||
```python
|
||||
TextField(
|
||||
key="MY_SETTING", # Config key
|
||||
label="Setting Name", # Display label
|
||||
description="Help text", # Optional description below field
|
||||
default="", # Default value
|
||||
placeholder="Enter value", # Placeholder text
|
||||
max_length=100, # Optional max characters
|
||||
required=False, # Is this field required?
|
||||
requires_restart=False, # Does changing this need a restart?
|
||||
show_when=None, # Conditional visibility (see below)
|
||||
disabled_when=None, # Conditional disable (see below)
|
||||
)
|
||||
```
|
||||
|
||||
### PasswordField
|
||||
|
||||
Masked input for sensitive values (API keys, passwords). Values are never echoed back to the frontend.
|
||||
|
||||
```python
|
||||
PasswordField(
|
||||
key="API_KEY",
|
||||
label="API Key",
|
||||
description="Your secret API key",
|
||||
placeholder="sk-...",
|
||||
required=True,
|
||||
)
|
||||
```
|
||||
|
||||
### NumberField
|
||||
|
||||
Numeric input with optional min/max constraints.
|
||||
|
||||
```python
|
||||
NumberField(
|
||||
key="TIMEOUT",
|
||||
label="Timeout (seconds)",
|
||||
description="Connection timeout in seconds",
|
||||
default=30,
|
||||
min_value=5,
|
||||
max_value=300,
|
||||
step=1, # Increment step
|
||||
required=False,
|
||||
)
|
||||
```
|
||||
|
||||
### CheckboxField
|
||||
|
||||
Toggle switch for boolean values.
|
||||
|
||||
```python
|
||||
CheckboxField(
|
||||
key="ENABLE_FEATURE",
|
||||
label="Enable Feature",
|
||||
description="Turn this feature on or off",
|
||||
default=False,
|
||||
)
|
||||
```
|
||||
|
||||
### SelectField
|
||||
|
||||
Dropdown for single-choice selection.
|
||||
|
||||
```python
|
||||
SelectField(
|
||||
key="LOG_LEVEL",
|
||||
label="Log Level",
|
||||
description="Logging verbosity",
|
||||
default="info",
|
||||
options=[
|
||||
{"value": "debug", "label": "Debug"},
|
||||
{"value": "info", "label": "Info"},
|
||||
{"value": "warning", "label": "Warning"},
|
||||
{"value": "error", "label": "Error"},
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
### MultiSelectField
|
||||
|
||||
Multi-choice selection from a list of options.
|
||||
|
||||
```python
|
||||
MultiSelectField(
|
||||
key="SUPPORTED_FORMATS",
|
||||
label="Supported Formats",
|
||||
description="Select which formats to support",
|
||||
default=["epub", "mobi"],
|
||||
options=[
|
||||
{"value": "epub", "label": "EPUB"},
|
||||
{"value": "mobi", "label": "MOBI"},
|
||||
{"value": "pdf", "label": "PDF"},
|
||||
{"value": "azw3", "label": "AZW3"},
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
### ActionButton
|
||||
|
||||
Button that executes a callback function. Does not store a value.
|
||||
|
||||
```python
|
||||
ActionButton(
|
||||
key="test_connection", # Unique key for the action
|
||||
label="Test Connection", # Button text
|
||||
description="Test the API connection",
|
||||
style="primary", # "default", "primary", or "danger"
|
||||
callback=my_callback_fn, # Function to execute
|
||||
)
|
||||
|
||||
def my_callback_fn():
|
||||
"""Callback must return dict with 'success' and 'message' keys."""
|
||||
try:
|
||||
# Perform action
|
||||
return {"success": True, "message": "Connection successful!"}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"Failed: {str(e)}"}
|
||||
```
|
||||
|
||||
### HeadingField
|
||||
|
||||
Display-only section heading with optional link. Does not store a value.
|
||||
|
||||
```python
|
||||
HeadingField(
|
||||
key="section_heading", # Unique key
|
||||
title="Configuration", # Heading text
|
||||
description="Configure the plugin settings below",
|
||||
link_url="https://example.com/docs", # Optional link
|
||||
link_text="View Documentation", # Link text
|
||||
)
|
||||
```
|
||||
|
||||
## Common Field Properties
|
||||
|
||||
All field types support these common properties:
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `key` | `str` | Required | Unique identifier for this setting |
|
||||
| `label` | `str` | Required | Display label in the UI |
|
||||
| `description` | `str` | `""` | Help text shown below the field |
|
||||
| `default` | `Any` | `None` | Default value if not set |
|
||||
| `required` | `bool` | `False` | Whether the field must have a value |
|
||||
| `disabled` | `bool` | `False` | Disable the field (greyed out) |
|
||||
| `disabled_reason` | `str` | `""` | Explanation shown when disabled |
|
||||
| `requires_restart` | `bool` | `False` | Whether changes require container restart |
|
||||
| `show_when` | `dict` | `None` | Conditional visibility (see below) |
|
||||
| `disabled_when` | `dict` | `None` | Conditional disable (see below) |
|
||||
|
||||
## Conditional Visibility
|
||||
|
||||
Fields can be shown/hidden based on other field values using `show_when`:
|
||||
|
||||
```python
|
||||
# Only show DNS servers field when custom DNS is selected
|
||||
TextField(
|
||||
key="CUSTOM_DNS_SERVERS",
|
||||
label="DNS Servers",
|
||||
description="Comma-separated DNS server IPs",
|
||||
show_when={"field": "DNS_PROVIDER", "value": "manual"},
|
||||
)
|
||||
```
|
||||
|
||||
The field will only be visible when the referenced field has the specified value.
|
||||
|
||||
## Conditional Disable
|
||||
|
||||
Fields can be enabled/disabled based on other field values using `disabled_when`:
|
||||
|
||||
```python
|
||||
# Disable timeout field when feature is disabled
|
||||
NumberField(
|
||||
key="FEATURE_TIMEOUT",
|
||||
label="Timeout (seconds)",
|
||||
description="Request timeout",
|
||||
default=30,
|
||||
disabled_when={
|
||||
"field": "FEATURE_ENABLED",
|
||||
"value": False,
|
||||
"reason": "Enable the feature first"
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
The field will be greyed out with the specified reason when the condition is met.
|
||||
|
||||
## Settings Groups
|
||||
|
||||
Register a group to organize related settings tabs in the sidebar:
|
||||
|
||||
```python
|
||||
from cwa_book_downloader.core.settings_registry import register_group
|
||||
|
||||
# Register a group (do this once, usually in a central config file)
|
||||
register_group(
|
||||
name="my_group",
|
||||
display_name="My Group",
|
||||
icon="folder",
|
||||
order=50,
|
||||
)
|
||||
|
||||
# Then register settings to the group
|
||||
@register_settings(
|
||||
name="plugin_a",
|
||||
display_name="Plugin A",
|
||||
icon="puzzle",
|
||||
order=51,
|
||||
group="my_group", # Assigns to the group
|
||||
)
|
||||
def plugin_a_settings():
|
||||
return [...]
|
||||
```
|
||||
|
||||
**Existing groups:**
|
||||
- `direct_download` (order=20): For download-related settings
|
||||
- `metadata_providers` (order=50): For metadata provider plugins
|
||||
|
||||
## Value Resolution Priority
|
||||
|
||||
Settings values are resolved in this order (highest priority first):
|
||||
|
||||
1. **Config File** - Stored in `CONFIG_DIR/plugins/<tab_name>.json`
|
||||
2. **Field Default** - Value specified in the field definition
|
||||
|
||||
The `general` tab uses `CONFIG_DIR/settings.json` instead of the plugins subdirectory.
|
||||
|
||||
## Reading Setting Values
|
||||
|
||||
Use the `config` singleton to read setting values in your plugin code:
|
||||
|
||||
```python
|
||||
from cwa_book_downloader.core.config import config
|
||||
|
||||
# Get a setting value with default fallback
|
||||
api_key = config.get("MY_PLUGIN_API_KEY", "")
|
||||
timeout = config.get("MY_PLUGIN_TIMEOUT", 30)
|
||||
|
||||
# Or access as attributes (raises AttributeError if not found)
|
||||
api_key = config.MY_PLUGIN_API_KEY
|
||||
|
||||
# Check all cached settings
|
||||
all_settings = config.get_all()
|
||||
```
|
||||
|
||||
The config singleton:
|
||||
- Automatically resolves values from config files with field defaults as fallback
|
||||
- Caches values for performance
|
||||
- Refreshes automatically when settings are updated via the UI
|
||||
|
||||
## Complete Example: Metadata Provider
|
||||
|
||||
Here's a complete example for a metadata provider plugin:
|
||||
|
||||
```python
|
||||
# cwa_book_downloader/metadata_providers/my_provider.py
|
||||
|
||||
from cwa_book_downloader.metadata_providers.base import (
|
||||
MetadataProvider,
|
||||
register_provider,
|
||||
)
|
||||
from cwa_book_downloader.core.settings_registry import (
|
||||
register_settings,
|
||||
HeadingField,
|
||||
TextField,
|
||||
PasswordField,
|
||||
CheckboxField,
|
||||
ActionButton,
|
||||
)
|
||||
from cwa_book_downloader.core.config import config
|
||||
|
||||
|
||||
def _test_connection():
|
||||
"""Test API connection callback."""
|
||||
api_key = config.get("MY_PROVIDER_API_KEY", "")
|
||||
if not api_key:
|
||||
return {"success": False, "message": "API key not configured"}
|
||||
|
||||
try:
|
||||
# Perform actual connection test
|
||||
# response = requests.get(...)
|
||||
return {"success": True, "message": "Connected to My Provider API"}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"Connection failed: {str(e)}"}
|
||||
|
||||
|
||||
@register_settings(
|
||||
name="my_provider",
|
||||
display_name="My Provider",
|
||||
icon="book",
|
||||
order=53,
|
||||
group="metadata_providers",
|
||||
)
|
||||
def my_provider_settings():
|
||||
"""Define settings for this metadata provider."""
|
||||
return [
|
||||
HeadingField(
|
||||
key="my_provider_heading",
|
||||
title="My Provider",
|
||||
description="A metadata provider for book information",
|
||||
link_url="https://myprovider.com",
|
||||
link_text="Visit My Provider",
|
||||
),
|
||||
PasswordField(
|
||||
key="MY_PROVIDER_API_KEY",
|
||||
label="API Key",
|
||||
description="Your My Provider API key",
|
||||
placeholder="Enter your API key",
|
||||
required=True,
|
||||
),
|
||||
CheckboxField(
|
||||
key="MY_PROVIDER_INCLUDE_COVERS",
|
||||
label="Include Cover Images",
|
||||
description="Fetch cover images when searching",
|
||||
default=True,
|
||||
),
|
||||
TextField(
|
||||
key="MY_PROVIDER_BASE_URL",
|
||||
label="API Base URL",
|
||||
description="Override the default API endpoint",
|
||||
default="https://api.myprovider.com/v1",
|
||||
required=False,
|
||||
),
|
||||
ActionButton(
|
||||
key="test_connection",
|
||||
label="Test Connection",
|
||||
description="Verify your API key works",
|
||||
style="primary",
|
||||
callback=_test_connection,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@register_provider("my_provider")
|
||||
class MyProvider(MetadataProvider):
|
||||
"""My Provider metadata implementation."""
|
||||
|
||||
name = "my_provider"
|
||||
display_name = "My Provider"
|
||||
requires_auth = True
|
||||
|
||||
def __init__(self, api_key: str = None):
|
||||
self.api_key = api_key or config.get("MY_PROVIDER_API_KEY", "")
|
||||
self.base_url = config.get(
|
||||
"MY_PROVIDER_BASE_URL",
|
||||
"https://api.myprovider.com/v1"
|
||||
)
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return bool(self.api_key)
|
||||
|
||||
def search(self, query: str):
|
||||
# Implementation...
|
||||
pass
|
||||
|
||||
def get_book(self, book_id: str):
|
||||
# Implementation...
|
||||
pass
|
||||
```
|
||||
|
||||
## Complete Example: Release Source
|
||||
|
||||
Here's a complete example for a release source plugin:
|
||||
|
||||
```python
|
||||
# cwa_book_downloader/release_sources/my_source.py
|
||||
|
||||
from cwa_book_downloader.release_sources.base import (
|
||||
ReleaseSource,
|
||||
DownloadHandler,
|
||||
register_source,
|
||||
register_handler,
|
||||
)
|
||||
from cwa_book_downloader.core.settings_registry import (
|
||||
register_settings,
|
||||
HeadingField,
|
||||
TextField,
|
||||
NumberField,
|
||||
CheckboxField,
|
||||
SelectField,
|
||||
ActionButton,
|
||||
)
|
||||
from cwa_book_downloader.core.config import config
|
||||
|
||||
|
||||
def _test_source():
|
||||
"""Test source availability callback."""
|
||||
base_url = config.get("MY_SOURCE_URL", "https://mysource.com")
|
||||
try:
|
||||
# Test connectivity
|
||||
return {"success": True, "message": f"Source available at {base_url}"}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"Source unavailable: {str(e)}"}
|
||||
|
||||
|
||||
@register_settings(
|
||||
name="my_source",
|
||||
display_name="My Source",
|
||||
icon="download",
|
||||
order=25,
|
||||
group="direct_download",
|
||||
)
|
||||
def my_source_settings():
|
||||
"""Define settings for this release source."""
|
||||
return [
|
||||
HeadingField(
|
||||
key="my_source_heading",
|
||||
title="My Source Configuration",
|
||||
description="Configure the My Source download provider",
|
||||
),
|
||||
CheckboxField(
|
||||
key="MY_SOURCE_ENABLED",
|
||||
label="Enable My Source",
|
||||
description="Include My Source in download fallback chain",
|
||||
default=True,
|
||||
),
|
||||
TextField(
|
||||
key="MY_SOURCE_URL",
|
||||
label="Source URL",
|
||||
description="Base URL for the source",
|
||||
default="https://mysource.com",
|
||||
show_when={"field": "MY_SOURCE_ENABLED", "value": True},
|
||||
),
|
||||
NumberField(
|
||||
key="MY_SOURCE_TIMEOUT",
|
||||
label="Timeout (seconds)",
|
||||
description="Request timeout",
|
||||
default=30,
|
||||
min_value=10,
|
||||
max_value=120,
|
||||
show_when={"field": "MY_SOURCE_ENABLED", "value": True},
|
||||
),
|
||||
SelectField(
|
||||
key="MY_SOURCE_PRIORITY",
|
||||
label="Priority",
|
||||
description="Where in the fallback chain to try this source",
|
||||
default="normal",
|
||||
options=[
|
||||
{"value": "high", "label": "High (try first)"},
|
||||
{"value": "normal", "label": "Normal"},
|
||||
{"value": "low", "label": "Low (try last)"},
|
||||
],
|
||||
show_when={"field": "MY_SOURCE_ENABLED", "value": True},
|
||||
),
|
||||
ActionButton(
|
||||
key="test_source",
|
||||
label="Test Source",
|
||||
description="Check if the source is accessible",
|
||||
style="primary",
|
||||
callback=_test_source,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@register_source("my_source")
|
||||
class MySource(ReleaseSource):
|
||||
"""My Source release source implementation."""
|
||||
|
||||
name = "my_source"
|
||||
display_name = "My Source"
|
||||
|
||||
def __init__(self):
|
||||
self.enabled = config.get("MY_SOURCE_ENABLED", True)
|
||||
self.base_url = config.get("MY_SOURCE_URL", "https://mysource.com")
|
||||
self.timeout = config.get("MY_SOURCE_TIMEOUT", 30)
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return self.enabled
|
||||
|
||||
def search(self, book):
|
||||
# Implementation...
|
||||
pass
|
||||
|
||||
|
||||
@register_handler("my_source")
|
||||
class MySourceHandler(DownloadHandler):
|
||||
"""Handler for downloading from My Source."""
|
||||
|
||||
name = "my_source"
|
||||
|
||||
def download(self, release, output_path):
|
||||
# Implementation...
|
||||
pass
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use descriptive keys**: Keys should be uppercase and prefixed with your plugin name (e.g., `MY_PLUGIN_API_KEY`)
|
||||
|
||||
2. **Provide helpful descriptions**: Include enough detail in descriptions to help users understand what each setting does
|
||||
|
||||
3. **Set sensible defaults**: Users should be able to get started without configuring everything
|
||||
|
||||
4. **Use conditional visibility**: Hide advanced options behind enabling checkboxes to reduce UI clutter
|
||||
|
||||
5. **Include a test button**: ActionButtons that test connections help users verify their configuration
|
||||
|
||||
6. **Mark restart-required settings**: Use `requires_restart=True` for settings that can't be applied live
|
||||
|
||||
7. **Group related settings**: Use HeadingField to visually separate sections, and put plugins in appropriate groups
|
||||
|
||||
8. **Handle missing values gracefully**: Always provide fallbacks when reading settings in your code
|
||||
|
||||
## API Reference
|
||||
|
||||
### Backend Routes
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/settings` | Get all settings tabs, groups, and values |
|
||||
| GET | `/api/settings/<tab_name>` | Get a specific settings tab |
|
||||
| PUT | `/api/settings/<tab_name>` | Update settings for a tab |
|
||||
| POST | `/api/settings/<tab_name>/action/<action_key>` | Execute an action button callback |
|
||||
|
||||
### Response Format
|
||||
|
||||
**GET /api/settings**
|
||||
```json
|
||||
{
|
||||
"groups": [
|
||||
{"name": "direct_download", "displayName": "Direct Download", "icon": "download", "order": 20}
|
||||
],
|
||||
"tabs": [
|
||||
{
|
||||
"name": "my_plugin",
|
||||
"displayName": "My Plugin",
|
||||
"icon": "book",
|
||||
"order": 53,
|
||||
"group": "metadata_providers",
|
||||
"fields": [
|
||||
{
|
||||
"type": "password",
|
||||
"key": "MY_PLUGIN_API_KEY",
|
||||
"label": "API Key",
|
||||
"description": "Your API key",
|
||||
"hasValue": true,
|
||||
"value": "",
|
||||
"required": true,
|
||||
"disabled": false,
|
||||
"requiresRestart": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**PUT /api/settings/<tab_name>**
|
||||
```json
|
||||
// Request
|
||||
{"MY_PLUGIN_API_KEY": "new-value", "MY_PLUGIN_TIMEOUT": 60}
|
||||
|
||||
// Response
|
||||
{
|
||||
"success": true,
|
||||
"message": "Settings updated",
|
||||
"updated": ["MY_PLUGIN_API_KEY", "MY_PLUGIN_TIMEOUT"],
|
||||
"requiresRestart": false
|
||||
}
|
||||
```
|
||||
|
||||
**POST /api/settings/<tab_name>/action/<action_key>**
|
||||
```json
|
||||
// Response
|
||||
{
|
||||
"success": true,
|
||||
"message": "Connection successful!"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,75 @@
|
||||
# URL Search Parameters
|
||||
|
||||
You can trigger searches directly via URL by adding query parameters. This enables bookmarking searches and sharing links.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```
|
||||
http://your-server:8084/?q=harry+potter
|
||||
```
|
||||
|
||||
## Supported Parameters
|
||||
|
||||
| Parameter | Description | Example |
|
||||
|-----------|-------------|---------|
|
||||
| `q` or `query` | Main search query | `/?q=dune` |
|
||||
| `author` | Filter by author name | `/?author=frank+herbert` |
|
||||
| `title` | Filter by book title | `/?title=foundation` |
|
||||
| `isbn` | Filter by ISBN | `/?isbn=978-0747532699` |
|
||||
| `lang` | Filter by language (ISO 639-1 code) | `/?lang=en` |
|
||||
| `format` | Filter by file format | `/?format=epub` |
|
||||
| `content` | Filter by content type | `/?content=fiction` |
|
||||
| `sort` | Sort order for results | `/?sort=newest` |
|
||||
|
||||
## Multiple Values
|
||||
|
||||
Some parameters support multiple values by repeating the parameter:
|
||||
|
||||
```
|
||||
/?lang=en&lang=de&lang=fr
|
||||
/?format=epub&format=mobi&format=azw3
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
**Simple search:**
|
||||
```
|
||||
/?q=lord+of+the+rings
|
||||
```
|
||||
|
||||
**Search with author filter:**
|
||||
```
|
||||
/?q=dune&author=frank+herbert
|
||||
```
|
||||
|
||||
**Search with format and language:**
|
||||
```
|
||||
/?q=harry+potter&format=epub&lang=en
|
||||
```
|
||||
|
||||
**Author search with multiple formats:**
|
||||
```
|
||||
/?author=stephen+king&format=epub&format=mobi
|
||||
```
|
||||
|
||||
**Search with sort order:**
|
||||
```
|
||||
/?q=science+fiction&sort=newest
|
||||
```
|
||||
|
||||
## Search Mode Behavior
|
||||
|
||||
### Direct Download Mode (default)
|
||||
|
||||
All parameters are used to filter results from Anna's Archive.
|
||||
|
||||
### Universal Mode
|
||||
|
||||
Only `q` and `sort` are used. Other parameters (author, title, format, etc.) are silently ignored since metadata providers have their own search capabilities.
|
||||
|
||||
## Notes
|
||||
|
||||
- URL parameters are read once on page load
|
||||
- The URL is not updated when you perform searches manually
|
||||
- Spaces should be encoded as `+` or `%20`
|
||||
- Invalid or unknown parameters are silently ignored
|
||||
@@ -1,131 +0,0 @@
|
||||
"""Network operations manager for the book downloader application."""
|
||||
|
||||
import network
|
||||
network.init()
|
||||
import requests
|
||||
import time
|
||||
from io import BytesIO
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
from tqdm import tqdm
|
||||
|
||||
from logger import setup_logger
|
||||
from config import PROXIES
|
||||
from env import MAX_RETRY, DEFAULT_SLEEP, USE_CF_BYPASS
|
||||
if USE_CF_BYPASS:
|
||||
import cloudflare_bypasser
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def html_get_page(url: str, retry: int = MAX_RETRY, use_bypasser: bool = False) -> str:
|
||||
"""Fetch HTML content from a URL with retry mechanism.
|
||||
|
||||
Args:
|
||||
url: Target URL
|
||||
retry: Number of retry attempts
|
||||
skip_404: Whether to skip 404 errors
|
||||
|
||||
Returns:
|
||||
str: HTML content if successful, None otherwise
|
||||
"""
|
||||
response = None
|
||||
try:
|
||||
logger.debug(f"html_get_page: {url}, retry: {retry}, use_bypasser: {use_bypasser}")
|
||||
if use_bypasser and USE_CF_BYPASS:
|
||||
logger.info(f"GET Using Cloudflare Bypasser for: {url}")
|
||||
response_html = cloudflare_bypasser.get(url)
|
||||
logger.debug(f"Cloudflare Bypasser response length: {len(response_html)}")
|
||||
if response_html.strip() != "":
|
||||
return response_html
|
||||
else:
|
||||
raise requests.exceptions.RequestException("Failed to bypass Cloudflare")
|
||||
else:
|
||||
logger.info(f"GET: {url}")
|
||||
response = requests.get(url, proxies=PROXIES)
|
||||
response.raise_for_status()
|
||||
logger.debug(f"Success getting: {url}")
|
||||
time.sleep(1)
|
||||
return str(response.text)
|
||||
|
||||
except Exception as e:
|
||||
if retry == 0:
|
||||
logger.error_trace(f"Failed to fetch page: {url}, error: {e}")
|
||||
return ""
|
||||
|
||||
if use_bypasser and USE_CF_BYPASS:
|
||||
logger.warning(f"Exception while using cloudflare bypass for URL: {url}")
|
||||
logger.warning(f"Exception: {e}")
|
||||
logger.warning(f"Response: {response}")
|
||||
elif response is not None and response.status_code == 404:
|
||||
logger.warning(f"404 error for URL: {url}")
|
||||
return ""
|
||||
elif response is not None and response.status_code == 403:
|
||||
logger.warning(f"403 detected for URL: {url}. Should retry using cloudflare bypass.")
|
||||
return html_get_page(url, retry - 1, True)
|
||||
|
||||
sleep_time = DEFAULT_SLEEP * (MAX_RETRY - retry + 1)
|
||||
logger.warning(
|
||||
f"Retrying GET {url} in {sleep_time} seconds due to error: {e}"
|
||||
)
|
||||
time.sleep(sleep_time)
|
||||
return html_get_page(url, retry - 1, use_bypasser)
|
||||
|
||||
def download_url(link: str, size: str = "") -> Optional[BytesIO]:
|
||||
"""Download content from URL into a BytesIO buffer.
|
||||
|
||||
Args:
|
||||
link: URL to download from
|
||||
|
||||
Returns:
|
||||
BytesIO: Buffer containing downloaded content if successful
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Downloading from: {link}")
|
||||
response = requests.get(link, stream=True, proxies=PROXIES)
|
||||
response.raise_for_status()
|
||||
|
||||
total_size : float = 0.0
|
||||
try:
|
||||
# we assume size is in MB
|
||||
total_size = float(size.strip().replace(" ", "").replace(",", ".").upper()[:-2].strip()) * 1024 * 1024
|
||||
except:
|
||||
total_size = float(response.headers.get('content-length', 0))
|
||||
|
||||
buffer = BytesIO()
|
||||
|
||||
# Initialize the progress bar with your guess
|
||||
pbar = tqdm(total=total_size, unit='B', unit_scale=True, desc='Downloading')
|
||||
for chunk in response.iter_content(chunk_size=1000):
|
||||
buffer.write(chunk)
|
||||
pbar.update(len(chunk))
|
||||
|
||||
pbar.close()
|
||||
if buffer.tell() * 0.1 < total_size * 0.9:
|
||||
# Check the content of the buffer if its HTML or binary
|
||||
if response.headers.get('content-type', '').startswith('text/html'):
|
||||
logger.warn(f"Failed to download content for {link}. Found HTML content instead.")
|
||||
return None
|
||||
return buffer
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error_trace(f"Failed to download from {link}: {e}")
|
||||
return None
|
||||
|
||||
def get_absolute_url(base_url: str, url: str) -> str:
|
||||
"""Get absolute URL from relative URL and base URL.
|
||||
|
||||
Args:
|
||||
base_url: Base URL
|
||||
url: Relative URL
|
||||
"""
|
||||
if url.strip() == "":
|
||||
return ""
|
||||
if url.strip("#") == "":
|
||||
return ""
|
||||
if url.startswith("http"):
|
||||
return url
|
||||
parsed_url = urlparse(url)
|
||||
parsed_base = urlparse(base_url)
|
||||
if parsed_url.netloc == "" or parsed_url.scheme == "":
|
||||
parsed_url = parsed_url._replace(netloc=parsed_base.netloc, scheme=parsed_base.scheme)
|
||||
return parsed_url.geturl()
|
||||
@@ -20,6 +20,7 @@ set -e
|
||||
|
||||
# Print build version
|
||||
echo "Build version: $BUILD_VERSION"
|
||||
echo "Release version: $RELEASE_VERSION"
|
||||
|
||||
# Configure timezone
|
||||
if [ "$TZ" ]; then
|
||||
@@ -104,16 +105,14 @@ change_ownership /tmp/cwa-book-downloader
|
||||
# Test write to all folders
|
||||
make_writable /cwa-book-ingest
|
||||
|
||||
# Set the command to run based on the environment
|
||||
is_prod=$(echo "$APP_ENV" | tr '[:upper:]' '[:lower:]')
|
||||
if [ "$is_prod" = "prod" ]; then
|
||||
command="gunicorn -t 300 -b ${FLASK_HOST:-0.0.0.0}:${FLASK_PORT:-8084} app:app"
|
||||
else
|
||||
command="python3 app.py"
|
||||
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"
|
||||
|
||||
# IF DEBUG
|
||||
if [ "$DEBUG" = "true" ]; then
|
||||
# If DEBUG and not using an external bypass
|
||||
if [ "$DEBUG" = "true" ] && [ "$USING_EXTERNAL_BYPASSER" != "true" ]; then
|
||||
set +e
|
||||
set -x
|
||||
echo "vvvvvvvvvvvv DEBUG MODE vvvvvvvvvvvv"
|
||||
@@ -174,7 +173,7 @@ sum=$(python3 -c "print(sum(int(l.strip()) for l in open('/tmp/test.cwa-bd').rea
|
||||
[ "$sum" == 11250075000 ] && echo "Success: /tmp is writable" || (echo "Failure: /tmp is not writable" && exit 1)
|
||||
rm /tmp/test.cwa-bd
|
||||
|
||||
echo "Running command: '$command' as '$USERNAME' in '$APP_ENV' mode"
|
||||
echo "Running command: '$command' as '$USERNAME' (debug=$is_debug)"
|
||||
|
||||
# Stop logging
|
||||
exec 1>&3 2>&4
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
def string_to_bool(s: str) -> bool:
|
||||
return s.lower() in ["true", "yes", "1", "y"]
|
||||
|
||||
CWA_DB = os.getenv("CWA_DB_PATH")
|
||||
CWA_DB_PATH = Path(CWA_DB) if CWA_DB else None
|
||||
LOG_ROOT = Path(os.getenv("LOG_ROOT", "/var/log/"))
|
||||
LOG_DIR = LOG_ROOT / "cwa-book-downloader"
|
||||
TMP_DIR = Path(os.getenv("TMP_DIR", "/tmp/cwa-book-downloader"))
|
||||
INGEST_DIR = Path(os.getenv("INGEST_DIR", "/cwa-book-ingest"))
|
||||
STATUS_TIMEOUT = int(os.getenv("STATUS_TIMEOUT", "3600"))
|
||||
USE_BOOK_TITLE = string_to_bool(os.getenv("USE_BOOK_TITLE", "false"))
|
||||
MAX_RETRY = int(os.getenv("MAX_RETRY", "10"))
|
||||
DEFAULT_SLEEP = int(os.getenv("DEFAULT_SLEEP", "5"))
|
||||
USE_CF_BYPASS = string_to_bool(os.getenv("USE_CF_BYPASS", "true"))
|
||||
HTTP_PROXY = os.getenv("HTTP_PROXY", "").strip()
|
||||
HTTPS_PROXY = os.getenv("HTTPS_PROXY", "").strip()
|
||||
AA_DONATOR_KEY = os.getenv("AA_DONATOR_KEY", "").strip()
|
||||
_AA_BASE_URL = os.getenv("AA_BASE_URL", "auto").strip()
|
||||
_AA_ADDITIONAL_URLS = os.getenv("AA_ADDITIONAL_URLS", "").strip()
|
||||
_SUPPORTED_FORMATS = os.getenv("SUPPORTED_FORMATS", "epub,mobi,azw3,fb2,djvu,cbz,cbr").lower()
|
||||
_BOOK_LANGUAGE = os.getenv("BOOK_LANGUAGE", "en").lower()
|
||||
_CUSTOM_SCRIPT = os.getenv("CUSTOM_SCRIPT", "").strip()
|
||||
FLASK_HOST = os.getenv("FLASK_HOST", "0.0.0.0")
|
||||
FLASK_PORT = int(os.getenv("FLASK_PORT", "8084"))
|
||||
DEBUG = string_to_bool(os.getenv("DEBUG", "false"))
|
||||
PRIORITIZE_WELIB = string_to_bool(os.getenv("PRIORITIZE_WELIB", "false"))
|
||||
|
||||
# If debug is true, we want to log everything
|
||||
if DEBUG:
|
||||
LOG_LEVEL = "DEBUG"
|
||||
else:
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
ENABLE_LOGGING = string_to_bool(os.getenv("ENABLE_LOGGING", "true"))
|
||||
MAIN_LOOP_SLEEP_TIME = int(os.getenv("MAIN_LOOP_SLEEP_TIME", "5"))
|
||||
MAX_CONCURRENT_DOWNLOADS = int(os.getenv("MAX_CONCURRENT_DOWNLOADS", "3"))
|
||||
DOWNLOAD_PROGRESS_UPDATE_INTERVAL = int(os.getenv("DOWNLOAD_PROGRESS_UPDATE_INTERVAL", "5"))
|
||||
DOCKERMODE = string_to_bool(os.getenv("DOCKERMODE", "false"))
|
||||
_CUSTOM_DNS = os.getenv("CUSTOM_DNS", "").strip()
|
||||
USE_DOH = string_to_bool(os.getenv("USE_DOH", "false"))
|
||||
BYPASS_RELEASE_INACTIVE_MIN = int(os.getenv("BYPASS_RELEASE_INACTIVE_MIN", "5"))
|
||||
APP_ENV = os.getenv("APP_ENV", "prod").lower()
|
||||
# Logging settings
|
||||
LOG_FILE = LOG_DIR / "cwa-book-downloader.log"
|
||||
|
||||
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 = ""
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# 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}_$(date +%Y%m%d-%H%M%S)"
|
||||
OUTPUT_FILE_NAME="cwa-book-downloader-debug_BUILD-${BUILD_VERSION:-local}_RELEASE-${RELEASE_VERSION:-NA}_$(date +%Y%m%d-%H%M%S)"
|
||||
OUTPUT_FILE="/tmp/$OUTPUT_FILE_NAME.zip"
|
||||
|
||||
# Create LOG_DIR if it doesn't exist
|
||||
@@ -18,17 +18,17 @@ echo "" >> "$LOG_DIR/system_info.txt"
|
||||
|
||||
# Add disk usage
|
||||
echo "=== Disk Usage ===" >> "$LOG_DIR/system_info.txt"
|
||||
df -h >> "$LOG_DIR/system_info.txt"
|
||||
df -h >> "$LOG_DIR/system_info.txt" 2>&1
|
||||
echo "" >> "$LOG_DIR/system_info.txt"
|
||||
|
||||
# Add memory info
|
||||
echo "=== Memory Info ===" >> "$LOG_DIR/system_info.txt"
|
||||
free -h >> "$LOG_DIR/system_info.txt"
|
||||
free -h >> "$LOG_DIR/system_info.txt" 2>&1
|
||||
echo "" >> "$LOG_DIR/system_info.txt"
|
||||
|
||||
# Add running processes
|
||||
echo "=== Running Processes ===" >> "$LOG_DIR/system_info.txt"
|
||||
ps aux >> "$LOG_DIR/system_info.txt"
|
||||
ps aux >> "$LOG_DIR/system_info.txt" 2>&1
|
||||
echo "" >> "$LOG_DIR/system_info.txt"
|
||||
|
||||
# Add network information using basic commands
|
||||
@@ -37,17 +37,17 @@ echo "=== Network Information ===" > "$LOG_DIR/network_info.txt"
|
||||
# Try to get basic connectivity information
|
||||
echo "=== Basic Connectivity ===" >> "$LOG_DIR/network_info.txt"
|
||||
echo "Hostname resolution:" >> "$LOG_DIR/network_info.txt"
|
||||
cat /etc/hosts 2>/dev/null >> "$LOG_DIR/network_info.txt" || echo "Unable to read /etc/hosts" >> "$LOG_DIR/network_info.txt"
|
||||
cat /etc/hosts >> "$LOG_DIR/network_info.txt" 2>&1 || echo "Unable to read /etc/hosts" >> "$LOG_DIR/network_info.txt"
|
||||
echo "" >> "$LOG_DIR/network_info.txt"
|
||||
|
||||
echo "DNS configuration:" >> "$LOG_DIR/network_info.txt"
|
||||
cat /etc/resolv.conf 2>/dev/null >> "$LOG_DIR/network_info.txt" || echo "Unable to read /etc/resolv.conf" >> "$LOG_DIR/network_info.txt"
|
||||
cat /etc/resolv.conf >> "$LOG_DIR/network_info.txt" 2>&1 || echo "Unable to read /etc/resolv.conf" >> "$LOG_DIR/network_info.txt"
|
||||
echo "" >> "$LOG_DIR/network_info.txt"
|
||||
|
||||
# Try to get interface information from /proc
|
||||
echo "=== Network Interfaces (/proc) ===" >> "$LOG_DIR/network_info.txt"
|
||||
if [ -f "/proc/net/dev" ]; then
|
||||
cat /proc/net/dev >> "$LOG_DIR/network_info.txt"
|
||||
cat /proc/net/dev >> "$LOG_DIR/network_info.txt" 2>&1
|
||||
else
|
||||
echo "Not available: /proc/net/dev not found" >> "$LOG_DIR/network_info.txt"
|
||||
fi
|
||||
@@ -55,9 +55,9 @@ echo "" >> "$LOG_DIR/network_info.txt"
|
||||
|
||||
# Try connectivity tests
|
||||
echo "=== Internet Connectivity ===" >> "$LOG_DIR/network_info.txt"
|
||||
ping -c 3 1.1.1.1 2>/dev/null >> "$LOG_DIR/network_info.txt" || echo "Ping command failed or not available" >> "$LOG_DIR/network_info.txt"
|
||||
ping -c 3 1.1.1.1 >> "$LOG_DIR/network_info.txt" 2>&1 || echo "Ping command failed or not available" >> "$LOG_DIR/network_info.txt"
|
||||
echo "" >> "$LOG_DIR/network_info.txt"
|
||||
ping -c 3 one.one.one.one 2>/dev/null >> "$LOG_DIR/network_info.txt" || echo "DNS resolution test failed" >> "$LOG_DIR/network_info.txt"
|
||||
ping -c 3 one.one.one.one >> "$LOG_DIR/network_info.txt" 2>&1 || echo "DNS resolution test failed" >> "$LOG_DIR/network_info.txt"
|
||||
echo "" >> "$LOG_DIR/network_info.txt"
|
||||
|
||||
# Test IPv6 connectivity
|
||||
@@ -77,7 +77,7 @@ echo "" >> "$LOG_DIR/network_info.txt"
|
||||
|
||||
# Try IPv6 connectivity test using Cloudflare's IPv6 DNS
|
||||
echo "Testing IPv6 connectivity to Cloudflare DNS:" >> "$LOG_DIR/network_info.txt"
|
||||
ping6 -c 3 2606:4700:4700::1111 2>/dev/null >> "$LOG_DIR/network_info.txt" || echo "IPv6 ping failed or not available" >> "$LOG_DIR/network_info.txt"
|
||||
ping6 -c 3 2606:4700:4700::1111 >> "$LOG_DIR/network_info.txt" 2>&1 || echo "IPv6 ping failed or not available" >> "$LOG_DIR/network_info.txt"
|
||||
echo "" >> "$LOG_DIR/network_info.txt"
|
||||
|
||||
# Test SSL connectivity
|
||||
@@ -92,24 +92,36 @@ echo "" >> "$LOG_DIR/network_info.txt"
|
||||
|
||||
# Add installed packages
|
||||
echo "=== Installed Python Packages ===" > "$LOG_DIR/packages.txt"
|
||||
pip list 2>/dev/null >> "$LOG_DIR/packages.txt" || echo "pip not found" >> "$LOG_DIR/packages.txt"
|
||||
pip list >> "$LOG_DIR/packages.txt" 2>&1 || echo "pip not found" >> "$LOG_DIR/packages.txt"
|
||||
echo "" >> "$LOG_DIR/packages.txt"
|
||||
|
||||
# Check Permissions
|
||||
echo "=== Permissions ===" > "$LOG_DIR/permissions.txt"
|
||||
echo "ls -all /app" >> "$LOG_DIR/permissions.txt"
|
||||
ls -all /app >> "$LOG_DIR/permissions.txt"
|
||||
ls -all /app >> "$LOG_DIR/permissions.txt" 2>&1
|
||||
echo "" >> "$LOG_DIR/permissions.txt"
|
||||
echo "ls -all /cwa-book-ingest" >> "$LOG_DIR/permissions.txt"
|
||||
ls -all /cwa-book-ingest >> "$LOG_DIR/permissions.txt"
|
||||
ls -all /cwa-book-ingest >> "$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"
|
||||
ls -all /var/log/cwa-book-downloader >> "$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"
|
||||
ls -all /tmp/cwa-book-downloader >> "$LOG_DIR/permissions.txt" 2>&1
|
||||
echo "" >> "$LOG_DIR/permissions.txt"
|
||||
|
||||
# Check Iptables (NAT)
|
||||
echo "=== IPtables NAT Rules ===" > "$LOG_DIR/iptables_nat.txt"
|
||||
iptables -t nat -L -v -n >> "$LOG_DIR/iptables_nat.txt" 2>&1
|
||||
|
||||
# Check DNS Resolution details
|
||||
echo "=== DNS Resolution Test ===" > "$LOG_DIR/dns_test.txt"
|
||||
echo "Resolving google.com:" >> "$LOG_DIR/dns_test.txt"
|
||||
nslookup google.com >> "$LOG_DIR/dns_test.txt" 2>&1
|
||||
echo "" >> "$LOG_DIR/dns_test.txt"
|
||||
echo "Resolving check.torproject.org:" >> "$LOG_DIR/dns_test.txt"
|
||||
nslookup check.torproject.org >> "$LOG_DIR/dns_test.txt" 2>&1
|
||||
|
||||
|
||||
# Check if running in Docker
|
||||
echo "=== Container Info ===" > "$LOG_DIR/container_info.txt"
|
||||
@@ -122,19 +134,58 @@ else
|
||||
fi
|
||||
|
||||
# Add environment variables (redacting sensitive info)
|
||||
env | grep -v -E "(AA_DONATOR_KEY)" | sort > "$LOG_DIR/environment.txt"
|
||||
env | grep -v -E "(AA_DONATOR_KEY|HARDCOVER_API_KEY|_KEY=|_SECRET=|_PASSWORD=|_TOKEN=)" | sort > "$LOG_DIR/environment.txt"
|
||||
|
||||
echo "--- HTTPBin ---" > $LOG_DIR/network_info.txt
|
||||
pyrequests https://httpbin.org/get >> $LOG_DIR/network_info.txt
|
||||
ehco ""
|
||||
# Add configuration files (redacting sensitive values)
|
||||
CONFIG_DIR=${CONFIG_DIR:-"/config"}
|
||||
if [ -d "$CONFIG_DIR" ]; then
|
||||
mkdir -p "$LOG_DIR/config"
|
||||
|
||||
# Copy and redact main settings file
|
||||
if [ -f "$CONFIG_DIR/settings.json" ]; then
|
||||
# Redact sensitive fields (API keys, passwords, tokens)
|
||||
sed -E 's/("(AA_DONATOR_KEY|HARDCOVER_API_KEY|[^"]*_KEY|[^"]*_SECRET|[^"]*_PASSWORD|[^"]*_TOKEN)"[[:space:]]*:[[:space:]]*")[^"]+"/\1[REDACTED]"/g' \
|
||||
"$CONFIG_DIR/settings.json" > "$LOG_DIR/config/settings.json" 2>/dev/null
|
||||
fi
|
||||
|
||||
# Copy and redact plugin config files
|
||||
if [ -d "$CONFIG_DIR/plugins" ]; then
|
||||
mkdir -p "$LOG_DIR/config/plugins"
|
||||
for config_file in "$CONFIG_DIR/plugins"/*.json; do
|
||||
if [ -f "$config_file" ]; then
|
||||
filename=$(basename "$config_file")
|
||||
sed -E 's/("(AA_DONATOR_KEY|HARDCOVER_API_KEY|[^"]*_KEY|[^"]*_SECRET|[^"]*_PASSWORD|[^"]*_TOKEN)"[[:space:]]*:[[:space:]]*")[^"]+"/\1[REDACTED]"/g' \
|
||||
"$config_file" > "$LOG_DIR/config/plugins/$filename" 2>/dev/null
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Configuration files copied (sensitive values redacted)" >> "$LOG_DIR/container_info.txt"
|
||||
else
|
||||
echo "Config directory not found at $CONFIG_DIR" >> "$LOG_DIR/container_info.txt"
|
||||
fi
|
||||
|
||||
echo "--- HTTPBin ---" >> $LOG_DIR/network_info.txt
|
||||
curl -s https://httpbin.org/get >> $LOG_DIR/network_info.txt 2>&1
|
||||
echo "" >> $LOG_DIR/network_info.txt
|
||||
echo "--- HowsMySSL ---" >> $LOG_DIR/network_info.txt
|
||||
pyrequests https://www.howsmyssl.com/a/check >> $LOG_DIR/network_info.txt
|
||||
ehco ""
|
||||
curl -s https://www.howsmyssl.com/a/check >> $LOG_DIR/network_info.txt 2>&1
|
||||
echo "" >> $LOG_DIR/network_info.txt
|
||||
echo "--- IPInfo ---" >> $LOG_DIR/network_info.txt
|
||||
pyrequests https://ipinfo.io >> $LOG_DIR/network_info.txt
|
||||
ehco ""
|
||||
curl -s https://ipinfo.io >> $LOG_DIR/network_info.txt 2>&1
|
||||
echo "" >> $LOG_DIR/network_info.txt
|
||||
echo "--- Cloudflare Trace ---" >> $LOG_DIR/network_info.txt
|
||||
pyrequests https://1.1.1.1/cdn-cgi/trace >> $LOG_DIR/network_info.txt
|
||||
curl -s https://1.1.1.1/cdn-cgi/trace >> $LOG_DIR/network_info.txt 2>&1
|
||||
|
||||
# Copy Tor logs if they exist
|
||||
if [ -f "/var/log/tor/notices.log" ]; then
|
||||
cp "/var/log/tor/notices.log" "$LOG_DIR/tor_notices.log"
|
||||
fi
|
||||
|
||||
# Copy Supervisor logs if they exist
|
||||
if [ -d "/var/log/supervisor" ]; then
|
||||
cp -rf "/var/log/supervisor/" "$LOG_DIR/supervisor/"
|
||||
fi
|
||||
|
||||
# Create the zip file directly from LOG_DIR
|
||||
ln -s "$LOG_DIR" /tmp/$OUTPUT_FILE_NAME
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
"""Data structures and models used across the application."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from enum import Enum
|
||||
from datetime import datetime, timedelta
|
||||
from threading import Lock, Event
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import time
|
||||
from env import INGEST_DIR, STATUS_TIMEOUT
|
||||
|
||||
class QueueStatus(str, Enum):
|
||||
"""Enum for possible book queue statuses."""
|
||||
QUEUED = "queued"
|
||||
DOWNLOADING = "downloading"
|
||||
AVAILABLE = "available"
|
||||
ERROR = "error"
|
||||
DONE = "done"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
@dataclass
|
||||
class QueueItem:
|
||||
"""Queue item with priority and metadata."""
|
||||
book_id: str
|
||||
priority: int
|
||||
added_time: float
|
||||
|
||||
def __lt__(self, other):
|
||||
"""Compare items for priority queue (lower priority number = higher precedence)."""
|
||||
if self.priority != other.priority:
|
||||
return self.priority < other.priority
|
||||
return self.added_time < other.added_time
|
||||
|
||||
@dataclass
|
||||
class BookInfo:
|
||||
"""Data class representing book information."""
|
||||
id: str
|
||||
title: str
|
||||
preview: Optional[str] = None
|
||||
author: Optional[str] = None
|
||||
publisher: Optional[str] = None
|
||||
year: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
format: Optional[str] = None
|
||||
size: Optional[str] = None
|
||||
info: Optional[Dict[str, List[str]]] = None
|
||||
download_urls: List[str] = field(default_factory=list)
|
||||
download_path: Optional[str] = None
|
||||
priority: int = 0
|
||||
progress: Optional[float] = None
|
||||
|
||||
class BookQueue:
|
||||
"""Thread-safe book queue manager with priority support and cancellation."""
|
||||
def __init__(self) -> None:
|
||||
self._queue: queue.PriorityQueue[QueueItem] = queue.PriorityQueue()
|
||||
self._lock = Lock()
|
||||
self._status: dict[str, QueueStatus] = {}
|
||||
self._book_data: dict[str, BookInfo] = {}
|
||||
self._status_timestamps: dict[str, datetime] = {} # Track when each status was last updated
|
||||
self._status_timeout = timedelta(seconds=STATUS_TIMEOUT) # 1 hour timeout
|
||||
self._cancel_flags: dict[str, Event] = {} # Cancellation flags for active downloads
|
||||
self._active_downloads: dict[str, bool] = {} # Track currently downloading books
|
||||
|
||||
def add(self, book_id: str, book_data: BookInfo, priority: int = 0) -> None:
|
||||
"""Add a book to the queue with specified priority.
|
||||
|
||||
Args:
|
||||
book_id: Unique identifier for the book
|
||||
book_data: Book information
|
||||
priority: Priority level (lower number = higher priority)
|
||||
"""
|
||||
with self._lock:
|
||||
# Don't add if already exists and not in error/done state
|
||||
if book_id in self._status and self._status[book_id] not in [QueueStatus.ERROR, QueueStatus.DONE, QueueStatus.CANCELLED]:
|
||||
return
|
||||
|
||||
book_data.priority = priority
|
||||
queue_item = QueueItem(book_id, priority, time.time())
|
||||
self._queue.put(queue_item)
|
||||
self._book_data[book_id] = book_data
|
||||
self._update_status(book_id, QueueStatus.QUEUED)
|
||||
|
||||
def get_next(self) -> Optional[Tuple[str, Event]]:
|
||||
"""Get next book ID from queue with cancellation flag.
|
||||
|
||||
Returns:
|
||||
Tuple of (book_id, cancel_flag) or None if queue is empty
|
||||
"""
|
||||
try:
|
||||
queue_item = self._queue.get_nowait()
|
||||
book_id = queue_item.book_id
|
||||
|
||||
with self._lock:
|
||||
# Check if book was cancelled while in queue
|
||||
if book_id in self._status and self._status[book_id] == QueueStatus.CANCELLED:
|
||||
return self.get_next() # Recursively get next non-cancelled item
|
||||
|
||||
# Create cancellation flag for this download
|
||||
cancel_flag = Event()
|
||||
self._cancel_flags[book_id] = cancel_flag
|
||||
self._active_downloads[book_id] = True
|
||||
|
||||
return book_id, cancel_flag
|
||||
except queue.Empty:
|
||||
return None
|
||||
|
||||
def _update_status(self, book_id: str, status: QueueStatus) -> None:
|
||||
"""Internal method to update status and timestamp."""
|
||||
self._status[book_id] = status
|
||||
self._status_timestamps[book_id] = datetime.now()
|
||||
|
||||
def update_status(self, book_id: str, status: QueueStatus) -> None:
|
||||
"""Update status of a book in the queue."""
|
||||
with self._lock:
|
||||
self._update_status(book_id, status)
|
||||
|
||||
# Clean up active download tracking when finished
|
||||
if status in [QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.DONE, QueueStatus.CANCELLED]:
|
||||
self._active_downloads.pop(book_id, None)
|
||||
self._cancel_flags.pop(book_id, None)
|
||||
|
||||
def update_download_path(self, book_id: str, download_path: str) -> None:
|
||||
"""Update the download path of a book in the queue."""
|
||||
with self._lock:
|
||||
if book_id in self._book_data:
|
||||
self._book_data[book_id].download_path = download_path
|
||||
|
||||
def update_progress(self, book_id: str, progress: float) -> None:
|
||||
"""Update download progress for a book."""
|
||||
with self._lock:
|
||||
if book_id in self._book_data:
|
||||
self._book_data[book_id].progress = progress
|
||||
|
||||
def get_status(self) -> Dict[QueueStatus, Dict[str, BookInfo]]:
|
||||
"""Get current queue status."""
|
||||
self.refresh()
|
||||
with self._lock:
|
||||
result: Dict[QueueStatus, Dict[str, BookInfo]] = {status: {} for status in QueueStatus}
|
||||
for book_id, status in self._status.items():
|
||||
if book_id in self._book_data:
|
||||
result[status][book_id] = self._book_data[book_id]
|
||||
return result
|
||||
|
||||
def get_queue_order(self) -> List[Dict[str, any]]:
|
||||
"""Get current queue order for display."""
|
||||
with self._lock:
|
||||
queue_items = []
|
||||
|
||||
# Get items from priority queue without removing them
|
||||
temp_items = []
|
||||
while not self._queue.empty():
|
||||
try:
|
||||
item = self._queue.get_nowait()
|
||||
temp_items.append(item)
|
||||
if item.book_id in self._book_data:
|
||||
book_info = self._book_data[item.book_id]
|
||||
queue_items.append({
|
||||
'id': item.book_id,
|
||||
'title': book_info.title,
|
||||
'author': book_info.author,
|
||||
'priority': item.priority,
|
||||
'added_time': item.added_time,
|
||||
'status': self._status.get(item.book_id, QueueStatus.QUEUED)
|
||||
})
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Put items back in queue
|
||||
for item in temp_items:
|
||||
self._queue.put(item)
|
||||
|
||||
return sorted(queue_items, key=lambda x: (x['priority'], x['added_time']))
|
||||
|
||||
def cancel_download(self, book_id: str) -> bool:
|
||||
"""Cancel a download and mark it as cancelled.
|
||||
|
||||
Args:
|
||||
book_id: Book identifier to cancel
|
||||
|
||||
Returns:
|
||||
bool: True if cancellation was successful
|
||||
"""
|
||||
with self._lock:
|
||||
current_status = self._status.get(book_id)
|
||||
|
||||
if current_status == QueueStatus.DOWNLOADING:
|
||||
# Signal active download to stop
|
||||
if book_id in self._cancel_flags:
|
||||
self._cancel_flags[book_id].set()
|
||||
self._update_status(book_id, QueueStatus.CANCELLED)
|
||||
return True
|
||||
elif current_status == QueueStatus.QUEUED:
|
||||
# Remove from queue and mark as cancelled
|
||||
self._update_status(book_id, QueueStatus.CANCELLED)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def set_priority(self, book_id: str, new_priority: int) -> bool:
|
||||
"""Change the priority of a queued book.
|
||||
|
||||
Args:
|
||||
book_id: Book identifier
|
||||
new_priority: New priority level (lower = higher priority)
|
||||
|
||||
Returns:
|
||||
bool: True if priority was successfully changed
|
||||
"""
|
||||
with self._lock:
|
||||
if book_id not in self._status or self._status[book_id] != QueueStatus.QUEUED:
|
||||
return False
|
||||
|
||||
# Remove book from queue and re-add with new priority
|
||||
temp_items = []
|
||||
found = False
|
||||
|
||||
while not self._queue.empty():
|
||||
try:
|
||||
item = self._queue.get_nowait()
|
||||
if item.book_id == book_id:
|
||||
# Create new item with updated priority
|
||||
new_item = QueueItem(book_id, new_priority, item.added_time)
|
||||
temp_items.append(new_item)
|
||||
found = True
|
||||
# Update book data priority
|
||||
if book_id in self._book_data:
|
||||
self._book_data[book_id].priority = new_priority
|
||||
else:
|
||||
temp_items.append(item)
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Put all items back
|
||||
for item in temp_items:
|
||||
self._queue.put(item)
|
||||
|
||||
return found
|
||||
|
||||
def reorder_queue(self, book_priorities: Dict[str, int]) -> bool:
|
||||
"""Bulk reorder queue by setting new priorities.
|
||||
|
||||
Args:
|
||||
book_priorities: Dict mapping book_id to new priority
|
||||
|
||||
Returns:
|
||||
bool: True if reordering was successful
|
||||
"""
|
||||
with self._lock:
|
||||
# Extract all items from queue
|
||||
all_items = []
|
||||
while not self._queue.empty():
|
||||
try:
|
||||
item = self._queue.get_nowait()
|
||||
# Update priority if specified
|
||||
if item.book_id in book_priorities:
|
||||
new_priority = book_priorities[item.book_id]
|
||||
item = QueueItem(item.book_id, new_priority, item.added_time)
|
||||
# Update book data priority
|
||||
if item.book_id in self._book_data:
|
||||
self._book_data[item.book_id].priority = new_priority
|
||||
all_items.append(item)
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Put all items back with updated priorities
|
||||
for item in all_items:
|
||||
self._queue.put(item)
|
||||
|
||||
return True
|
||||
|
||||
def get_active_downloads(self) -> List[str]:
|
||||
"""Get list of currently active download book IDs."""
|
||||
with self._lock:
|
||||
return list(self._active_downloads.keys())
|
||||
|
||||
def clear_completed(self) -> int:
|
||||
"""Remove all completed, errored, or cancelled books from tracking.
|
||||
|
||||
Returns:
|
||||
int: Number of books removed
|
||||
"""
|
||||
with self._lock:
|
||||
to_remove = []
|
||||
for book_id, status in self._status.items():
|
||||
if status in [QueueStatus.DONE, QueueStatus.ERROR, QueueStatus.CANCELLED]:
|
||||
to_remove.append(book_id)
|
||||
|
||||
removed_count = len(to_remove)
|
||||
for book_id in to_remove:
|
||||
self._status.pop(book_id, None)
|
||||
self._status_timestamps.pop(book_id, None)
|
||||
self._book_data.pop(book_id, None)
|
||||
self._cancel_flags.pop(book_id, None)
|
||||
self._active_downloads.pop(book_id, None)
|
||||
|
||||
return removed_count
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Remove any books that are done downloading or have stale status."""
|
||||
with self._lock:
|
||||
current_time = datetime.now()
|
||||
|
||||
# Create a list of items to remove to avoid modifying dict during iteration
|
||||
to_remove = []
|
||||
|
||||
for book_id, status in self._status.items():
|
||||
path = self._book_data[book_id].download_path
|
||||
if path and not Path(path).exists():
|
||||
self._book_data[book_id].download_path = None
|
||||
path = None
|
||||
|
||||
# Check for completed downloads
|
||||
if status == QueueStatus.AVAILABLE:
|
||||
if not path:
|
||||
self._update_status(book_id, QueueStatus.DONE)
|
||||
|
||||
# Check for stale status entries
|
||||
last_update = self._status_timestamps.get(book_id)
|
||||
if last_update and (current_time - last_update) > self._status_timeout:
|
||||
if status in [QueueStatus.DONE, QueueStatus.ERROR, QueueStatus.AVAILABLE, QueueStatus.CANCELLED]:
|
||||
to_remove.append(book_id)
|
||||
|
||||
# Remove stale entries
|
||||
for book_id in to_remove:
|
||||
del self._status[book_id]
|
||||
del self._status_timestamps[book_id]
|
||||
if book_id in self._book_data:
|
||||
del self._book_data[book_id]
|
||||
|
||||
def set_status_timeout(self, hours: int) -> None:
|
||||
"""Set the status timeout duration in hours."""
|
||||
with self._lock:
|
||||
self._status_timeout = timedelta(hours=hours)
|
||||
|
||||
|
||||
# Global instance of BookQueue
|
||||
book_queue = BookQueue()
|
||||
|
||||
@dataclass
|
||||
class SearchFilters:
|
||||
isbn: Optional[List[str]] = None
|
||||
author: Optional[List[str]] = None
|
||||
title: Optional[List[str]] = None
|
||||
lang: Optional[List[str]] = None
|
||||
sort: Optional[str] = None
|
||||
content: Optional[List[str]] = None
|
||||
format: Optional[List[str]] = None
|
||||
@@ -1,350 +0,0 @@
|
||||
"""Network operations manager for the book downloader application."""
|
||||
|
||||
import requests
|
||||
import urllib.request
|
||||
from typing import Sequence, Tuple, Any, Union, cast, List, Optional, Callable
|
||||
import socket
|
||||
import dns.resolver
|
||||
from socket import AddressFamily, SocketKind
|
||||
import urllib.parse
|
||||
import ssl
|
||||
import ipaddress
|
||||
|
||||
from logger import setup_logger
|
||||
from config import PROXIES, AA_BASE_URL, CUSTOM_DNS, AA_AVAILABLE_URLS, DOH_SERVER
|
||||
import config
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
# Common helper functions for DNS resolution
|
||||
def _decode_host(host: Union[str, bytes, None]) -> str:
|
||||
"""Convert host to string, handling bytes and None cases."""
|
||||
if host is None:
|
||||
return ""
|
||||
if isinstance(host, bytes):
|
||||
return host.decode('utf-8')
|
||||
return str(host)
|
||||
|
||||
def _decode_port(port: Union[str, bytes, int, None]) -> int:
|
||||
"""Convert port to integer, handling various input types."""
|
||||
if port is None:
|
||||
return 0
|
||||
if isinstance(port, (str, bytes)):
|
||||
return int(port)
|
||||
return int(port)
|
||||
|
||||
def _is_local_address(host_str: str) -> bool:
|
||||
"""Check if an address is local and should bypass custom DNS."""
|
||||
"""Check if an address is local or private and should bypass custom DNS."""
|
||||
# Localhost checks
|
||||
if (host_str == 'localhost' or
|
||||
host_str.startswith('127.') or
|
||||
host_str == '::1' or
|
||||
host_str == '0.0.0.0'):
|
||||
return True
|
||||
|
||||
# IPv4 private ranges (RFC 1918)
|
||||
if (host_str.startswith('10.') or
|
||||
(host_str.startswith('172.') and
|
||||
len(host_str.split('.')) > 1 and
|
||||
16 <= int(host_str.split('.')[1]) <= 31) or
|
||||
host_str.startswith('192.168.')):
|
||||
return True
|
||||
|
||||
# IPv6 private ranges
|
||||
if (host_str.startswith('fc') or
|
||||
host_str.startswith('fd') or # Unique local addresses (fc00::/7)
|
||||
host_str.startswith('fe80:')): # Link-local addresses (fe80::/10)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _is_ip_address(host_str: str) -> bool:
|
||||
"""Check if a string is a valid IP address (IPv4 or IPv6)."""
|
||||
try:
|
||||
ipaddress.ip_address(host_str)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
# Store the original getaddrinfo function
|
||||
original_getaddrinfo = socket.getaddrinfo
|
||||
|
||||
class DoHResolver:
|
||||
"""DNS over HTTPS resolver implementation."""
|
||||
def __init__(self, provider_url: str, hostname: str, ip: str):
|
||||
"""Initialize DoH resolver with specified provider."""
|
||||
self.base_url = provider_url.lower().strip()
|
||||
self.hostname = hostname # Store the hostname for hostname-based skipping
|
||||
self.ip = ip # Store IP for direct connections
|
||||
self.session = requests.Session()
|
||||
|
||||
# Different headers based on provider
|
||||
if 'google' in self.base_url:
|
||||
self.session.headers.update({
|
||||
'Accept': 'application/json',
|
||||
})
|
||||
else:
|
||||
self.session.headers.update({
|
||||
'Accept': 'application/dns-json',
|
||||
})
|
||||
|
||||
def resolve(self, hostname: str, record_type: str) -> List[str]:
|
||||
"""Resolve a hostname using DoH.
|
||||
|
||||
Args:
|
||||
hostname: The hostname to resolve
|
||||
record_type: The DNS record type (A or AAAA)
|
||||
|
||||
Returns:
|
||||
List of resolved IP addresses
|
||||
"""
|
||||
# Check if hostname is already an IP address, no need to resolve
|
||||
if _is_ip_address(hostname):
|
||||
logger.debug(f"Skipping DoH resolution for IP address: {hostname}")
|
||||
return [hostname]
|
||||
|
||||
# Check if hostname is a private IP address, and skip DoH if it is
|
||||
if _is_local_address(hostname):
|
||||
logger.debug(f"Skipping DoH resolution for private IP: {hostname}")
|
||||
return [hostname]
|
||||
|
||||
# Skip resolution for the DoH server itself to prevent recursion
|
||||
if hostname == self.hostname:
|
||||
logger.debug(f"Skipping DoH resolution for DoH server itself: {hostname}")
|
||||
return [self.ip]
|
||||
|
||||
try:
|
||||
params = {
|
||||
'name': hostname,
|
||||
'type': 'AAAA' if record_type == 'AAAA' else 'A'
|
||||
}
|
||||
|
||||
response = self.session.get(
|
||||
self.base_url,
|
||||
params=params,
|
||||
proxies=PROXIES,
|
||||
timeout=5
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
if 'Answer' not in data:
|
||||
logger.warning(f"DoH resolution failed for {hostname}: {data}")
|
||||
return []
|
||||
|
||||
# Extract IP addresses from the response
|
||||
answers = [answer['data'] for answer in data['Answer']
|
||||
if answer.get('type') == (28 if record_type == 'AAAA' else 1)]
|
||||
logger.debug(f"Resolved {hostname} to {len(answers)} addresses using DoH: {answers}")
|
||||
return answers
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"DoH resolution failed for {hostname}: {e}")
|
||||
return []
|
||||
|
||||
def create_custom_resolver():
|
||||
"""Create a custom DNS resolver using the configured DNS servers."""
|
||||
custom_resolver = dns.resolver.Resolver()
|
||||
custom_resolver.nameservers = CUSTOM_DNS
|
||||
return custom_resolver
|
||||
|
||||
def resolve_with_custom_dns(resolver, hostname: str, record_type: str) -> List[str]:
|
||||
"""Resolve hostname using custom DNS resolver.
|
||||
|
||||
Args:
|
||||
resolver: The DNS resolver to use
|
||||
hostname: The hostname to resolve
|
||||
record_type: The DNS record type (A or AAAA)
|
||||
|
||||
Returns:
|
||||
List of resolved IP addresses
|
||||
"""
|
||||
try:
|
||||
answers = resolver.resolve(hostname, record_type)
|
||||
return [str(answer) for answer in answers]
|
||||
except Exception as e:
|
||||
logger.debug(f"{record_type} resolution failed for {hostname}: {e}")
|
||||
return []
|
||||
|
||||
def create_custom_getaddrinfo(
|
||||
resolve_ipv4: Callable[[str], List[str]],
|
||||
resolve_ipv6: Callable[[str], List[str]],
|
||||
skip_check: Optional[Callable[[str], bool]] = None
|
||||
):
|
||||
"""Create a custom getaddrinfo function that uses the provided resolvers.
|
||||
|
||||
Args:
|
||||
resolve_ipv4: Function to resolve IPv4 addresses
|
||||
resolve_ipv6: Function to resolve IPv6 addresses
|
||||
skip_check: Optional function to check if custom resolution should be skipped
|
||||
|
||||
Returns:
|
||||
A custom getaddrinfo function
|
||||
"""
|
||||
def custom_getaddrinfo(
|
||||
host: Union[str, bytes, None],
|
||||
port: Union[str, bytes, int, None],
|
||||
family: int = 0,
|
||||
type: int = 0,
|
||||
proto: int = 0,
|
||||
flags: int = 0
|
||||
) -> Sequence[Tuple[AddressFamily, SocketKind, int, str, Tuple[Any, ...]]]:
|
||||
host_str = _decode_host(host)
|
||||
port_int = _decode_port(port)
|
||||
|
||||
# Skip custom resolution for IP addresses, local addresses, or if skip check passes
|
||||
if _is_ip_address(host_str) or _is_local_address(host_str) or (skip_check and skip_check(host_str)):
|
||||
logger.debug(f"Using system DNS for IP address or local/private address: {host_str}")
|
||||
return original_getaddrinfo(host, port, family, type, proto, flags)
|
||||
|
||||
results: list[Tuple[AddressFamily, SocketKind, int, str, Tuple[Any, ...]]] = []
|
||||
|
||||
try:
|
||||
# Try IPv6 first if family allows it
|
||||
if family == 0 or family == socket.AF_INET6:
|
||||
logger.debug(f"Resolving IPv6 address for {host_str}")
|
||||
ipv6_answers = resolve_ipv6(host_str)
|
||||
for answer in ipv6_answers:
|
||||
results.append((socket.AF_INET6, cast(SocketKind, type), proto, '', (answer, port_int, 0, 0)))
|
||||
if ipv6_answers:
|
||||
logger.debug(f"Found {len(ipv6_answers)} IPv6 addresses for {host_str}")
|
||||
|
||||
# Then try IPv4
|
||||
if family == 0 or family == socket.AF_INET:
|
||||
logger.debug(f"Resolving IPv4 address for {host_str}")
|
||||
ipv4_answers = resolve_ipv4(host_str)
|
||||
for answer in ipv4_answers:
|
||||
results.append((socket.AF_INET, cast(SocketKind, type), proto, '', (answer, port_int)))
|
||||
if ipv4_answers:
|
||||
logger.debug(f"Found {len(ipv4_answers)} IPv4 addresses for {host_str}")
|
||||
|
||||
if results:
|
||||
logger.debug(f"Resolved {host_str} to {len(results)} addresses")
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Custom DNS resolution failed for {host_str}: {e}, falling back to system DNS")
|
||||
|
||||
# Fall back to system DNS if custom resolution fails
|
||||
try:
|
||||
return original_getaddrinfo(host, port, family, type, proto, flags)
|
||||
except Exception as e:
|
||||
logger.error(f"System DNS resolution also failed for {host_str}: {e}")
|
||||
# Last resort: Try to connect to the hostname directly
|
||||
if family == 0 or family == socket.AF_INET:
|
||||
logger.warning(f"Using direct hostname as last resort for {host_str}")
|
||||
return [(socket.AF_INET, cast(SocketKind, type), proto, '', (host_str, port_int))]
|
||||
else:
|
||||
raise # Re-raise the exception if we can't provide a last resort
|
||||
|
||||
return custom_getaddrinfo
|
||||
|
||||
def init_doh_resolver(doh_server: str = DOH_SERVER):
|
||||
"""Initialize DNS over HTTPS resolver.
|
||||
|
||||
Args:
|
||||
doh_server: The DoH server URL
|
||||
"""
|
||||
# Pre-resolve the DoH server hostname to prevent recursion
|
||||
url = urllib.parse.urlparse(doh_server)
|
||||
server_hostname = url.hostname if url.hostname else ''
|
||||
|
||||
# Use system DNS for DoH server to prevent circular dependencies
|
||||
try:
|
||||
# Temporarily restore original getaddrinfo to resolve DoH server
|
||||
temp_getaddrinfo = socket.getaddrinfo
|
||||
socket.getaddrinfo = original_getaddrinfo
|
||||
|
||||
server_ip = socket.gethostbyname(server_hostname)
|
||||
logger.info(f"DoH server {server_hostname} resolved to IP: {server_ip}")
|
||||
|
||||
# Restore custom getaddrinfo if it was previously set
|
||||
socket.getaddrinfo = temp_getaddrinfo
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to resolve DoH server {server_hostname}: {e}")
|
||||
# Fall back to a known public DNS if resolution fails
|
||||
server_ip = "1.1.1.1"
|
||||
logger.info(f"Using fallback IP for DoH server: {server_ip}")
|
||||
|
||||
# Create DoH resolver
|
||||
doh_resolver = DoHResolver(doh_server, server_hostname, server_ip)
|
||||
|
||||
# Create resolver functions
|
||||
def resolve_ipv4(hostname: str) -> List[str]:
|
||||
return doh_resolver.resolve(hostname, 'A')
|
||||
|
||||
def resolve_ipv6(hostname: str) -> List[str]:
|
||||
return doh_resolver.resolve(hostname, 'AAAA')
|
||||
|
||||
# Skip DoH resolution for the DoH server itself, IP addresses, and private addresses
|
||||
def skip_doh(hostname: str) -> bool:
|
||||
return (hostname == server_hostname or
|
||||
hostname == server_ip or
|
||||
_is_ip_address(hostname) or
|
||||
_is_local_address(hostname))
|
||||
|
||||
# Replace socket.getaddrinfo with our DoH-enabled version
|
||||
socket.getaddrinfo = cast(Any, create_custom_getaddrinfo(
|
||||
resolve_ipv4, resolve_ipv6, skip_doh
|
||||
))
|
||||
|
||||
logger.info("DoH resolver successfully configured and activated")
|
||||
return doh_resolver
|
||||
|
||||
def init_custom_resolver():
|
||||
"""Initialize custom DNS resolver using configured DNS servers."""
|
||||
custom_resolver = create_custom_resolver()
|
||||
|
||||
# Create resolver functions
|
||||
def resolve_ipv4(hostname: str) -> List[str]:
|
||||
return resolve_with_custom_dns(custom_resolver, hostname, 'A')
|
||||
|
||||
def resolve_ipv6(hostname: str) -> List[str]:
|
||||
return resolve_with_custom_dns(custom_resolver, hostname, 'AAAA')
|
||||
|
||||
# Replace socket.getaddrinfo with our custom resolver
|
||||
socket.getaddrinfo = cast(Any, create_custom_getaddrinfo(resolve_ipv4, resolve_ipv6))
|
||||
|
||||
logger.info("Custom DNS resolver successfully configured and activated")
|
||||
return custom_resolver
|
||||
|
||||
# Initialize DNS resolvers based on configuration
|
||||
def init_dns_resolvers():
|
||||
"""Initialize DNS resolvers based on configuration."""
|
||||
if len(CUSTOM_DNS) > 0:
|
||||
init_custom_resolver()
|
||||
if DOH_SERVER:
|
||||
init_doh_resolver()
|
||||
|
||||
# Initialize DNS resolvers
|
||||
init_dns_resolvers()
|
||||
|
||||
# Check available AA_BASE_URLs if set to auto
|
||||
if AA_BASE_URL == "auto":
|
||||
logger.info(f"AA_BASE_URL: auto, checking available urls {AA_AVAILABLE_URLS}")
|
||||
for url in AA_AVAILABLE_URLS:
|
||||
try:
|
||||
response = requests.get(url, proxies=PROXIES)
|
||||
if response.status_code == 200:
|
||||
AA_BASE_URL = url
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Error checking {url}: {e}")
|
||||
if AA_BASE_URL == "auto":
|
||||
AA_BASE_URL = AA_AVAILABLE_URLS[0]
|
||||
config.AA_BASE_URL = AA_BASE_URL
|
||||
logger.info(f"AA_BASE_URL: {AA_BASE_URL}")
|
||||
|
||||
# Configure urllib opener with appropriate headers
|
||||
opener = urllib.request.build_opener()
|
||||
opener.addheaders = [
|
||||
('User-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
|
||||
'AppleWebKit/537.36 (KHTML, like Gecko) '
|
||||
'Chrome/129.0.0.0 Safari/537.3')
|
||||
]
|
||||
urllib.request.install_opener(opener)
|
||||
|
||||
# Need an empty function to be called by downloader.py
|
||||
def init():
|
||||
pass
|
||||
@@ -1,6 +1,6 @@
|
||||
# 📚 Calibre-Web-Automated-Book-Downloader
|
||||
|
||||

|
||||
<img src="src/frontend/public/logo.png" alt="Calibre-Web Automated Book Downloader" width="200">
|
||||
|
||||
An intuitive web interface for searching and requesting book downloads, designed to work seamlessly with [Calibre-Web-Automated](https://github.com/crocodilestick/Calibre-Web-Automated). This project streamlines the process of downloading books and preparing them for integration into your Calibre library.
|
||||
|
||||
@@ -15,11 +15,11 @@ An intuitive web interface for searching and requesting book downloads, designed
|
||||
|
||||
## 🖼️ Screenshots
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
@@ -63,10 +63,15 @@ An intuitive web interface for searching and requesting book downloads, designed
|
||||
| `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.
|
||||
|
||||
If logging is enabld, log folder default location is `/var/log/cwa-book-downloader`
|
||||
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.
|
||||
@@ -83,9 +88,34 @@ Note that if using TOR, the TZ will be calculated automatically based on IP.
|
||||
| `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 |
|
||||
@@ -103,9 +133,11 @@ If disabling the cloudflare bypass, you will be using alternative download hosts
|
||||
| `AA_ADDITIONAL_URLS` | Proxy URLs for AA (, separated) | `` |
|
||||
| `HTTP_PROXY` | HTTP proxy URL | `` |
|
||||
| `HTTPS_PROXY` | HTTPS proxy URL | `` |
|
||||
| `CUSTOM_DNS` | Custom DNS IP | `` |
|
||||
| `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
|
||||
@@ -117,31 +149,44 @@ HTTP_PROXY=http://username:password@proxy.example.com:8080
|
||||
HTTPS_PROXY=http://username:password@proxy.example.com:8080
|
||||
```
|
||||
|
||||
**DNS Configuration**
|
||||
|
||||
The `CUSTOM_DNS` setting supports two formats:
|
||||
The `CUSTOM_DNS` setting controls how DNS resolution works. By default, it is set to `auto` which provides automatic failover for reliable connectivity.
|
||||
|
||||
1. **Custom DNS Servers**: A comma-separated list of DNS server IP addresses
|
||||
**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 in the same string
|
||||
- Supports both IPv4 and IPv6 addresses
|
||||
|
||||
2. **Preset DNS Providers**: Use one of these predefined options:
|
||||
- `google` - Google DNS
|
||||
- `quad9` - Quad9 DNS
|
||||
- `cloudflare` - Cloudflare DNS
|
||||
- `opendns` - OpenDNS
|
||||
|
||||
For users experiencing ISP-level website blocks (such as Virgin Media in the UK), using alternative DNS providers like Cloudflare may help bypass these restrictions
|
||||
|
||||
If a `CUSTOM_DNS` is specified from the preset providers, you can also set a `USE_DOH=true` to force using DNS over HTTPS,
|
||||
which might also help in certain network situations. Note that only `google`, `quad9`, `cloudflare` and `opendns` are
|
||||
supported for now, and any other value in `CUSTOM_DNS` will make the `USE_DOH` flag ignored.
|
||||
|
||||
Try something like this :
|
||||
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 |
|
||||
@@ -175,7 +220,9 @@ volumes:
|
||||
|
||||
Mount should align with your Calibre-Web-Automated ingest folder.
|
||||
|
||||
## 🧅 Tor Variant
|
||||
## Variants:
|
||||
|
||||
### 🧅 Tor Variant
|
||||
|
||||
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.
|
||||
|
||||
@@ -196,11 +243,73 @@ To use the Tor variant:
|
||||
* **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.
|
||||
|
||||
### External Cloudflare resolver variant
|
||||
|
||||
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.
|
||||
|
||||
#### How it works:
|
||||
|
||||
- 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 single service:
|
||||
The application consists of a Flask backend with a React-based frontend:
|
||||
|
||||
1. **calibre-web-automated-bookdownloader**: Main application providing web interface and download functionality
|
||||
### 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:
|
||||
```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
|
||||
|
||||
@@ -214,7 +323,7 @@ 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 pyrequests http://localhost:8084/request/api/status || exit 1
|
||||
CMD curl -s http://localhost:8084/api/status || exit 1
|
||||
```
|
||||
|
||||
## 📝 Logging
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
flask
|
||||
flask-cors
|
||||
flask-socketio
|
||||
python-socketio
|
||||
requests[socks]
|
||||
beautifulsoup4
|
||||
tqdm
|
||||
dnspython
|
||||
gunicorn
|
||||
gevent
|
||||
gevent-websocket
|
||||
psutil
|
||||
emoji
|
||||
rarfile
|
||||
@@ -0,0 +1,4 @@
|
||||
pyvirtualdisplay
|
||||
pyautogui
|
||||
seleniumbase>=4.41.1
|
||||
python-xlib
|
||||
@@ -1,11 +0,0 @@
|
||||
flask
|
||||
requests[socks]
|
||||
beautifulsoup4
|
||||
tqdm
|
||||
pyvirtualdisplay
|
||||
dnspython
|
||||
pyautogui
|
||||
seleniumbase>=4.41.1
|
||||
gunicorn
|
||||
python-xlib
|
||||
psutil
|
||||
@@ -0,0 +1,99 @@
|
||||
# Source Code Documentation
|
||||
|
||||
This directory contains the frontend application for Calibre-Web Automated Book Downloader.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/
|
||||
└── frontend/ # React + TypeScript frontend application
|
||||
├── public/ # Static assets (logo, favicon)
|
||||
├── src/ # Source code
|
||||
│ ├── components/ # React components
|
||||
│ ├── App.tsx # Main application component
|
||||
│ └── styles.css # Global styles
|
||||
├── package.json # Dependencies and scripts
|
||||
├── vite.config.ts # Vite configuration
|
||||
└── tsconfig.json # TypeScript configuration
|
||||
```
|
||||
|
||||
## Frontend Development
|
||||
|
||||
### Prerequisites
|
||||
- Node.js (v16 or higher)
|
||||
- npm or yarn
|
||||
|
||||
### Quick Start
|
||||
|
||||
From the project root:
|
||||
```bash
|
||||
# Install dependencies
|
||||
make install
|
||||
|
||||
# Start development server (http://localhost:5173)
|
||||
make dev
|
||||
|
||||
# Build for production
|
||||
make build
|
||||
|
||||
# Preview production build
|
||||
make preview
|
||||
|
||||
# Run type checking
|
||||
make typecheck
|
||||
```
|
||||
|
||||
Alternatively, from `src/frontend`:
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Technology Stack
|
||||
- **Framework**: React 18 with TypeScript
|
||||
- **Build Tool**: Vite 5
|
||||
- **Styling**: TailwindCSS 3
|
||||
- **Communication**: WebSocket for real-time updates
|
||||
|
||||
### Key Features
|
||||
- **Search Interface**: Real-time book search with filtering
|
||||
- **Download Queue**: Live status updates via WebSocket
|
||||
- **Details Modal**: Rich book information display
|
||||
- **Responsive Design**: Mobile-first approach
|
||||
|
||||
## Development Tips
|
||||
|
||||
### Hot Module Replacement (HMR)
|
||||
The development server supports HMR for instant feedback during development.
|
||||
|
||||
### API Integration
|
||||
The frontend communicates with the Flask backend via:
|
||||
- REST API endpoints (`/api/*`)
|
||||
- WebSocket connection (`ws://localhost:8084/ws`)
|
||||
|
||||
### Building for Production
|
||||
The production build is optimized and minified:
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
Output is generated in `src/frontend/dist/`
|
||||
|
||||
### Type Safety
|
||||
Run TypeScript checks without building:
|
||||
```bash
|
||||
make typecheck
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### Development Server Issues
|
||||
- Ensure port 5173 is available
|
||||
- Check that the backend is running on port 8084
|
||||
- Verify WebSocket connection in browser console
|
||||
|
||||
### Build Issues
|
||||
- Clear `node_modules` and reinstall: `make clean && make install`
|
||||
- Check Node.js version compatibility
|
||||
- Verify TypeScript configuration
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# Production
|
||||
/dist
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# IDE
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.production.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="description" content="Calibre Web Book Downloader - Modern UI" />
|
||||
|
||||
<!-- Theme color with media queries for light/dark mode -->
|
||||
<meta name="theme-color" content="#f8f8f8" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#121212" media="(prefers-color-scheme: dark)" />
|
||||
|
||||
<!-- iOS PWA Meta Tags -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Book Downloader" />
|
||||
|
||||
<!-- App Icons -->
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/logo.png" />
|
||||
<title>Book Downloader</title>
|
||||
<script>
|
||||
// Apply theme immediately before first paint to prevent flash
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('preferred-theme') || 'auto';
|
||||
let theme = savedTheme;
|
||||
|
||||
if (savedTheme === 'auto') {
|
||||
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
|
||||
// Add class to prevent transitions on initial load
|
||||
document.documentElement.classList.add('preload');
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body style="background: var(--bg); color: var(--text);">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "cwad-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.2",
|
||||
"socket.io-client": "^4.7.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,686 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo, CSSProperties } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import {
|
||||
Book,
|
||||
Release,
|
||||
StatusData,
|
||||
AppConfig,
|
||||
} from './types';
|
||||
import { getBookInfo, getMetadataBookInfo, downloadBook, downloadRelease, cancelDownload, clearCompleted, getConfig } from './services/api';
|
||||
import { useToast } from './hooks/useToast';
|
||||
import { useRealtimeStatus } from './hooks/useRealtimeStatus';
|
||||
import { useAuth } from './hooks/useAuth';
|
||||
import { useSearch } from './hooks/useSearch';
|
||||
import { useUrlSearch } from './hooks/useUrlSearch';
|
||||
import { useDownloadTracking } from './hooks/useDownloadTracking';
|
||||
import { Header } from './components/Header';
|
||||
import { SearchSection } from './components/SearchSection';
|
||||
import { AdvancedFilters } from './components/AdvancedFilters';
|
||||
import { ResultsSection } from './components/ResultsSection';
|
||||
import { DetailsModal } from './components/DetailsModal';
|
||||
import { ReleaseModal } from './components/ReleaseModal';
|
||||
import { DownloadsSidebar } from './components/DownloadsSidebar';
|
||||
import { ToastContainer } from './components/ToastContainer';
|
||||
import { Footer } from './components/Footer';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { SettingsModal } from './components/settings';
|
||||
import { ConfigSetupBanner } from './components/ConfigSetupBanner';
|
||||
import { DEFAULT_LANGUAGES, DEFAULT_SUPPORTED_FORMATS } from './data/languages';
|
||||
import { buildSearchQuery } from './utils/buildSearchQuery';
|
||||
import { SearchModeProvider } from './contexts/SearchModeContext';
|
||||
import './styles.css';
|
||||
|
||||
function App() {
|
||||
const { toasts, showToast, removeToast } = useToast();
|
||||
|
||||
// WebSocket URL based on current location
|
||||
const wsUrl = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
|
||||
? 'http://localhost:8084'
|
||||
: window.location.origin;
|
||||
|
||||
// Realtime status with WebSocket and polling fallback
|
||||
const {
|
||||
status: currentStatus,
|
||||
isUsingWebSocket,
|
||||
forceRefresh: fetchStatus
|
||||
} = useRealtimeStatus({
|
||||
wsUrl,
|
||||
pollInterval: 5000,
|
||||
reconnectAttempts: 3,
|
||||
});
|
||||
|
||||
// Download tracking for universal mode
|
||||
const {
|
||||
bookToReleaseMap,
|
||||
trackRelease,
|
||||
markBookCompleted,
|
||||
clearTracking,
|
||||
getButtonState,
|
||||
getUniversalButtonState,
|
||||
} = useDownloadTracking(currentStatus);
|
||||
|
||||
// Authentication state and handlers
|
||||
// Initialized first since search hook needs auth state
|
||||
const {
|
||||
isAuthenticated,
|
||||
authRequired,
|
||||
authChecked,
|
||||
loginError,
|
||||
isLoggingIn,
|
||||
setIsAuthenticated,
|
||||
handleLogin,
|
||||
handleLogout,
|
||||
} = useAuth({
|
||||
showToast,
|
||||
});
|
||||
|
||||
// Search state and handlers
|
||||
const {
|
||||
books,
|
||||
setBooks,
|
||||
isSearching,
|
||||
searchInput,
|
||||
setSearchInput,
|
||||
showAdvanced,
|
||||
setShowAdvanced,
|
||||
advancedFilters,
|
||||
setAdvancedFilters,
|
||||
updateAdvancedFilters,
|
||||
handleSearch,
|
||||
handleResetSearch,
|
||||
handleSortChange,
|
||||
searchFieldValues,
|
||||
updateSearchFieldValue,
|
||||
} = useSearch({
|
||||
showToast,
|
||||
setIsAuthenticated,
|
||||
authRequired,
|
||||
onSearchReset: clearTracking,
|
||||
});
|
||||
|
||||
// Wire up logout callback to clear search state
|
||||
const handleLogoutWithCleanup = useCallback(async () => {
|
||||
await handleLogout();
|
||||
setBooks([]);
|
||||
clearTracking();
|
||||
}, [handleLogout, setBooks, clearTracking]);
|
||||
|
||||
// UI state
|
||||
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
|
||||
const [releaseBook, setReleaseBook] = useState<Book | null>(null);
|
||||
const [config, setConfig] = useState<AppConfig | null>(null);
|
||||
const [downloadsSidebarOpen, setDownloadsSidebarOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [configBannerOpen, setConfigBannerOpen] = useState(false);
|
||||
|
||||
// URL-based search: parse URL params for automatic search on page load
|
||||
const urlSearchEnabled = isAuthenticated && config !== null;
|
||||
const { parsedParams, wasProcessed } = useUrlSearch({ enabled: urlSearchEnabled });
|
||||
const urlSearchExecutedRef = useRef(false);
|
||||
|
||||
// Track previous status and search mode for change detection
|
||||
const prevStatusRef = useRef<StatusData>({});
|
||||
const prevSearchModeRef = useRef<string | undefined>(undefined);
|
||||
|
||||
// Calculate status counts for header badges (memoized)
|
||||
const statusCounts = useMemo(() => {
|
||||
const ongoing = [
|
||||
currentStatus.queued,
|
||||
currentStatus.resolving,
|
||||
currentStatus.downloading,
|
||||
].reduce((sum, status) => sum + (status ? Object.keys(status).length : 0), 0);
|
||||
|
||||
const completed = currentStatus.complete
|
||||
? Object.keys(currentStatus.complete).length
|
||||
: 0;
|
||||
|
||||
const errored = currentStatus.error ? Object.keys(currentStatus.error).length : 0;
|
||||
|
||||
return { ongoing, completed, errored };
|
||||
}, [currentStatus]);
|
||||
|
||||
const activeCount = statusCounts.ongoing;
|
||||
|
||||
// Compute visibility states
|
||||
const hasResults = books.length > 0;
|
||||
const isInitialState = !hasResults;
|
||||
|
||||
// Detect status changes and show notifications
|
||||
const detectChanges = useCallback((prev: StatusData, curr: StatusData) => {
|
||||
if (!prev || Object.keys(prev).length === 0) return;
|
||||
|
||||
// Check for new items in queue
|
||||
const prevQueued = prev.queued || {};
|
||||
const currQueued = curr.queued || {};
|
||||
Object.keys(currQueued).forEach(bookId => {
|
||||
if (!prevQueued[bookId]) {
|
||||
const book = currQueued[bookId];
|
||||
showToast(`${book.title || 'Book'} added to queue`, 'info');
|
||||
// Auto-open downloads sidebar if enabled
|
||||
if (config?.auto_open_downloads_sidebar !== false) {
|
||||
setDownloadsSidebarOpen(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Check for items that started downloading
|
||||
const prevDownloading = prev.downloading || {};
|
||||
const currDownloading = curr.downloading || {};
|
||||
Object.keys(currDownloading).forEach(bookId => {
|
||||
if (!prevDownloading[bookId]) {
|
||||
const book = currDownloading[bookId];
|
||||
showToast(`${book.title || 'Book'} started downloading`, 'info');
|
||||
}
|
||||
});
|
||||
|
||||
// Check for completed items
|
||||
const prevDownloadingIds = new Set(Object.keys(prevDownloading));
|
||||
const prevResolvingIds = new Set(Object.keys(prev.resolving || {}));
|
||||
const prevQueuedIds = new Set(Object.keys(prevQueued));
|
||||
const currComplete = curr.complete || {};
|
||||
|
||||
Object.keys(currComplete).forEach(bookId => {
|
||||
if (prevDownloadingIds.has(bookId) || prevQueuedIds.has(bookId)) {
|
||||
const book = currComplete[bookId];
|
||||
showToast(`${book.title || 'Book'} completed`, 'success');
|
||||
|
||||
// Auto-download to browser if enabled
|
||||
if (config?.download_to_browser && book.download_path) {
|
||||
const link = document.createElement('a');
|
||||
link.href = `/api/localdownload?id=${encodeURIComponent(bookId)}`;
|
||||
link.download = '';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
// Track completed release IDs in session state for universal mode
|
||||
Object.entries(bookToReleaseMap).forEach(([metadataBookId, releaseIds]) => {
|
||||
if (releaseIds.includes(bookId)) {
|
||||
markBookCompleted(metadataBookId);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Check for failed items
|
||||
const currError = curr.error || {};
|
||||
Object.keys(currError).forEach(bookId => {
|
||||
if (prevDownloadingIds.has(bookId) || prevResolvingIds.has(bookId) || prevQueuedIds.has(bookId)) {
|
||||
const book = currError[bookId];
|
||||
const errorMsg = book.status_message || 'Download failed';
|
||||
showToast(`${book.title || 'Book'}: ${errorMsg}`, 'error');
|
||||
}
|
||||
});
|
||||
}, [showToast, bookToReleaseMap, markBookCompleted, config]);
|
||||
|
||||
// Detect status changes when currentStatus updates
|
||||
useEffect(() => {
|
||||
if (prevStatusRef.current && Object.keys(prevStatusRef.current).length > 0) {
|
||||
detectChanges(prevStatusRef.current, currentStatus);
|
||||
}
|
||||
prevStatusRef.current = currentStatus;
|
||||
}, [currentStatus, detectChanges]);
|
||||
|
||||
// Load config function
|
||||
const loadConfig = useCallback(async (mode: 'initial' | 'settings-saved' = 'initial') => {
|
||||
try {
|
||||
const cfg = await getConfig();
|
||||
|
||||
// Check if search mode changed (only on settings save)
|
||||
if (mode === 'settings-saved' && prevSearchModeRef.current !== cfg.search_mode) {
|
||||
setBooks([]);
|
||||
setSelectedBook(null);
|
||||
clearTracking();
|
||||
}
|
||||
|
||||
prevSearchModeRef.current = cfg.search_mode;
|
||||
setConfig(cfg);
|
||||
|
||||
// Determine the default sort based on search mode
|
||||
const defaultSort = cfg.search_mode === 'universal'
|
||||
? (cfg.metadata_default_sort || 'relevance')
|
||||
: (cfg.default_sort || 'relevance');
|
||||
|
||||
if (cfg?.supported_formats) {
|
||||
if (mode === 'initial') {
|
||||
setAdvancedFilters(prev => ({
|
||||
...prev,
|
||||
formats: cfg.supported_formats,
|
||||
sort: defaultSort,
|
||||
}));
|
||||
} else if (mode === 'settings-saved') {
|
||||
// On settings save, update formats and reset sort to new default
|
||||
setAdvancedFilters(prev => ({
|
||||
...prev,
|
||||
formats: prev.formats.filter(f => cfg.supported_formats.includes(f)),
|
||||
sort: defaultSort,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load config:', error);
|
||||
}
|
||||
}, [setBooks, setAdvancedFilters, clearTracking]);
|
||||
|
||||
// Fetch config when authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadConfig('initial');
|
||||
}
|
||||
}, [isAuthenticated, loadConfig]);
|
||||
|
||||
// Execute URL-based search when params are present
|
||||
useEffect(() => {
|
||||
if (
|
||||
wasProcessed &&
|
||||
parsedParams?.hasSearchParams &&
|
||||
!urlSearchExecutedRef.current &&
|
||||
config
|
||||
) {
|
||||
urlSearchExecutedRef.current = true;
|
||||
|
||||
const searchMode = config.search_mode || 'direct';
|
||||
const bookLanguages = config.book_languages || [];
|
||||
const defaultLanguageCodes =
|
||||
config.default_language && config.default_language.length > 0
|
||||
? config.default_language
|
||||
: [bookLanguages[0]?.code || 'en'];
|
||||
|
||||
// Populate search input from URL
|
||||
if (parsedParams.searchInput) {
|
||||
setSearchInput(parsedParams.searchInput);
|
||||
}
|
||||
|
||||
// Apply advanced filters from URL
|
||||
if (Object.keys(parsedParams.advancedFilters).length > 0) {
|
||||
setAdvancedFilters(prev => ({
|
||||
...prev,
|
||||
...parsedParams.advancedFilters,
|
||||
}));
|
||||
|
||||
// Show advanced panel if we have filter values (not just query/sort)
|
||||
const hasAdvancedValues = ['isbn', 'author', 'title', 'content'].some(
|
||||
key => parsedParams.advancedFilters[key as keyof typeof parsedParams.advancedFilters]
|
||||
);
|
||||
if (hasAdvancedValues) {
|
||||
setShowAdvanced(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Build query and trigger search
|
||||
const mergedFilters = {
|
||||
...advancedFilters,
|
||||
...parsedParams.advancedFilters,
|
||||
};
|
||||
|
||||
const query = buildSearchQuery({
|
||||
searchInput: parsedParams.searchInput,
|
||||
showAdvanced: true,
|
||||
advancedFilters: mergedFilters as typeof advancedFilters,
|
||||
bookLanguages,
|
||||
defaultLanguage: defaultLanguageCodes,
|
||||
searchMode,
|
||||
});
|
||||
|
||||
handleSearch(query, config, searchFieldValues);
|
||||
}
|
||||
}, [
|
||||
wasProcessed,
|
||||
parsedParams,
|
||||
config,
|
||||
advancedFilters,
|
||||
searchFieldValues,
|
||||
handleSearch,
|
||||
setSearchInput,
|
||||
setAdvancedFilters,
|
||||
setShowAdvanced,
|
||||
]);
|
||||
|
||||
const handleSettingsSaved = useCallback(() => {
|
||||
loadConfig('settings-saved');
|
||||
}, [loadConfig]);
|
||||
|
||||
// Log WebSocket connection status
|
||||
useEffect(() => {
|
||||
if (isUsingWebSocket) {
|
||||
console.log('✅ Using WebSocket for real-time updates');
|
||||
} else {
|
||||
console.log('⏳ Using polling fallback (5s interval)');
|
||||
}
|
||||
}, [isUsingWebSocket]);
|
||||
|
||||
// Fetch status on startup
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
}, [fetchStatus]);
|
||||
|
||||
// Show book details
|
||||
const handleShowDetails = async (id: string): Promise<void> => {
|
||||
const metadataBook = books.find(b => b.id === id && b.provider && b.provider_id);
|
||||
|
||||
if (metadataBook) {
|
||||
try {
|
||||
const fullBook = await getMetadataBookInfo(metadataBook.provider!, metadataBook.provider_id!);
|
||||
setSelectedBook({
|
||||
...metadataBook,
|
||||
description: fullBook.description || metadataBook.description,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load book description, using search data:', error);
|
||||
setSelectedBook(metadataBook);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const book = await getBookInfo(id);
|
||||
setSelectedBook(book);
|
||||
} catch (error) {
|
||||
console.error('Failed to load book details:', error);
|
||||
showToast('Failed to load book details', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle "Find Downloads" from DetailsModal
|
||||
const handleFindDownloads = (book: Book) => {
|
||||
setSelectedBook(null);
|
||||
setReleaseBook(book);
|
||||
};
|
||||
|
||||
// Download book
|
||||
const handleDownload = async (book: Book): Promise<void> => {
|
||||
try {
|
||||
await downloadBook(book.id);
|
||||
await fetchStatus();
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
showToast('Failed to queue download', 'error');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Cancel download
|
||||
const handleCancel = async (id: string) => {
|
||||
try {
|
||||
await cancelDownload(id);
|
||||
await fetchStatus();
|
||||
} catch (error) {
|
||||
console.error('Cancel failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Clear completed
|
||||
const handleClearCompleted = async () => {
|
||||
try {
|
||||
await clearCompleted();
|
||||
await fetchStatus();
|
||||
} catch (error) {
|
||||
console.error('Clear completed failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Open release modal
|
||||
const handleGetReleases = async (book: Book) => {
|
||||
if (book.provider && book.provider_id) {
|
||||
try {
|
||||
const fullBook = await getMetadataBookInfo(book.provider, book.provider_id);
|
||||
setReleaseBook({
|
||||
...book,
|
||||
description: fullBook.description || book.description,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load book description, using search data:', error);
|
||||
setReleaseBook(book);
|
||||
}
|
||||
} else {
|
||||
setReleaseBook(book);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle download from ReleaseModal
|
||||
const handleReleaseDownload = async (book: Book, release: Release) => {
|
||||
try {
|
||||
trackRelease(book.id, release.source_id);
|
||||
|
||||
await downloadRelease({
|
||||
source: release.source,
|
||||
source_id: release.source_id,
|
||||
title: release.title,
|
||||
format: release.format,
|
||||
size: release.size,
|
||||
size_bytes: release.size_bytes,
|
||||
download_url: release.download_url,
|
||||
protocol: release.protocol,
|
||||
indexer: release.indexer,
|
||||
seeders: release.seeders,
|
||||
extra: release.extra,
|
||||
preview: book.preview, // Pass book cover from metadata
|
||||
author: book.author, // Pass author from metadata
|
||||
});
|
||||
await fetchStatus();
|
||||
} catch (error) {
|
||||
console.error('Release download failed:', error);
|
||||
showToast('Failed to queue download', 'error');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const bookLanguages = config?.book_languages || DEFAULT_LANGUAGES;
|
||||
const supportedFormats = config?.supported_formats || DEFAULT_SUPPORTED_FORMATS;
|
||||
const defaultLanguageCodes =
|
||||
config?.default_language && config.default_language.length > 0
|
||||
? config.default_language
|
||||
: [bookLanguages[0]?.code || 'en'];
|
||||
|
||||
const searchMode = config?.search_mode || 'direct';
|
||||
|
||||
const mainAppContent = (
|
||||
<SearchModeProvider searchMode={searchMode}>
|
||||
<Header
|
||||
calibreWebUrl={config?.calibre_web_url || ''}
|
||||
debug={config?.debug || false}
|
||||
logoUrl="/logo.png"
|
||||
showSearch={!isInitialState}
|
||||
searchInput={searchInput}
|
||||
onSearchChange={setSearchInput}
|
||||
onDownloadsClick={() => setDownloadsSidebarOpen(true)}
|
||||
onSettingsClick={() => {
|
||||
if (config?.settings_enabled) {
|
||||
setSettingsOpen(true);
|
||||
} else {
|
||||
setConfigBannerOpen(true);
|
||||
}
|
||||
}}
|
||||
statusCounts={statusCounts}
|
||||
onLogoClick={() => handleResetSearch(config)}
|
||||
authRequired={authRequired}
|
||||
isAuthenticated={isAuthenticated}
|
||||
onLogout={handleLogoutWithCleanup}
|
||||
onSearch={() => {
|
||||
const query = buildSearchQuery({
|
||||
searchInput,
|
||||
showAdvanced,
|
||||
advancedFilters,
|
||||
bookLanguages,
|
||||
defaultLanguage: defaultLanguageCodes,
|
||||
searchMode,
|
||||
});
|
||||
handleSearch(query, config, searchFieldValues);
|
||||
}}
|
||||
onAdvancedToggle={() => setShowAdvanced(!showAdvanced)}
|
||||
isLoading={isSearching}
|
||||
onShowToast={showToast}
|
||||
onRemoveToast={removeToast}
|
||||
/>
|
||||
|
||||
<AdvancedFilters
|
||||
visible={showAdvanced && !isInitialState}
|
||||
bookLanguages={bookLanguages}
|
||||
defaultLanguage={defaultLanguageCodes}
|
||||
supportedFormats={supportedFormats}
|
||||
filters={advancedFilters}
|
||||
onFiltersChange={updateAdvancedFilters}
|
||||
metadataSearchFields={config?.metadata_search_fields}
|
||||
searchFieldValues={searchFieldValues}
|
||||
onSearchFieldChange={updateSearchFieldValue}
|
||||
onSubmit={() => {
|
||||
const query = buildSearchQuery({
|
||||
searchInput,
|
||||
showAdvanced,
|
||||
advancedFilters,
|
||||
bookLanguages,
|
||||
defaultLanguage: defaultLanguageCodes,
|
||||
searchMode,
|
||||
});
|
||||
handleSearch(query, config, searchFieldValues);
|
||||
}}
|
||||
/>
|
||||
|
||||
<main className="w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-6">
|
||||
<SearchSection
|
||||
onSearch={(query) => handleSearch(query, config, searchFieldValues)}
|
||||
isLoading={isSearching}
|
||||
isInitialState={isInitialState}
|
||||
bookLanguages={bookLanguages}
|
||||
defaultLanguage={defaultLanguageCodes}
|
||||
supportedFormats={config?.supported_formats || DEFAULT_SUPPORTED_FORMATS}
|
||||
logoUrl="/logo.png"
|
||||
searchInput={searchInput}
|
||||
onSearchInputChange={setSearchInput}
|
||||
showAdvanced={showAdvanced}
|
||||
onAdvancedToggle={() => setShowAdvanced(!showAdvanced)}
|
||||
advancedFilters={advancedFilters}
|
||||
onAdvancedFiltersChange={updateAdvancedFilters}
|
||||
metadataSearchFields={config?.metadata_search_fields}
|
||||
searchFieldValues={searchFieldValues}
|
||||
onSearchFieldChange={updateSearchFieldValue}
|
||||
/>
|
||||
|
||||
<ResultsSection
|
||||
books={books}
|
||||
visible={hasResults}
|
||||
onDetails={handleShowDetails}
|
||||
onDownload={handleDownload}
|
||||
onGetReleases={handleGetReleases}
|
||||
getButtonState={getButtonState}
|
||||
getUniversalButtonState={getUniversalButtonState}
|
||||
sortValue={advancedFilters.sort}
|
||||
onSortChange={(value) => handleSortChange(value, config)}
|
||||
metadataSortOptions={config?.metadata_sort_options}
|
||||
/>
|
||||
|
||||
{selectedBook && (
|
||||
<DetailsModal
|
||||
book={selectedBook}
|
||||
onClose={() => setSelectedBook(null)}
|
||||
onDownload={handleDownload}
|
||||
onFindDownloads={handleFindDownloads}
|
||||
buttonState={getButtonState(selectedBook.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{releaseBook && (
|
||||
<ReleaseModal
|
||||
book={releaseBook}
|
||||
onClose={() => setReleaseBook(null)}
|
||||
onDownload={handleReleaseDownload}
|
||||
supportedFormats={supportedFormats}
|
||||
defaultLanguages={defaultLanguageCodes}
|
||||
bookLanguages={bookLanguages}
|
||||
currentStatus={currentStatus}
|
||||
defaultReleaseSource={config?.default_release_source}
|
||||
/>
|
||||
)}
|
||||
|
||||
</main>
|
||||
|
||||
<Footer
|
||||
buildVersion={config?.build_version}
|
||||
releaseVersion={config?.release_version}
|
||||
debug={config?.debug}
|
||||
/>
|
||||
<ToastContainer toasts={toasts} />
|
||||
|
||||
<DownloadsSidebar
|
||||
isOpen={downloadsSidebarOpen}
|
||||
onClose={() => setDownloadsSidebarOpen(false)}
|
||||
status={currentStatus}
|
||||
onRefresh={fetchStatus}
|
||||
onClearCompleted={handleClearCompleted}
|
||||
onCancel={handleCancel}
|
||||
activeCount={activeCount}
|
||||
/>
|
||||
|
||||
<SettingsModal
|
||||
isOpen={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onShowToast={showToast}
|
||||
onSettingsSaved={handleSettingsSaved}
|
||||
/>
|
||||
|
||||
{/* Auto-show banner on startup for users without config */}
|
||||
{config && (
|
||||
<ConfigSetupBanner settingsEnabled={config.settings_enabled} />
|
||||
)}
|
||||
|
||||
{/* Controlled banner shown when clicking settings without config */}
|
||||
<ConfigSetupBanner
|
||||
isOpen={configBannerOpen}
|
||||
onClose={() => setConfigBannerOpen(false)}
|
||||
onContinue={() => {
|
||||
setConfigBannerOpen(false);
|
||||
setSettingsOpen(true);
|
||||
}}
|
||||
/>
|
||||
</SearchModeProvider>
|
||||
);
|
||||
|
||||
const visuallyHiddenStyle: CSSProperties = {
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
padding: 0,
|
||||
margin: '-1px',
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0,
|
||||
};
|
||||
|
||||
if (!authChecked) {
|
||||
return (
|
||||
<div aria-live="polite" style={visuallyHiddenStyle}>
|
||||
Checking authentication…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const shouldRedirectFromLogin = !authRequired || isAuthenticated;
|
||||
const appElement = authRequired && !isAuthenticated ? (
|
||||
<Navigate to="/login" replace />
|
||||
) : (
|
||||
mainAppContent
|
||||
);
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
shouldRedirectFromLogin ? (
|
||||
<Navigate to="/" replace />
|
||||
) : (
|
||||
<LoginPage
|
||||
onLogin={handleLogin}
|
||||
error={loginError}
|
||||
isLoading={isLoggingIn}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route path="/*" element={appElement} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,237 @@
|
||||
import { ReactNode, KeyboardEvent } from 'react';
|
||||
import { AdvancedFilterState, Language, MetadataSearchField } from '../types';
|
||||
import { normalizeLanguageSelection } from '../utils/languageFilters';
|
||||
import { useSearchMode } from '../contexts/SearchModeContext';
|
||||
import { LanguageMultiSelect } from './LanguageMultiSelect';
|
||||
import { DropdownList } from './DropdownList';
|
||||
import { CONTENT_OPTIONS } from '../data/filterOptions';
|
||||
import { SearchFieldRenderer } from './shared';
|
||||
|
||||
const FORMAT_TYPES = ['pdf', 'epub', 'mobi', 'azw3', 'fb2', 'djvu', 'cbz', 'cbr', 'zip', 'rar'] as const;
|
||||
|
||||
interface AdvancedFiltersProps {
|
||||
visible: boolean;
|
||||
bookLanguages: Language[];
|
||||
defaultLanguage: string[];
|
||||
supportedFormats: string[];
|
||||
filters: AdvancedFilterState;
|
||||
onFiltersChange: (updates: Partial<AdvancedFilterState>) => void;
|
||||
formClassName?: string;
|
||||
renderWrapper?: (form: ReactNode) => ReactNode;
|
||||
// Universal mode props
|
||||
metadataSearchFields?: MetadataSearchField[];
|
||||
searchFieldValues?: Record<string, string | number | boolean>;
|
||||
onSearchFieldChange?: (key: string, value: string | number | boolean) => void;
|
||||
// Submit handler for Enter key
|
||||
onSubmit?: () => void;
|
||||
}
|
||||
|
||||
export const AdvancedFilters = ({
|
||||
visible,
|
||||
bookLanguages,
|
||||
defaultLanguage,
|
||||
supportedFormats,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
formClassName,
|
||||
renderWrapper,
|
||||
metadataSearchFields = [],
|
||||
searchFieldValues = {},
|
||||
onSearchFieldChange,
|
||||
onSubmit,
|
||||
}: AdvancedFiltersProps) => {
|
||||
const { searchMode } = useSearchMode();
|
||||
const { isbn, author, title, lang, content, formats } = filters;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && onSubmit) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleLangChange = (next: string[]) => {
|
||||
const normalized = normalizeLanguageSelection(next);
|
||||
onFiltersChange({ lang: normalized });
|
||||
};
|
||||
|
||||
const handleContentChange = (next: string[] | string) => {
|
||||
const value = Array.isArray(next) ? next[0] ?? '' : next;
|
||||
onFiltersChange({ content: value });
|
||||
};
|
||||
|
||||
const handleFormatsChange = (next: string[] | string) => {
|
||||
const nextFormats = Array.isArray(next) ? next : next ? [next] : [];
|
||||
onFiltersChange({ formats: nextFormats });
|
||||
};
|
||||
|
||||
const formatOptions = FORMAT_TYPES.map(format => ({
|
||||
value: format,
|
||||
label: format.toUpperCase(),
|
||||
disabled: !supportedFormats.includes(format),
|
||||
}));
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
// Universal search mode: render dynamic provider fields
|
||||
if (searchMode === 'universal') {
|
||||
// If no fields defined for this provider, don't show the section
|
||||
if (metadataSearchFields.length === 0) return null;
|
||||
|
||||
const universalForm = (
|
||||
<form
|
||||
id="search-filters"
|
||||
className={
|
||||
formClassName ??
|
||||
'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2 lg:ml-[calc(3rem+1rem)] lg:w-[50vw]'
|
||||
}
|
||||
>
|
||||
{metadataSearchFields.map((field) => (
|
||||
<div key={field.key}>
|
||||
{field.type !== 'CheckboxSearchField' && (
|
||||
<label htmlFor={`${field.key}-input`} className="block text-sm mb-1 opacity-80">
|
||||
{field.label}
|
||||
</label>
|
||||
)}
|
||||
<SearchFieldRenderer
|
||||
field={field}
|
||||
value={searchFieldValues[field.key] ?? (field.type === 'CheckboxSearchField' ? false : '')}
|
||||
onChange={(value) => onSearchFieldChange?.(field.key, value)}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
{field.description && (
|
||||
<p className="text-xs mt-1 opacity-60">{field.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</form>
|
||||
);
|
||||
|
||||
const wrappedUniversalForm = renderWrapper ? (
|
||||
renderWrapper(universalForm)
|
||||
) : (
|
||||
<div className="w-full border-b pt-6 pb-4 mb-4" style={{ borderColor: 'var(--border-muted)' }}>
|
||||
<div className="w-full px-4 sm:px-6 lg:px-8">{universalForm}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return wrappedUniversalForm;
|
||||
}
|
||||
|
||||
// Direct download mode: render existing hardcoded filters
|
||||
const form = (
|
||||
<form
|
||||
id="search-filters"
|
||||
className={
|
||||
formClassName ??
|
||||
'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2 lg:ml-[calc(3rem+1rem)] lg:w-[50vw]'
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<label htmlFor="isbn-input" className="block text-sm mb-1 opacity-80">
|
||||
ISBN
|
||||
</label>
|
||||
<input
|
||||
id="isbn-input"
|
||||
type="text"
|
||||
placeholder="ISBN"
|
||||
autoComplete="off"
|
||||
enterKeyHint="search"
|
||||
className="w-full px-3 py-2 rounded-md border"
|
||||
style={{
|
||||
background: 'var(--bg-soft)',
|
||||
color: 'var(--text)',
|
||||
borderColor: 'var(--border-muted)',
|
||||
}}
|
||||
value={isbn}
|
||||
onChange={e => {
|
||||
onFiltersChange({ isbn: e.target.value });
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="author-input" className="block text-sm mb-1 opacity-80">
|
||||
Author
|
||||
</label>
|
||||
<input
|
||||
id="author-input"
|
||||
type="text"
|
||||
placeholder="Author"
|
||||
autoComplete="off"
|
||||
enterKeyHint="search"
|
||||
className="w-full px-3 py-2 rounded-md border"
|
||||
style={{
|
||||
background: 'var(--bg-soft)',
|
||||
color: 'var(--text)',
|
||||
borderColor: 'var(--border-muted)',
|
||||
}}
|
||||
value={author}
|
||||
onChange={e => {
|
||||
onFiltersChange({ author: e.target.value });
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="title-input" className="block text-sm mb-1 opacity-80">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
id="title-input"
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
autoComplete="off"
|
||||
enterKeyHint="search"
|
||||
className="w-full px-3 py-2 rounded-md border"
|
||||
style={{
|
||||
background: 'var(--bg-soft)',
|
||||
color: 'var(--text)',
|
||||
borderColor: 'var(--border-muted)',
|
||||
}}
|
||||
value={title}
|
||||
onChange={e => {
|
||||
onFiltersChange({ title: e.target.value });
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<LanguageMultiSelect
|
||||
options={bookLanguages}
|
||||
value={lang}
|
||||
onChange={handleLangChange}
|
||||
defaultLanguageCodes={defaultLanguage}
|
||||
label="Language"
|
||||
/>
|
||||
<DropdownList
|
||||
label="Content"
|
||||
options={CONTENT_OPTIONS}
|
||||
value={content}
|
||||
onChange={handleContentChange}
|
||||
placeholder="All"
|
||||
/>
|
||||
<div>
|
||||
<DropdownList
|
||||
label="Formats"
|
||||
placeholder="Any"
|
||||
options={formatOptions}
|
||||
value={formats}
|
||||
onChange={handleFormatsChange}
|
||||
multiple
|
||||
showCheckboxes
|
||||
keepOpenOnSelect
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
const wrappedForm = renderWrapper ? (
|
||||
renderWrapper(form)
|
||||
) : (
|
||||
<div className="w-full border-b pt-6 pb-4 mb-4" style={{ borderColor: 'var(--border-muted)' }}>
|
||||
<div className="w-full px-4 sm:px-6 lg:px-8">{form}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return wrappedForm;
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { Book, ButtonStateInfo } from '../types';
|
||||
import { useSearchMode } from '../contexts/SearchModeContext';
|
||||
import { BookDownloadButton } from './BookDownloadButton';
|
||||
import { BookGetButton } from './BookGetButton';
|
||||
|
||||
type ButtonSize = 'sm' | 'md';
|
||||
type ButtonVariant = 'default' | 'icon';
|
||||
|
||||
interface BookActionButtonProps {
|
||||
book: Book;
|
||||
buttonState: ButtonStateInfo;
|
||||
onDownload: (book: Book) => Promise<void>;
|
||||
onGetReleases: (book: Book) => void;
|
||||
isLoadingReleases?: boolean;
|
||||
size?: ButtonSize;
|
||||
variant?: ButtonVariant;
|
||||
fullWidth?: boolean;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export function BookActionButton({
|
||||
book,
|
||||
buttonState,
|
||||
onDownload,
|
||||
onGetReleases,
|
||||
isLoadingReleases,
|
||||
size,
|
||||
variant = 'default',
|
||||
fullWidth,
|
||||
className,
|
||||
style,
|
||||
}: BookActionButtonProps) {
|
||||
const { searchMode } = useSearchMode();
|
||||
|
||||
if (searchMode === 'universal') {
|
||||
return (
|
||||
<BookGetButton
|
||||
book={book}
|
||||
onGetReleases={onGetReleases}
|
||||
buttonState={buttonState}
|
||||
isLoading={isLoadingReleases}
|
||||
size={size}
|
||||
variant={variant}
|
||||
fullWidth={fullWidth}
|
||||
className={className}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BookDownloadButton
|
||||
buttonState={buttonState}
|
||||
onDownload={() => onDownload(book)}
|
||||
size={size}
|
||||
variant={variant === 'default' ? 'primary' : 'icon'}
|
||||
fullWidth={fullWidth}
|
||||
className={className}
|
||||
style={style}
|
||||
ariaLabel={buttonState.text}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useEffect, useState, CSSProperties } from 'react';
|
||||
import { ButtonStateInfo } from '../types';
|
||||
import { CircularProgress } from './shared';
|
||||
|
||||
type ButtonSize = 'sm' | 'md';
|
||||
type ButtonVariant = 'primary' | 'icon';
|
||||
|
||||
interface BookDownloadButtonProps {
|
||||
buttonState: ButtonStateInfo;
|
||||
onDownload: () => Promise<void>;
|
||||
size?: ButtonSize;
|
||||
fullWidth?: boolean;
|
||||
className?: string;
|
||||
showIcon?: boolean;
|
||||
style?: CSSProperties;
|
||||
variant?: ButtonVariant;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
sm: 'px-2.5 py-1.5 text-xs',
|
||||
md: 'px-4 py-2.5 text-sm',
|
||||
};
|
||||
|
||||
const iconVariantSizeClasses: Record<ButtonSize, string> = {
|
||||
sm: 'p-px m-0.5 sm:p-1 sm:m-0.5 aspect-square',
|
||||
md: 'p-0.5 m-0.5 sm:p-1.5 sm:m-0.5 aspect-square',
|
||||
};
|
||||
|
||||
const primaryIconSizes: Record<ButtonSize, string> = {
|
||||
sm: 'w-3.5 h-3.5',
|
||||
md: 'w-4 h-4',
|
||||
};
|
||||
|
||||
const iconVariantIconSizes: Record<ButtonSize, { mobile: string; desktop: string }> = {
|
||||
sm: { mobile: 'w-5 h-5', desktop: 'w-5 h-5' },
|
||||
md: { mobile: 'w-6 h-6', desktop: 'w-6 h-6' },
|
||||
};
|
||||
|
||||
const iconVariantProgressSizes: Record<ButtonSize, { mobile: number; desktop: number }> = {
|
||||
sm: { mobile: 20, desktop: 20 },
|
||||
md: { mobile: 24, desktop: 24 },
|
||||
};
|
||||
|
||||
export const BookDownloadButton = ({
|
||||
buttonState,
|
||||
onDownload,
|
||||
size = 'md',
|
||||
fullWidth = false,
|
||||
className = '',
|
||||
showIcon = false,
|
||||
style,
|
||||
variant = 'primary',
|
||||
ariaLabel,
|
||||
}: BookDownloadButtonProps) => {
|
||||
const [isQueuing, setIsQueuing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isQueuing && buttonState.state !== 'download') {
|
||||
setIsQueuing(false);
|
||||
}
|
||||
}, [buttonState.state, isQueuing]);
|
||||
|
||||
const isCompleted = buttonState.state === 'complete';
|
||||
const hasError = buttonState.state === 'error';
|
||||
const isInProgress = ['queued', 'resolving', 'downloading'].includes(buttonState.state);
|
||||
const isDisabled = buttonState.state !== 'download' || isQueuing || isCompleted;
|
||||
const displayText = isQueuing ? 'Queuing...' : buttonState.text;
|
||||
const showCircularProgress = buttonState.state === 'downloading' && buttonState.progress !== undefined;
|
||||
const showSpinner = (isInProgress && !showCircularProgress) || isQueuing;
|
||||
|
||||
const primaryStateClasses =
|
||||
isCompleted
|
||||
? 'bg-green-600 cursor-not-allowed'
|
||||
: hasError
|
||||
? 'bg-red-600 cursor-not-allowed opacity-75'
|
||||
: isInProgress
|
||||
? 'bg-gray-500 cursor-not-allowed opacity-75'
|
||||
: 'bg-sky-700 hover:bg-sky-800';
|
||||
|
||||
const iconStateClasses =
|
||||
isCompleted
|
||||
? 'bg-green-600 text-white cursor-not-allowed'
|
||||
: hasError
|
||||
? 'bg-red-600 text-white cursor-not-allowed opacity-75'
|
||||
: isInProgress
|
||||
? 'bg-gray-500 text-white cursor-not-allowed opacity-75'
|
||||
: 'text-gray-600 dark:text-gray-200 hover-action';
|
||||
|
||||
const stateClasses = variant === 'icon' ? iconStateClasses : primaryStateClasses;
|
||||
const widthClasses = variant === 'primary' && fullWidth ? 'w-full' : '';
|
||||
|
||||
const baseClasses =
|
||||
variant === 'icon'
|
||||
? 'flex items-center justify-center rounded-full transition-all duration-200 disabled:opacity-80 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-sky-500'
|
||||
: 'inline-flex items-center justify-center gap-1.5 rounded text-white transition-all duration-200 disabled:opacity-80 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-sky-500';
|
||||
|
||||
const sizeClass = variant === 'icon' ? iconVariantSizeClasses[size] : sizeClasses[size];
|
||||
const iconSizes = variant === 'icon' ? iconVariantIconSizes[size] : undefined;
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (isDisabled) return;
|
||||
setIsQueuing(true);
|
||||
try {
|
||||
await onDownload();
|
||||
} catch (error) {
|
||||
setIsQueuing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStatusIcon = () => {
|
||||
if (isCompleted) {
|
||||
if (variant === 'icon' && iconSizes) {
|
||||
return (
|
||||
<>
|
||||
<svg className={`${iconSizes.mobile} sm:hidden`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<svg className={`${iconSizes.desktop} hidden sm:block`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg className={primaryIconSizes[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
if (variant === 'icon' && iconSizes) {
|
||||
return (
|
||||
<>
|
||||
<svg className={`${iconSizes.mobile} sm:hidden`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<svg className={`${iconSizes.desktop} hidden sm:block`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg className={primaryIconSizes[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (showCircularProgress) {
|
||||
if (variant === 'icon') {
|
||||
const progressSize = iconVariantProgressSizes[size].mobile;
|
||||
return <CircularProgress progress={buttonState.progress} size={progressSize} />;
|
||||
}
|
||||
return <CircularProgress progress={buttonState.progress} size={size === 'sm' ? 12 : 16} />;
|
||||
}
|
||||
|
||||
if (showSpinner) {
|
||||
if (variant === 'icon' && iconSizes) {
|
||||
return (
|
||||
<div className={`${iconSizes.mobile} border-2 border-current border-t-transparent rounded-full animate-spin`} />
|
||||
);
|
||||
}
|
||||
const spinnerClass = size === 'sm' ? 'w-3 h-3' : 'w-4 h-4';
|
||||
return <div className={`${spinnerClass} border-2 border-current border-t-transparent rounded-full animate-spin`} />;
|
||||
}
|
||||
|
||||
if (variant === 'icon' && iconSizes) {
|
||||
return (
|
||||
<>
|
||||
<svg className={`${iconSizes.mobile} sm:hidden`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
<svg className={`${iconSizes.desktop} hidden sm:block`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseClasses} ${sizeClass} ${stateClasses} ${widthClasses} ${className}`.trim()}
|
||||
onClick={handleDownload}
|
||||
disabled={isDisabled || isInProgress}
|
||||
data-action="download"
|
||||
style={style}
|
||||
aria-label={ariaLabel ?? displayText}
|
||||
>
|
||||
{variant === 'primary' && showIcon && !isCompleted && !hasError && !showCircularProgress && !showSpinner && (
|
||||
<svg className={primaryIconSizes[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v12m0 0l-4-4m4 4 4-4M6 20h12" />
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{variant === 'primary' && <span className="download-button-text">{displayText}</span>}
|
||||
{variant === 'icon' && <span className="sr-only">{ariaLabel ?? displayText}</span>}
|
||||
|
||||
{renderStatusIcon()}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { Book, ButtonStateInfo } from '../types';
|
||||
import { CircularProgress } from './shared';
|
||||
|
||||
type ButtonSize = 'sm' | 'md';
|
||||
type ButtonVariant = 'default' | 'icon';
|
||||
|
||||
interface BookGetButtonProps {
|
||||
book: Book;
|
||||
onGetReleases: (book: Book) => void;
|
||||
buttonState?: ButtonStateInfo;
|
||||
isLoading?: boolean;
|
||||
size?: ButtonSize;
|
||||
variant?: ButtonVariant;
|
||||
fullWidth?: boolean;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
sm: 'px-2.5 py-1.5 text-xs',
|
||||
md: 'px-4 py-2.5 text-sm',
|
||||
};
|
||||
|
||||
const iconSizeClasses: Record<ButtonSize, string> = {
|
||||
sm: 'p-1.5',
|
||||
md: 'p-1.5 sm:p-2',
|
||||
};
|
||||
|
||||
const iconSizes: Record<ButtonSize, string> = {
|
||||
sm: 'w-3.5 h-3.5',
|
||||
md: 'w-4 h-4',
|
||||
};
|
||||
|
||||
const iconOnlySizes: Record<ButtonSize, string> = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-4 h-4 sm:w-5 sm:h-5',
|
||||
};
|
||||
|
||||
export const BookGetButton = ({
|
||||
book,
|
||||
onGetReleases,
|
||||
buttonState,
|
||||
isLoading = false,
|
||||
size = 'md',
|
||||
variant = 'default',
|
||||
fullWidth = false,
|
||||
className = '',
|
||||
style,
|
||||
}: BookGetButtonProps) => {
|
||||
const isIconVariant = variant === 'icon';
|
||||
const widthClasses = fullWidth ? 'w-full' : '';
|
||||
const sizeClass = isIconVariant ? iconSizeClasses[size] : sizeClasses[size];
|
||||
const iconSize = isIconVariant ? iconOnlySizes[size] : iconSizes[size];
|
||||
|
||||
// Determine states based on buttonState
|
||||
const isCompleted = buttonState?.state === 'complete';
|
||||
const hasError = buttonState?.state === 'error';
|
||||
const isInProgress = buttonState && ['queued', 'resolving', 'downloading'].includes(buttonState.state);
|
||||
const showCircularProgress = buttonState?.state === 'downloading' && buttonState.progress !== undefined;
|
||||
const showSpinner = (isInProgress && !showCircularProgress) || isLoading;
|
||||
|
||||
// Disable button while loading metadata
|
||||
const isDisabled = isLoading;
|
||||
|
||||
// Determine button styling based on state
|
||||
const getButtonClasses = () => {
|
||||
if (isCompleted) {
|
||||
return isIconVariant
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-green-600 hover:bg-green-700';
|
||||
}
|
||||
if (hasError) {
|
||||
return isIconVariant
|
||||
? 'bg-red-600 text-white opacity-75'
|
||||
: 'bg-red-600 hover:bg-red-700';
|
||||
}
|
||||
if (isLoading) {
|
||||
// Show loading state (fetching metadata)
|
||||
return isIconVariant
|
||||
? 'text-gray-400 dark:text-gray-500'
|
||||
: 'bg-emerald-600/70';
|
||||
}
|
||||
if (isInProgress) {
|
||||
// Show progress state but keep it clickable
|
||||
return isIconVariant
|
||||
? 'bg-sky-600 text-white'
|
||||
: 'bg-sky-600 hover:bg-sky-700';
|
||||
}
|
||||
// Default state - icon variant has no background
|
||||
return isIconVariant
|
||||
? 'text-gray-600 dark:text-gray-200 hover-action'
|
||||
: 'bg-emerald-600 hover:bg-emerald-700';
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (isDisabled) return;
|
||||
onGetReleases(book);
|
||||
};
|
||||
|
||||
// Determine display text
|
||||
const getDisplayText = () => {
|
||||
if (isCompleted) return 'Downloaded';
|
||||
if (hasError) return 'Failed';
|
||||
if (isLoading) return 'Loading';
|
||||
if (buttonState?.state === 'downloading') return 'Downloading';
|
||||
if (buttonState?.state === 'resolving') return 'Resolving';
|
||||
if (buttonState?.state === 'queued') return 'Queued';
|
||||
return 'Get';
|
||||
};
|
||||
|
||||
// Render appropriate icon based on state
|
||||
const renderIcon = () => {
|
||||
if (isCompleted) {
|
||||
return (
|
||||
<svg className={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<svg className={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (showCircularProgress) {
|
||||
const progressSize = isIconVariant ? (size === 'sm' ? 16 : 20) : (size === 'sm' ? 12 : 16);
|
||||
return <CircularProgress progress={buttonState?.progress} size={progressSize} />;
|
||||
}
|
||||
|
||||
if (showSpinner) {
|
||||
return (
|
||||
<div
|
||||
className={`${iconSize} border-2 border-current border-t-transparent rounded-full animate-spin`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Default "+" icon for Get action
|
||||
return (
|
||||
<svg className={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
// Icon variant renders as a circular button without text
|
||||
if (isIconVariant) {
|
||||
return (
|
||||
<button
|
||||
className={`flex items-center justify-center rounded-full transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-emerald-500 ${sizeClass} ${getButtonClasses()} ${className}`.trim()}
|
||||
onClick={handleClick}
|
||||
disabled={isDisabled}
|
||||
style={style}
|
||||
aria-label={`${getDisplayText()} releases for ${book.title}`}
|
||||
>
|
||||
{renderIcon()}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`inline-flex items-center justify-center gap-1.5 rounded text-white transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-emerald-500 ${sizeClass} ${widthClasses} ${getButtonClasses()} ${className}`.trim()}
|
||||
onClick={handleClick}
|
||||
disabled={isDisabled}
|
||||
style={style}
|
||||
aria-label={`${getDisplayText()} releases for ${book.title}`}
|
||||
>
|
||||
{renderIcon()}
|
||||
<span>{getDisplayText()}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'cwa-config-banner-dismissed';
|
||||
|
||||
interface ConfigSetupBannerProps {
|
||||
/** Whether to show the banner (controlled mode) */
|
||||
isOpen?: boolean;
|
||||
/** Called when banner is closed */
|
||||
onClose?: () => void;
|
||||
/** Called when "Continue to Settings" is clicked (only shown if provided) */
|
||||
onContinue?: () => void;
|
||||
/** Auto-show mode: show banner if settings not enabled and not dismissed */
|
||||
settingsEnabled?: boolean;
|
||||
}
|
||||
|
||||
export const ConfigSetupBanner = ({
|
||||
isOpen: controlledOpen,
|
||||
onClose,
|
||||
onContinue,
|
||||
settingsEnabled,
|
||||
}: ConfigSetupBannerProps) => {
|
||||
const [autoShowVisible, setAutoShowVisible] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
|
||||
// Auto-show mode: check localStorage on mount
|
||||
useEffect(() => {
|
||||
if (settingsEnabled !== undefined) {
|
||||
const dismissed = localStorage.getItem(STORAGE_KEY);
|
||||
setAutoShowVisible(!settingsEnabled && dismissed !== 'true');
|
||||
}
|
||||
}, [settingsEnabled]);
|
||||
|
||||
// Determine if we should show based on controlled or auto-show mode
|
||||
const isControlledMode = controlledOpen !== undefined;
|
||||
const isVisible = isControlledMode ? controlledOpen : autoShowVisible;
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
setIsClosing(false);
|
||||
if (isControlledMode) {
|
||||
onClose?.();
|
||||
} else {
|
||||
// Auto-show mode: save to localStorage
|
||||
localStorage.setItem(STORAGE_KEY, 'true');
|
||||
setAutoShowVisible(false);
|
||||
}
|
||||
}, 150);
|
||||
}, [isControlledMode, onClose]);
|
||||
|
||||
const handleContinue = useCallback(() => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
setIsClosing(false);
|
||||
onContinue?.();
|
||||
}, 150);
|
||||
}, [onContinue]);
|
||||
|
||||
if (!isVisible && !isClosing) return null;
|
||||
|
||||
// Determine which mode we're in for the footer buttons
|
||||
const showContinueButton = !!onContinue;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-150
|
||||
${isClosing ? 'opacity-0' : 'opacity-100'}`}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
className={`relative w-full max-w-lg rounded-xl
|
||||
border border-[var(--border-muted)] shadow-2xl
|
||||
overflow-hidden
|
||||
${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
|
||||
style={{ background: 'var(--bg)' }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Settings Setup Information"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--border-muted)]">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{showContinueButton ? 'Config Volume Required' : 'New Feature: Settings Page'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-1.5 rounded-lg hover:bg-[var(--hover-surface)] transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-5 py-4 space-y-4">
|
||||
<p className="text-sm opacity-80">
|
||||
{showContinueButton
|
||||
? 'To save settings, add a config volume to your Docker Compose file:'
|
||||
: 'CWA Book Downloader now has a settings page! To enable it, add a config volume to your Docker Compose file:'}
|
||||
</p>
|
||||
|
||||
{/* Code snippet */}
|
||||
<div className="rounded-lg overflow-hidden border border-[var(--border-muted)]">
|
||||
<div className="px-3 py-1.5 text-xs font-medium opacity-60 border-b border-[var(--border-muted)]"
|
||||
style={{ background: 'var(--bg-soft)' }}>
|
||||
docker-compose.yml
|
||||
</div>
|
||||
<pre
|
||||
className="px-3 py-3 text-sm overflow-x-auto"
|
||||
style={{ background: 'var(--bg-soft)' }}
|
||||
>
|
||||
<code>
|
||||
<span className="opacity-60">services:</span>{'\n'}
|
||||
<span className="opacity-60">{' '}cwa-book-downloader:</span>{'\n'}
|
||||
{' '}volumes:{'\n'}
|
||||
{' '}- <span className="text-blue-400">/path/to/config</span>:<span className="text-green-400">/config</span>
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<p className="text-xs opacity-60">
|
||||
{showContinueButton
|
||||
? 'Without this volume, settings changes will not persist across container restarts.'
|
||||
: 'This allows you to configure settings through the UI and persist them across container restarts.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-4 border-t border-[var(--border-muted)] flex justify-end gap-3">
|
||||
{showContinueButton ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium
|
||||
bg-[var(--bg-soft)] border border-[var(--border-muted)]
|
||||
hover:bg-[var(--hover-surface)] transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onClick={handleContinue}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium
|
||||
bg-[var(--primary-color)] text-white
|
||||
hover:bg-[var(--primary-dark)] transition-colors"
|
||||
>
|
||||
Continue to Settings
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium
|
||||
bg-[var(--primary-color)] text-white
|
||||
hover:bg-[var(--primary-dark)] transition-colors"
|
||||
>
|
||||
Got it
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,323 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Book, ButtonStateInfo, isMetadataBook } from '../types';
|
||||
import { BookDownloadButton } from './BookDownloadButton';
|
||||
|
||||
interface DetailsModalProps {
|
||||
book: Book | null;
|
||||
onClose: () => void;
|
||||
onDownload: (book: Book) => Promise<void>;
|
||||
onFindDownloads?: (book: Book) => void; // For Universal mode
|
||||
buttonState: ButtonStateInfo;
|
||||
}
|
||||
|
||||
export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, buttonState }: DetailsModalProps) => {
|
||||
const [isQueuing, setIsQueuing] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
setIsClosing(false);
|
||||
}, 150);
|
||||
}, [onClose]);
|
||||
|
||||
// Clear queuing state and close modal once button state changes from download
|
||||
useEffect(() => {
|
||||
if (isQueuing && buttonState.state !== 'download') {
|
||||
setIsQueuing(false);
|
||||
// Close modal after status has updated
|
||||
const timer = setTimeout(handleClose, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [buttonState.state, isQueuing, handleClose]);
|
||||
|
||||
// Handle ESC key to close modal
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [handleClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (book) {
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}
|
||||
}, [book]);
|
||||
|
||||
if (!book && !isClosing) return null;
|
||||
if (!book) return null;
|
||||
|
||||
const titleId = `book-details-title-${book.id}`;
|
||||
|
||||
const handleDownload = async () => {
|
||||
setIsQueuing(true);
|
||||
try {
|
||||
await onDownload(book);
|
||||
// Don't close here - wait for button state to change
|
||||
} catch (error) {
|
||||
setIsQueuing(false);
|
||||
// Close on error
|
||||
setTimeout(handleClose, 300);
|
||||
}
|
||||
};
|
||||
|
||||
// Determine if this is a metadata book (Universal mode) vs a release (Direct Download)
|
||||
const isMetadata = isMetadataBook(book);
|
||||
|
||||
const publisherInfo = { label: 'Publisher', value: book.publisher || '-' };
|
||||
|
||||
// Build metadata grid based on mode
|
||||
// Universal mode: Year, Genres (no language, no publisher - often blank from providers)
|
||||
// Direct Download mode: Year, Language, Format, Size
|
||||
const metadata = isMetadata
|
||||
? [
|
||||
{ label: 'Year', value: book.year || '-' },
|
||||
...(book.genres && book.genres.length > 0
|
||||
? [{ label: 'Genres', value: book.genres.slice(0, 3).join(', ') }]
|
||||
: []),
|
||||
]
|
||||
: [
|
||||
{ label: 'Year', value: book.year || '-' },
|
||||
{ label: 'Language', value: book.language || '-' },
|
||||
{ label: 'Format', value: book.format || '-' },
|
||||
{ label: 'Size', value: book.size || '-' },
|
||||
];
|
||||
|
||||
// Extract rating and readers from display_fields for dedicated boxes (Universal mode)
|
||||
const ratingField = isMetadata && book.display_fields?.find(f => f.icon === 'star');
|
||||
const readersField = isMetadata && book.display_fields?.find(f => f.icon === 'users');
|
||||
// Other display fields (pages, editions, etc.) shown inline
|
||||
const otherDisplayFields = isMetadata && book.display_fields?.filter(f => f.icon !== 'star' && f.icon !== 'users');
|
||||
|
||||
// Use provider display name from backend, fall back to capitalized provider name
|
||||
const providerDisplay = book.provider_display_name
|
||||
|| (book.provider ? book.provider.charAt(0).toUpperCase() + book.provider.slice(1) : '');
|
||||
const artworkMaxHeight = 'calc(90vh - 220px)';
|
||||
const artworkMaxWidth = 'min(45vw, 520px, calc((90vh - 220px) / 1.6))';
|
||||
const additionalInfo =
|
||||
book.info && Object.keys(book.info).length > 0
|
||||
? Object.entries(book.info).filter(([key]) => {
|
||||
const normalized = key.toLowerCase();
|
||||
return normalized !== 'language' && normalized !== 'year';
|
||||
})
|
||||
: [];
|
||||
const extendedInfoEntries = [[publisherInfo.label, publisherInfo.value], ...additionalInfo];
|
||||
const infoCardClass = 'rounded-2xl border border-[var(--border-muted)] px-4 py-3 text-sm';
|
||||
const infoCardStyle = { background: 'var(--bg)' };
|
||||
const infoLabelClass = 'text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400';
|
||||
const infoValueClass = 'text-gray-900 dark:text-gray-100';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay active px-4 py-6 sm:px-6"
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget) handleClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`details-container w-full max-w-4xl ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<div className="flex max-h-[90vh] flex-col overflow-hidden rounded-2xl border border-[var(--border-muted)] bg-[var(--bg-soft)] text-[var(--text)] shadow-2xl">
|
||||
<header className="flex items-start gap-4 border-b border-[var(--border-muted)] px-5 py-4">
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Book</p>
|
||||
<h3 id={titleId} className="text-lg font-semibold leading-snug">
|
||||
{book.title || 'Untitled'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{book.author || 'Unknown author'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="rounded-full p-2 text-gray-500 transition-colors hover-action hover:text-gray-900 dark:hover:text-gray-100"
|
||||
aria-label="Close details"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-6">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-stretch lg:gap-8 lg:min-h-0">
|
||||
<div className="flex w-full justify-center lg:w-auto lg:flex-none lg:justify-start lg:self-stretch lg:pr-4">
|
||||
{book.preview ? (
|
||||
<div
|
||||
className="flex w-full items-center justify-center lg:h-full lg:max-w-none"
|
||||
style={{ maxHeight: artworkMaxHeight, maxWidth: artworkMaxWidth }}
|
||||
>
|
||||
<img
|
||||
src={book.preview}
|
||||
alt="Book cover"
|
||||
className="h-auto max-h-full w-auto max-w-full rounded-xl object-contain shadow-lg"
|
||||
style={{ maxHeight: '100%', maxWidth: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex w-full items-center justify-center rounded-xl border border-dashed border-[var(--border-muted)] bg-[var(--bg)]/60 p-6 text-sm text-gray-500 lg:h-full lg:max-w-none"
|
||||
style={{ maxHeight: artworkMaxHeight, maxWidth: artworkMaxWidth }}
|
||||
>
|
||||
No cover
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 sm:gap-5 lg:min-h-0">
|
||||
{book.description && (
|
||||
<div className={`${infoCardClass} space-y-1`} style={infoCardStyle}>
|
||||
<p className={infoLabelClass}>Description</p>
|
||||
<p className={`${infoValueClass} whitespace-pre-line`}>{book.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata grid - adapts columns based on mode and available data */}
|
||||
<div className={`grid grid-cols-2 gap-3 lg:gap-4 ${isMetadata ? 'lg:grid-cols-2' : 'lg:grid-cols-4'}`}>
|
||||
{metadata.map(item => (
|
||||
<div key={item.label} className={`${infoCardClass} space-y-1`} style={infoCardStyle}>
|
||||
<p className={infoLabelClass}>{item.label}</p>
|
||||
<p className={infoValueClass}>{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Rating box - Universal mode only */}
|
||||
{ratingField && (
|
||||
<div className={`${infoCardClass} space-y-1`} style={infoCardStyle}>
|
||||
<p className={infoLabelClass}>{ratingField.label}</p>
|
||||
<p className={`${infoValueClass} flex items-center gap-1.5`}>
|
||||
<svg className="h-4 w-4 text-amber-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
{ratingField.value}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Readers box - Universal mode only */}
|
||||
{readersField && (
|
||||
<div className={`${infoCardClass} space-y-1`} style={infoCardStyle}>
|
||||
<p className={infoLabelClass}>{readersField.label}</p>
|
||||
<p className={`${infoValueClass} flex items-center gap-1.5`}>
|
||||
<svg className="h-4 w-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
|
||||
</svg>
|
||||
{readersField.value}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Other display fields (pages, editions) - Universal mode only */}
|
||||
{otherDisplayFields && otherDisplayFields.length > 0 && (
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
{otherDisplayFields.map(field => (
|
||||
<span key={field.label} className="flex items-center gap-1.5">
|
||||
{field.icon === 'book' && (
|
||||
<svg className="h-4 w-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
)}
|
||||
{field.icon === 'editions' && (
|
||||
<svg className="h-4 w-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6.878V6a2.25 2.25 0 0 1 2.25-2.25h7.5A2.25 2.25 0 0 1 18 6v.878m-12 0c.235-.083.487-.128.75-.128h10.5c.263 0 .515.045.75.128m-12 0A2.25 2.25 0 0 0 4.5 9v.878m13.5-3A2.25 2.25 0 0 1 19.5 9v.878m0 0a2.246 2.246 0 0 0-.75-.128H5.25c-.263 0-.515.045-.75.128m15 0A2.25 2.25 0 0 1 21 12v6a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 18v-6c0-.98.626-1.813 1.5-2.122" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="text-gray-500 dark:text-gray-400">{field.label}:</span>
|
||||
<span className="text-gray-900 dark:text-gray-100">{field.value}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ISBN - Universal mode only */}
|
||||
{isMetadata && (book.isbn_13 || book.isbn_10) && (
|
||||
<div className={`${infoCardClass} space-y-1`} style={infoCardStyle}>
|
||||
<p className={infoLabelClass}>ISBN</p>
|
||||
<p className={infoValueClass}>{book.isbn_13 || book.isbn_10}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extended info (publisher, etc.) - Direct Download mode only */}
|
||||
{!isMetadata && extendedInfoEntries.length > 0 && (
|
||||
<div className={`${infoCardClass} space-y-3`} style={infoCardStyle}>
|
||||
<ul className="space-y-3 list-none">
|
||||
{extendedInfoEntries.map(([key, value]) => (
|
||||
<li key={key} className="space-y-1">
|
||||
<p className={infoLabelClass}>{key}</p>
|
||||
<p className={infoValueClass}>{Array.isArray(value) ? value.join(', ') : value}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="border-t border-[var(--border-muted)] bg-[var(--bg-soft)] px-5 py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* Source link - Universal mode only */}
|
||||
{isMetadata && book.source_url ? (
|
||||
<a
|
||||
href={book.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--border-muted)] bg-[var(--bg)] px-3 py-2 text-xs font-medium text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-900 dark:text-gray-400 dark:hover:border-gray-500 dark:hover:text-gray-200"
|
||||
>
|
||||
View on {providerDisplay}
|
||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{isMetadata ? (
|
||||
<button
|
||||
onClick={() => onFindDownloads?.(book)}
|
||||
className="rounded-full bg-emerald-600 px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
|
||||
>
|
||||
Find Downloads
|
||||
</button>
|
||||
) : (
|
||||
<BookDownloadButton
|
||||
buttonState={buttonState}
|
||||
onDownload={handleDownload}
|
||||
size="md"
|
||||
className="rounded-full px-6 py-2.5 text-sm font-medium"
|
||||
ariaLabel={`Download ${book.title || 'book'}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,387 @@
|
||||
import { useEffect } from 'react';
|
||||
import { StatusData, Book } from '../types';
|
||||
|
||||
interface DownloadsSidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
status: StatusData;
|
||||
onRefresh: () => void;
|
||||
onClearCompleted: () => void;
|
||||
onCancel: (id: string) => void;
|
||||
activeCount: number;
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, { bg: string; text: string; label: string; waveColor: string }> = {
|
||||
queued: { bg: 'bg-amber-500/20', text: 'text-amber-700 dark:text-amber-300', label: 'Queued', waveColor: 'rgba(217, 119, 6, 0.3)' },
|
||||
resolving: { bg: 'bg-indigo-500/20', text: 'text-indigo-700 dark:text-indigo-300', label: 'Resolving', waveColor: 'rgba(79, 70, 229, 0.3)' },
|
||||
downloading: { bg: 'bg-sky-500/20', text: 'text-sky-700 dark:text-sky-300', label: 'Downloading', waveColor: 'rgba(2, 132, 199, 0.3)' },
|
||||
complete: { bg: 'bg-green-500/20', text: 'text-green-700 dark:text-green-300', label: 'Complete', waveColor: '' },
|
||||
error: { bg: 'bg-red-500/20', text: 'text-red-700 dark:text-red-300', label: 'Error', waveColor: '' },
|
||||
cancelled: { bg: 'bg-gray-500/20', text: 'text-gray-700 dark:text-gray-300', label: 'Cancelled', waveColor: '' },
|
||||
};
|
||||
|
||||
// Add keyframe animation for wave effect
|
||||
const styleSheet = document.createElement('style');
|
||||
styleSheet.textContent = `
|
||||
@keyframes wave {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
`;
|
||||
if (!document.head.querySelector('style[data-wave-animation]')) {
|
||||
styleSheet.setAttribute('data-wave-animation', 'true');
|
||||
document.head.appendChild(styleSheet);
|
||||
}
|
||||
|
||||
// Book thumbnail component with fallback
|
||||
const BookThumbnail = ({ preview, title }: { preview?: string; title?: string }) => {
|
||||
if (!preview) {
|
||||
return (
|
||||
<div
|
||||
className="w-16 h-24 rounded-tl bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[8px] font-medium text-gray-500 dark:text-gray-400"
|
||||
style={{ aspectRatio: '2/3' }}
|
||||
>
|
||||
No Cover
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={preview}
|
||||
alt={title || 'Book cover'}
|
||||
className="w-16 h-24 object-cover rounded-tl shadow-sm"
|
||||
style={{ aspectRatio: '2/3' }}
|
||||
onError={(e) => {
|
||||
// Replace with placeholder on error
|
||||
const target = e.target as HTMLImageElement;
|
||||
const placeholder = document.createElement('div');
|
||||
placeholder.className = 'w-16 h-24 rounded-tl bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[8px] font-medium text-gray-500 dark:text-gray-400';
|
||||
placeholder.style.aspectRatio = '2/3';
|
||||
placeholder.textContent = 'No Cover';
|
||||
target.replaceWith(placeholder);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper to get progress percentage based on status
|
||||
const getStatusProgress = (statusName: string, bookProgress?: number): number => {
|
||||
switch (statusName) {
|
||||
case 'queued':
|
||||
return 5;
|
||||
case 'resolving':
|
||||
return 15;
|
||||
case 'downloading':
|
||||
// Map actual progress (0-100) to 20-100 range
|
||||
if (typeof bookProgress === 'number') {
|
||||
return 20 + (bookProgress * 0.8);
|
||||
}
|
||||
return 20;
|
||||
case 'complete':
|
||||
case 'error':
|
||||
return 100;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to get progress bar color based on status
|
||||
const getProgressBarColor = (statusName: string): string => {
|
||||
if (statusName === 'complete') return 'bg-green-600';
|
||||
if (statusName === 'error') return 'bg-red-600';
|
||||
if (statusName === 'queued') return 'bg-amber-600';
|
||||
if (statusName === 'resolving') return 'bg-indigo-600';
|
||||
if (statusName === 'downloading') return 'bg-sky-600';
|
||||
return 'bg-sky-600';
|
||||
};
|
||||
|
||||
|
||||
export const DownloadsSidebar = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
status,
|
||||
onRefresh,
|
||||
onClearCompleted,
|
||||
onCancel,
|
||||
activeCount,
|
||||
}: DownloadsSidebarProps) => {
|
||||
// Handle ESC key to close sidebar
|
||||
useEffect(() => {
|
||||
if (!isOpen) return; // Only listen when sidebar is open
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Collect all download items from different status sections
|
||||
const allDownloadItems: Array<{ book: Book; status: string }> = [];
|
||||
|
||||
const statusTypes = ['downloading', 'resolving', 'queued', 'error', 'complete', 'cancelled'];
|
||||
|
||||
statusTypes.forEach((statusName) => {
|
||||
const items = (status as any)[statusName];
|
||||
if (items && Object.keys(items).length > 0) {
|
||||
Object.values(items).forEach((book: any) => {
|
||||
allDownloadItems.push({ book, status: statusName });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by added_time descending (newest first)
|
||||
allDownloadItems.sort((a, b) => (b.book.added_time || 0) - (a.book.added_time || 0));
|
||||
|
||||
const renderDownloadItem = (item: { book: Book; status: string }) => {
|
||||
const { book, status: statusName } = item;
|
||||
const statusStyle = STATUS_STYLES[statusName] || {
|
||||
bg: 'bg-gray-500/10',
|
||||
text: 'text-gray-600',
|
||||
label: statusName.charAt(0).toUpperCase() + statusName.slice(1),
|
||||
};
|
||||
|
||||
const isInProgress = ['queued', 'resolving', 'downloading'].includes(statusName);
|
||||
const isCompleted = statusName === 'complete';
|
||||
const hasError = statusName === 'error';
|
||||
|
||||
// Get progress information
|
||||
const progress = getStatusProgress(statusName, book.progress);
|
||||
const progressBarColor = getProgressBarColor(statusName);
|
||||
|
||||
// Format progress text - use status_message from backend if available
|
||||
let progressText = book.status_message || statusStyle.label;
|
||||
if (statusName === 'downloading' && !book.status_message && book.progress && book.size) {
|
||||
// Fallback: calculate size progress only if backend didn't provide a message
|
||||
const sizeValue = parseFloat(book.size.replace(/[^\d.]/g, ''));
|
||||
const sizeUnit = book.size.replace(/[\d.\s]/g, '');
|
||||
const downloadedSize = (book.progress / 100) * sizeValue;
|
||||
progressText = `${downloadedSize.toFixed(1)}${sizeUnit} / ${book.size}`;
|
||||
} else if (isCompleted) {
|
||||
progressText = book.status_message || 'Complete';
|
||||
} else if (hasError) {
|
||||
progressText = book.status_message || 'Failed';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={book.id}
|
||||
className="relative rounded-lg border hover:shadow-md transition-shadow overflow-hidden"
|
||||
style={{ borderColor: 'var(--border-muted)', background: 'var(--bg-soft)' }}
|
||||
>
|
||||
{/* Cancel/Clear Button - top right corner */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCancel(book.id);
|
||||
}}
|
||||
className="absolute top-1 right-1 z-10 flex items-center justify-center w-6 h-6 rounded-full hover:bg-red-100 dark:hover:bg-red-900/30 text-gray-500 hover:text-red-600 transition-colors"
|
||||
title={isInProgress ? "Cancel download" : "Clear from list"}
|
||||
aria-label={isInProgress ? "Cancel download" : "Clear from list"}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex gap-2">
|
||||
{/* Book Thumbnail - left side */}
|
||||
<div className="flex-shrink-0">
|
||||
<BookThumbnail preview={book.preview} title={book.title} />
|
||||
</div>
|
||||
|
||||
{/* Book Info - right side */}
|
||||
<div className="flex-1 min-w-0 flex flex-col pl-1.5 pr-3 pt-2 pb-2">
|
||||
{/* Title & Author - with safe area for cancel/clear button */}
|
||||
<div className="pr-6">
|
||||
<h3 className="font-semibold text-sm truncate" title={book.title}>
|
||||
{isCompleted && book.download_path ? (
|
||||
<a
|
||||
href={`/api/localdownload?id=${encodeURIComponent(book.id)}`}
|
||||
className="text-sky-600 hover:underline"
|
||||
>
|
||||
{book.title || 'Unknown Title'}
|
||||
</a>
|
||||
) : (
|
||||
book.title || 'Unknown Title'
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-xs opacity-70 truncate" title={book.author}>
|
||||
{book.author || 'Unknown Author'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Format, Size, Source */}
|
||||
<div className="text-xs opacity-70 mt-1">
|
||||
{book.format && <span className="uppercase">{book.format}</span>}
|
||||
{book.format && book.size && <span> • </span>}
|
||||
{book.size && <span>{book.size}</span>}
|
||||
{book.source_display_name && (
|
||||
<>
|
||||
<span> • </span>
|
||||
<span>{book.source_display_name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="flex justify-end mt-auto pt-1">
|
||||
<span
|
||||
className={`relative px-2 py-0.5 rounded-lg text-xs font-medium ${statusStyle.bg} ${statusStyle.text}`}
|
||||
>
|
||||
{/* Wave animation overlay for in-progress states */}
|
||||
{isInProgress && statusStyle.waveColor && (
|
||||
<span
|
||||
key={statusName}
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: `linear-gradient(90deg, transparent 0%, ${statusStyle.waveColor} 50%, transparent 100%)`,
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'wave 2s linear infinite',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="relative">{progressText}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar - at bottom */}
|
||||
<div className="h-1.5 bg-gray-200 dark:bg-gray-700 overflow-hidden relative">
|
||||
<div
|
||||
className={`h-full ${progressBarColor} transition-all duration-300 relative overflow-hidden`}
|
||||
style={{ width: `${Math.min(100, Math.max(0, progress))}%` }}
|
||||
>
|
||||
{/* Animated wave effect for in-progress states */}
|
||||
{isInProgress && progress < 100 && (
|
||||
<div
|
||||
className="absolute inset-0 opacity-30"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.5) 50%, transparent 100%)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'wave 2s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`fixed inset-0 bg-black/50 z-40 transition-opacity duration-300 ${
|
||||
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={`fixed top-0 right-0 h-full w-full sm:w-96 z-50 flex flex-col shadow-2xl transition-transform duration-300 ${
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
style={{ background: 'var(--bg)' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between p-4"
|
||||
style={{ paddingTop: 'calc(1rem + env(safe-area-inset-top))' }}
|
||||
>
|
||||
<h2 className="text-lg font-semibold">Downloads</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full hover-action transition-colors"
|
||||
aria-label="Close sidebar"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div
|
||||
className="flex items-center gap-2 p-4 border-b"
|
||||
style={{ borderColor: 'var(--border-muted)' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearCompleted}
|
||||
className="flex-1 flex items-center justify-center px-3 py-2 h-10 rounded border text-sm hover-action transition-colors"
|
||||
style={{ borderColor: 'var(--border-muted)' }}
|
||||
>
|
||||
Clear Completed
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
className="flex items-center justify-center h-10 w-10 rounded-full text-sm hover-action transition-colors ml-auto"
|
||||
aria-label="Refresh"
|
||||
title="Refresh"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Queue Items */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto p-4 space-y-3"
|
||||
style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}
|
||||
>
|
||||
{allDownloadItems.length > 0 ? (
|
||||
allDownloadItems.map((item) => renderDownloadItem(item))
|
||||
) : (
|
||||
<div className="text-center text-sm opacity-70 mt-8">
|
||||
No downloads in queue
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with active count */}
|
||||
{activeCount > 0 && (
|
||||
<div
|
||||
className="p-3 border-t text-xs text-center opacity-70"
|
||||
style={{
|
||||
borderColor: 'var(--border-muted)',
|
||||
paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom))',
|
||||
}}
|
||||
>
|
||||
{activeCount} active {activeCount === 1 ? 'download' : 'downloads'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
import { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
interface DropdownProps {
|
||||
label?: string;
|
||||
summary?: ReactNode;
|
||||
children: (helpers: { close: () => void }) => ReactNode;
|
||||
align?: 'left' | 'right';
|
||||
widthClassName?: string;
|
||||
buttonClassName?: string;
|
||||
panelClassName?: string;
|
||||
disabled?: boolean;
|
||||
renderTrigger?: (props: { isOpen: boolean; toggle: () => void }) => ReactNode;
|
||||
}
|
||||
|
||||
export const Dropdown = ({
|
||||
label,
|
||||
summary,
|
||||
children,
|
||||
align = 'left',
|
||||
widthClassName = 'w-full',
|
||||
buttonClassName = '',
|
||||
panelClassName = '',
|
||||
disabled = false,
|
||||
renderTrigger,
|
||||
}: DropdownProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [panelDirection, setPanelDirection] = useState<'down' | 'up'>('down');
|
||||
|
||||
const toggleOpen = () => {
|
||||
if (disabled) return;
|
||||
setIsOpen(prev => !prev);
|
||||
};
|
||||
|
||||
const close = () => setIsOpen(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const updatePanelDirection = () => {
|
||||
if (!containerRef.current || !panelRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const panelHeight = panelRef.current.offsetHeight || panelRef.current.scrollHeight;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - 8;
|
||||
const spaceAbove = rect.top - 8;
|
||||
const shouldOpenUp = spaceBelow < panelHeight && spaceAbove >= panelHeight;
|
||||
|
||||
setPanelDirection(shouldOpenUp ? 'up' : 'down');
|
||||
};
|
||||
|
||||
updatePanelDirection();
|
||||
window.addEventListener('resize', updatePanelDirection);
|
||||
window.addEventListener('scroll', updatePanelDirection, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePanelDirection);
|
||||
window.removeEventListener('scroll', updatePanelDirection, true);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div className={`relative ${widthClassName}`} ref={containerRef}>
|
||||
{label && (
|
||||
<label className="block text-sm mb-1 opacity-80" onClick={toggleOpen}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
{renderTrigger ? (
|
||||
renderTrigger({ isOpen, toggle: toggleOpen })
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleOpen}
|
||||
disabled={disabled}
|
||||
className={`w-full px-3 py-2 rounded-md border flex items-center justify-between text-left focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 ${buttonClassName}`}
|
||||
style={{
|
||||
background: 'var(--bg-soft)',
|
||||
color: 'var(--text)',
|
||||
borderColor: 'var(--border-muted)',
|
||||
}}
|
||||
>
|
||||
<span className="truncate">
|
||||
{summary ?? <span className="opacity-60">Select an option</span>}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`absolute ${align === 'right' ? 'right-0' : 'left-0'} ${
|
||||
panelDirection === 'down' ? 'mt-2' : 'bottom-full mb-2'
|
||||
} rounded-md border shadow-lg z-20 ${panelClassName || widthClassName}`}
|
||||
style={{
|
||||
background: 'var(--bg)',
|
||||
borderColor: 'var(--border-muted)',
|
||||
}}
|
||||
>
|
||||
<div className="max-h-64 overflow-auto">
|
||||
{children({ close })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Dropdown } from './Dropdown';
|
||||
|
||||
export interface DropdownListOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
interface DropdownListProps {
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
options: DropdownListOption[];
|
||||
multiple?: boolean;
|
||||
showCheckboxes?: boolean;
|
||||
value: string[] | string | null | undefined;
|
||||
onChange: (value: string[] | string) => void;
|
||||
align?: 'left' | 'right';
|
||||
widthClassName?: string;
|
||||
buttonClassName?: string;
|
||||
summaryFormatter?: (selected: DropdownListOption[], placeholder: string) => ReactNode;
|
||||
keepOpenOnSelect?: boolean;
|
||||
}
|
||||
|
||||
export const DropdownList = ({
|
||||
label,
|
||||
placeholder = 'Select an option',
|
||||
options,
|
||||
multiple = false,
|
||||
showCheckboxes,
|
||||
value,
|
||||
onChange,
|
||||
align,
|
||||
widthClassName,
|
||||
buttonClassName,
|
||||
summaryFormatter,
|
||||
keepOpenOnSelect,
|
||||
}: DropdownListProps) => {
|
||||
const selectedValues = normalizeValue(value, multiple);
|
||||
const selectedOptions = options.filter(opt => selectedValues.includes(opt.value));
|
||||
const checkboxEnabled = showCheckboxes ?? multiple;
|
||||
const stayOpenOnSelect = keepOpenOnSelect ?? multiple;
|
||||
|
||||
const renderSummary = () => {
|
||||
if (summaryFormatter) {
|
||||
return summaryFormatter(selectedOptions, placeholder);
|
||||
}
|
||||
|
||||
if (selectedOptions.length === 0) {
|
||||
// For single select with empty string value, find and show the empty value option label
|
||||
if (!multiple) {
|
||||
const emptyOption = options.find(opt => opt.value === '');
|
||||
if (emptyOption) {
|
||||
return emptyOption.label;
|
||||
}
|
||||
}
|
||||
return <span className="opacity-60">{placeholder}</span>;
|
||||
}
|
||||
|
||||
if (!multiple) {
|
||||
return selectedOptions[0]?.label ?? placeholder;
|
||||
}
|
||||
|
||||
if (selectedOptions.length === 1) {
|
||||
return selectedOptions[0].label;
|
||||
}
|
||||
|
||||
const [first, second, ...rest] = selectedOptions.map(opt => opt.label);
|
||||
const suffix = rest.length > 0 ? ` +${rest.length}` : '';
|
||||
return `${first}, ${second ?? ''}${suffix}`.trim();
|
||||
};
|
||||
|
||||
const handleOptionClick = (option: DropdownListOption, close: () => void) => {
|
||||
if (option.disabled) return;
|
||||
|
||||
if (multiple) {
|
||||
const next = selectedValues.includes(option.value)
|
||||
? selectedValues.filter(v => v !== option.value)
|
||||
: [...selectedValues, option.value];
|
||||
onChange(next);
|
||||
if (!stayOpenOnSelect) {
|
||||
close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedValues[0] === option.value) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(option.value);
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
label={label}
|
||||
summary={renderSummary()}
|
||||
align={align}
|
||||
widthClassName={widthClassName}
|
||||
buttonClassName={buttonClassName}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div role="listbox" aria-multiselectable={multiple}>
|
||||
{options.map(option => (
|
||||
<button
|
||||
type="button"
|
||||
key={option.value}
|
||||
className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 hover-surface ${
|
||||
option.disabled ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
onClick={() => handleOptionClick(option, close)}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{checkboxEnabled && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedValues.includes(option.value)}
|
||||
readOnly
|
||||
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500 pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
{option.icon}
|
||||
<div className="flex flex-col">
|
||||
<span>{option.label}</span>
|
||||
{option.description && (
|
||||
<span className="text-xs opacity-70">{option.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeValue = (value: string[] | string | null | undefined, multiple: boolean): string[] => {
|
||||
if (multiple) {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return [value];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.length ? [value[0]] : [];
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && value) {
|
||||
return [value];
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
interface FooterProps {
|
||||
buildVersion?: string;
|
||||
releaseVersion?: string;
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
export const Footer = ({ buildVersion, releaseVersion, debug }: FooterProps) => {
|
||||
// Determine version display - show "dev" if no version is set
|
||||
const versionDisplay = releaseVersion && releaseVersion !== 'N/A'
|
||||
? releaseVersion
|
||||
: 'dev';
|
||||
|
||||
return (
|
||||
<footer
|
||||
className="mt-8 border-t py-6"
|
||||
style={{
|
||||
borderColor: 'var(--border-muted)',
|
||||
paddingBottom: 'calc(1.5rem + env(safe-area-inset-bottom))',
|
||||
}}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0 text-left">
|
||||
<p className="text-sm opacity-80">Calibre Web Book Downloader</p>
|
||||
<p className="text-xs opacity-60 mt-1">
|
||||
Version: {versionDisplay}
|
||||
{buildVersion && buildVersion !== 'N/A' && ` (${buildVersion})`}
|
||||
{debug && ' • Debug Mode'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<a
|
||||
href="https://github.com/calibrain/calibre-web-automated-book-downloader"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="opacity-80 hover:opacity-100"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
className="w-6 h-6"
|
||||
>
|
||||
<path d="M12 1C5.923 1 1 5.923 1 12c0 4.867 3.149 8.979 7.521 10.436.55.096.756-.233.756-.522 0-.262-.013-1.128-.013-2.049-2.764.509-3.479-.674-3.699-1.292-.124-.317-.66-1.293-1.127-1.554-.385-.207-.936-.715-.014-.729.866-.014 1.485.797 1.691 1.128.99 1.663 2.571 1.196 3.204.907.096-.715.385-1.196.701-1.471-2.448-.275-5.005-1.224-5.005-5.432 0-1.196.426-2.186 1.128-2.956-.111-.275-.496-1.402.11-2.915 0 0 .921-.288 3.024 1.128a10.193 10.193 0 0 1 2.75-.371c.936 0 1.871.123 2.75.371 2.104-1.43 3.025-1.128 3.025-1.128.605 1.513.221 2.64.111 2.915.701.77 1.127 1.747 1.127 2.956 0 4.222-2.571 5.157-5.019 5.432.399.344.743 1.004.743 2.035 0 1.471-.014 2.654-.014 3.025 0 .289.206.632.756.522C19.851 20.979 23 16.854 23 12c0-6.077-4.922-11-11-11Z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,463 @@
|
||||
import { useState, useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
|
||||
import { SearchBar, SearchBarHandle } from './SearchBar';
|
||||
|
||||
export interface HeaderHandle {
|
||||
submitSearch: () => void;
|
||||
}
|
||||
|
||||
interface StatusCounts {
|
||||
ongoing: number;
|
||||
completed: number;
|
||||
errored: number;
|
||||
}
|
||||
|
||||
interface HeaderProps {
|
||||
calibreWebUrl?: string;
|
||||
debug?: boolean;
|
||||
logoUrl?: string;
|
||||
showSearch?: boolean;
|
||||
searchInput?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
onSearch?: () => void;
|
||||
onAdvancedToggle?: () => void;
|
||||
isLoading?: boolean;
|
||||
onDownloadsClick?: () => void;
|
||||
onSettingsClick?: () => void;
|
||||
statusCounts?: StatusCounts;
|
||||
onLogoClick?: () => void;
|
||||
authRequired?: boolean;
|
||||
isAuthenticated?: boolean;
|
||||
onLogout?: () => void;
|
||||
onShowToast?: (message: string, type: 'success' | 'error' | 'info', persistent?: boolean) => string;
|
||||
onRemoveToast?: (id: string) => void;
|
||||
}
|
||||
|
||||
export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
calibreWebUrl,
|
||||
debug,
|
||||
logoUrl,
|
||||
showSearch = false,
|
||||
searchInput = '',
|
||||
onSearchChange,
|
||||
onSearch,
|
||||
onAdvancedToggle,
|
||||
isLoading = false,
|
||||
onDownloadsClick,
|
||||
onSettingsClick,
|
||||
statusCounts = { ongoing: 0, completed: 0, errored: 0 },
|
||||
onLogoClick,
|
||||
authRequired = false,
|
||||
isAuthenticated = false,
|
||||
onLogout,
|
||||
onShowToast,
|
||||
onRemoveToast,
|
||||
}, ref) => {
|
||||
const searchBarRef = useRef<SearchBarHandle>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
submitSearch: () => {
|
||||
searchBarRef.current?.submit();
|
||||
},
|
||||
}));
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [shouldAnimateIn, setShouldAnimateIn] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('preferred-theme') || 'auto';
|
||||
applyTheme(saved);
|
||||
|
||||
// Remove preload class after initial theme is applied to enable transitions
|
||||
requestAnimationFrame(() => {
|
||||
document.documentElement.classList.remove('preload');
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
if (localStorage.getItem('preferred-theme') === 'auto') {
|
||||
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
|
||||
}
|
||||
};
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
// Helper function to close dropdown with animation
|
||||
const closeDropdown = () => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
setIsDropdownOpen(false);
|
||||
setIsClosing(false);
|
||||
}, 150); // Match the animation duration
|
||||
};
|
||||
|
||||
// Close dropdown when clicking outside or pressing ESC
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscapeKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
if (isDropdownOpen && !isClosing) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscapeKey);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscapeKey);
|
||||
};
|
||||
}, [isDropdownOpen, isClosing]);
|
||||
|
||||
const applyTheme = (pref: string) => {
|
||||
if (pref === 'auto') {
|
||||
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-theme', pref);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
closeDropdown();
|
||||
onLogout?.();
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (isDropdownOpen) {
|
||||
closeDropdown();
|
||||
} else {
|
||||
setShouldAnimateIn(true);
|
||||
setIsDropdownOpen(true);
|
||||
// Reset animation flag after animation completes
|
||||
setTimeout(() => setShouldAnimateIn(false), 200);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHeaderSearch = () => {
|
||||
onSearch?.();
|
||||
};
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
onSearchChange?.(value);
|
||||
};
|
||||
|
||||
// Icon buttons component - reused for both states
|
||||
const IconButtons = () => (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Calibre-Web Button */}
|
||||
{calibreWebUrl && (
|
||||
<a
|
||||
href={calibreWebUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-full hover-action transition-all duration-200 text-gray-900 dark:text-gray-100"
|
||||
aria-label="Open Calibre-Web"
|
||||
title="Go To Library"
|
||||
>
|
||||
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">Go To Library</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Downloads Button */}
|
||||
{onDownloadsClick && (
|
||||
<button
|
||||
onClick={onDownloadsClick}
|
||||
className="relative flex items-center gap-2 px-3 py-2 rounded-full hover-action transition-all duration-200 text-gray-900 dark:text-gray-100"
|
||||
aria-label="View downloads"
|
||||
title="Downloads"
|
||||
>
|
||||
<div className="relative">
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
|
||||
/>
|
||||
</svg>
|
||||
{/* Show badge with appropriate color based on status */}
|
||||
{(statusCounts.ongoing > 0 || statusCounts.completed > 0 || statusCounts.errored > 0) && (
|
||||
<span
|
||||
className={`absolute -top-1 -right-1 text-white text-[0.55rem] font-bold rounded-full w-3.5 h-3.5 flex items-center justify-center ${
|
||||
statusCounts.errored > 0
|
||||
? 'bg-red-500'
|
||||
: statusCounts.ongoing > 0
|
||||
? 'bg-blue-500'
|
||||
: 'bg-green-500'
|
||||
}`}
|
||||
title={`${statusCounts.ongoing} ongoing, ${statusCounts.completed} completed, ${statusCounts.errored} failed`}
|
||||
>
|
||||
{statusCounts.ongoing + statusCounts.completed + statusCounts.errored}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="hidden sm:inline text-sm font-medium">Downloads</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* User Menu Dropdown */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={toggleDropdown}
|
||||
className={`relative p-2 rounded-full hover-action transition-colors ${
|
||||
isDropdownOpen ? 'bg-gray-100 dark:bg-gray-700' : ''
|
||||
}`}
|
||||
aria-label="User menu"
|
||||
aria-expanded={isDropdownOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{(isDropdownOpen || isClosing) && (
|
||||
<div
|
||||
className={`absolute right-0 mt-2 w-48 rounded-lg shadow-lg border z-50 ${
|
||||
isClosing ? 'animate-fade-out-up' : shouldAnimateIn ? 'animate-fade-in-down' : ''
|
||||
}`}
|
||||
style={{
|
||||
background: 'var(--bg)',
|
||||
borderColor: 'var(--border-muted)',
|
||||
}}
|
||||
>
|
||||
<div className="py-1">
|
||||
<a
|
||||
href="https://github.com/calibrain/calibre-web-automated-book-downloader/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full text-left px-4 py-2 hover-surface transition-colors flex items-center gap-3 text-slate-700 dark:text-slate-200"
|
||||
title="Submit a bug report"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 3v1.5M3 21v-6m0 0 2.77-.693a9 9 0 0 1 6.208.682l.108.054a9 9 0 0 0 6.086.71l3.114-.732a48.524 48.524 0 0 1-.005-10.499l-3.11.732a9 9 0 0 1-6.085-.711l-.108-.054a9 9 0 0 0-6.208-.682L3 4.5M3 15V4.5"
|
||||
/>
|
||||
</svg>
|
||||
<span>Report a Bug</span>
|
||||
</a>
|
||||
|
||||
{/* Settings Button */}
|
||||
{onSettingsClick && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
closeDropdown();
|
||||
onSettingsClick();
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 hover-surface transition-colors flex items-center gap-3"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Debug Buttons */}
|
||||
{debug && (
|
||||
<>
|
||||
<button
|
||||
className="w-full text-left px-4 py-2 hover-surface transition-colors flex items-center gap-3 text-orange-600 dark:text-orange-400"
|
||||
onClick={async () => {
|
||||
closeDropdown();
|
||||
// Show persistent toast while gathering logs
|
||||
const loadingToastId = onShowToast?.('Gathering debug logs... This may take a minute.', 'info', true);
|
||||
try {
|
||||
const response = await fetch('/api/debug', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
// Remove the loading toast
|
||||
if (loadingToastId) onRemoveToast?.(loadingToastId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
onShowToast?.(`Debug download failed: ${errorData.error || response.statusText}`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the filename from Content-Disposition header or use default
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
let filename = 'debug.zip';
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
|
||||
if (filenameMatch && filenameMatch[1]) {
|
||||
filename = filenameMatch[1].replace(/['"]/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
// Create blob and trigger download
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
a.remove();
|
||||
|
||||
onShowToast?.('Debug logs downloaded successfully', 'success');
|
||||
} catch (error) {
|
||||
// Remove the loading toast on error too
|
||||
if (loadingToastId) onRemoveToast?.(loadingToastId);
|
||||
console.error('Debug download error:', error);
|
||||
onShowToast?.('Debug download failed. Check console for details.', 'error');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 12.75c1.148 0 2.278.08 3.383.237 1.037.146 1.866.966 1.866 2.013 0 3.728-2.35 6.75-5.25 6.75S6.75 18.728 6.75 15c0-1.046.83-1.867 1.866-2.013A24.204 24.204 0 0112 12.75zm0 0c2.883 0 5.647.508 8.207 1.44a23.91 23.91 0 01-1.152 6.06M12 12.75c-2.883 0-5.647.508-8.208 1.44.125 2.104.52 4.136 1.153 6.06M12 12.75a2.25 2.25 0 002.248-2.354M12 12.75a2.25 2.25 0 01-2.248-2.354M12 8.25c.995 0 1.971-.08 2.922-.236.403-.066.74-.358.795-.762a3.778 3.778 0 00-.399-2.25M12 8.25c-.995 0-1.97-.08-2.922-.236-.402-.066-.74-.358-.795-.762a3.734 3.734 0 01.4-2.253M12 8.25a2.25 2.25 0 00-2.248 2.146M12 8.25a2.25 2.25 0 012.248 2.146M8.683 5a6.032 6.032 0 01-1.155-1.002c.07-.63.27-1.222.574-1.747m.581 2.749A3.75 3.75 0 0115.318 5m0 0c.427-.283.815-.62 1.155-.999a4.471 4.471 0 00-.575-1.752M4.921 6a24.048 24.048 0 00-.392 3.314c1.668.546 3.416.914 5.223 1.082M19.08 6c.205 1.08.337 2.187.392 3.314a23.882 23.882 0 01-5.223 1.082" />
|
||||
</svg>
|
||||
<span>Debug</span>
|
||||
</button>
|
||||
<form action="/api/restart" method="get" className="w-full">
|
||||
<button
|
||||
className="w-full text-left px-4 py-2 hover-surface transition-colors flex items-center gap-3 text-orange-600 dark:text-orange-400"
|
||||
type="submit"
|
||||
>
|
||||
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
</svg>
|
||||
<span>Restart</span>
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Logout Button */}
|
||||
{authRequired && isAuthenticated && onLogout && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="w-full text-left px-4 py-2 hover-surface transition-colors flex items-center gap-3 text-red-600 dark:text-red-400"
|
||||
>
|
||||
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
||||
</svg>
|
||||
<span>Sign Out</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<header
|
||||
className="w-full sticky top-0 z-40 backdrop-blur-sm header-with-fade"
|
||||
style={{ background: 'var(--bg)', paddingTop: 'env(safe-area-inset-top)' }}
|
||||
>
|
||||
<div className={`max-w-full mx-auto px-4 sm:px-6 lg:px-8 transition-all duration-500 ${
|
||||
showSearch ? 'h-auto py-4' : 'h-24'
|
||||
}`}>
|
||||
{/* When search is active: stack on mobile, side-by-side on desktop */}
|
||||
{showSearch && (
|
||||
<div className="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-3">
|
||||
{/* Logo + Icon buttons - appear first on mobile (above search), last on desktop (right side) */}
|
||||
<div className="flex items-center justify-between w-full lg:w-auto lg:justify-end lg:order-2">
|
||||
{/* Logo - visible on mobile only, aligned left */}
|
||||
{logoUrl && (
|
||||
<img
|
||||
src={logoUrl}
|
||||
onClick={onLogoClick}
|
||||
alt="Logo"
|
||||
className="h-10 w-10 flex-shrink-0 cursor-pointer lg:hidden"
|
||||
/>
|
||||
)}
|
||||
|
||||
<IconButtons />
|
||||
</div>
|
||||
|
||||
{/* Search bar - appear second on mobile (below logo+icons), first on desktop (left side) */}
|
||||
<div className="flex items-center gap-4 lg:order-1 flex-1">
|
||||
{/* Logo - visible on desktop only, aligned with search */}
|
||||
{logoUrl && (
|
||||
<img
|
||||
src={logoUrl}
|
||||
onClick={onLogoClick}
|
||||
alt="Logo"
|
||||
className="hidden lg:block h-12 w-12 flex-shrink-0 cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
<SearchBar
|
||||
ref={searchBarRef}
|
||||
className="flex-1 lg:flex-initial"
|
||||
inputClassName="lg:w-[50vw]"
|
||||
value={searchInput}
|
||||
onChange={handleSearchChange}
|
||||
onSubmit={handleHeaderSearch}
|
||||
onAdvancedToggle={onAdvancedToggle}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* When search is NOT active: show icon buttons only on the right */}
|
||||
{!showSearch && (
|
||||
<div className="flex items-center justify-end h-full">
|
||||
<IconButtons />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Language } from '../types';
|
||||
import {
|
||||
formatDefaultLanguageLabel,
|
||||
LANGUAGE_OPTION_ALL,
|
||||
LANGUAGE_OPTION_DEFAULT,
|
||||
normalizeLanguageSelection,
|
||||
} from '../utils/languageFilters';
|
||||
import { DropdownList, DropdownListOption } from './DropdownList';
|
||||
|
||||
interface LanguageMultiSelectProps {
|
||||
options: Language[];
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
defaultLanguageCodes: string[];
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const LanguageMultiSelect = ({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
defaultLanguageCodes,
|
||||
label,
|
||||
placeholder,
|
||||
}: LanguageMultiSelectProps) => {
|
||||
const defaultLabel = formatDefaultLanguageLabel(defaultLanguageCodes, options);
|
||||
const defaultCodeSet = new Set(defaultLanguageCodes);
|
||||
const nonDefaultLanguages = options.filter(lang => !defaultCodeSet.has(lang.code));
|
||||
const selectableValues = [LANGUAGE_OPTION_DEFAULT, ...nonDefaultLanguages.map(lang => lang.code)];
|
||||
|
||||
const optionList: DropdownListOption[] = [
|
||||
{
|
||||
value: LANGUAGE_OPTION_ALL,
|
||||
label: 'All languages',
|
||||
},
|
||||
{
|
||||
value: LANGUAGE_OPTION_DEFAULT,
|
||||
label: defaultLabel,
|
||||
},
|
||||
...nonDefaultLanguages.map(lang => ({
|
||||
value: lang.code,
|
||||
label: lang.language,
|
||||
})),
|
||||
];
|
||||
|
||||
const includesAllSelection = value.includes(LANGUAGE_OPTION_ALL);
|
||||
const effectiveValue = includesAllSelection ? selectableValues : value;
|
||||
const selectedSet = new Set(effectiveValue);
|
||||
const isAllSelected = selectableValues.every(code => selectedSet.has(code));
|
||||
const displayedValue = isAllSelected ? [LANGUAGE_OPTION_ALL, ...effectiveValue] : effectiveValue;
|
||||
|
||||
const summaryFormatter = (_selected: DropdownListOption[], fallback: string) => {
|
||||
if (isAllSelected) {
|
||||
return 'All languages';
|
||||
}
|
||||
|
||||
const labels: string[] = [];
|
||||
|
||||
if (selectedSet.has(LANGUAGE_OPTION_DEFAULT)) {
|
||||
labels.push(defaultLabel);
|
||||
}
|
||||
|
||||
nonDefaultLanguages.forEach(lang => {
|
||||
if (selectedSet.has(lang.code)) {
|
||||
labels.push(lang.language);
|
||||
}
|
||||
});
|
||||
|
||||
if (labels.length === 0) {
|
||||
return placeholder || fallback;
|
||||
}
|
||||
|
||||
if (labels.length === 1) {
|
||||
return labels[0];
|
||||
}
|
||||
|
||||
const [first, second, ...rest] = labels;
|
||||
const suffix = rest.length > 0 ? ` +${rest.length}` : '';
|
||||
return `${first}, ${second ?? ''}${suffix}`.trim();
|
||||
};
|
||||
|
||||
const handleChange = (nextValue: string[] | string) => {
|
||||
const nextArray = Array.isArray(nextValue) ? nextValue : [nextValue];
|
||||
const includesAll = nextArray.includes(LANGUAGE_OPTION_ALL);
|
||||
const toggledAllOn = includesAll && !isAllSelected;
|
||||
const toggledAllOff =
|
||||
isAllSelected && !includesAll && nextArray.length === effectiveValue.length;
|
||||
|
||||
let resolved = nextArray.filter(code => code !== LANGUAGE_OPTION_ALL);
|
||||
|
||||
if (toggledAllOn) {
|
||||
resolved = [LANGUAGE_OPTION_ALL];
|
||||
} else if (toggledAllOff) {
|
||||
resolved = [];
|
||||
}
|
||||
|
||||
const normalized = normalizeLanguageSelection(resolved);
|
||||
onChange(normalized);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownList
|
||||
label={label}
|
||||
options={optionList}
|
||||
multiple
|
||||
showCheckboxes
|
||||
value={displayedValue}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
summaryFormatter={summaryFormatter}
|
||||
keepOpenOnSelect
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { FormEvent, KeyboardEvent, useEffect, useRef, useState } from 'react';
|
||||
import { LoginCredentials } from '../types';
|
||||
|
||||
interface LoginFormProps {
|
||||
onSubmit: (credentials: LoginCredentials) => void;
|
||||
error?: string | null;
|
||||
isLoading?: boolean;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
const EyeIcon = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const EyeSlashIcon = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const LoginForm = ({
|
||||
onSubmit,
|
||||
error = null,
|
||||
isLoading = false,
|
||||
autoFocus = true,
|
||||
}: LoginFormProps) => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [rememberMe, setRememberMe] = useState(true);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const usernameRef = useRef<HTMLInputElement>(null);
|
||||
const passwordRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus) {
|
||||
usernameRef.current?.focus();
|
||||
}
|
||||
}, [autoFocus]);
|
||||
|
||||
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const usernameValue = (formData.get('username') as string)?.trim() || '';
|
||||
const passwordValue = (formData.get('password') as string) || '';
|
||||
|
||||
if (usernameValue && passwordValue && !isLoading) {
|
||||
onSubmit({
|
||||
username: usernameValue,
|
||||
password: passwordValue,
|
||||
remember_me: rememberMe,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUsernameKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
passwordRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg text-sm bg-red-600 text-white">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<form
|
||||
method="post"
|
||||
action="/api/login"
|
||||
autoComplete="on"
|
||||
id="login-form"
|
||||
name="login"
|
||||
data-form-type="login"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="username" className="block text-sm font-medium mb-2">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
ref={usernameRef}
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
inputMode="text"
|
||||
enterKeyHint="next"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
onKeyDown={handleUsernameKeyDown}
|
||||
disabled={isLoading}
|
||||
className="w-full px-4 py-2.5 rounded-lg border focus:outline-none focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
style={{
|
||||
backgroundColor: 'var(--input-background)',
|
||||
borderColor: 'var(--border-color)',
|
||||
color: 'var(--text-color)',
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor="password" className="block text-sm font-medium mb-2">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={passwordRef}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
name="password"
|
||||
autoComplete="current-password"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
inputMode="text"
|
||||
enterKeyHint="go"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
disabled={isLoading}
|
||||
className="w-full px-4 py-2.5 rounded-lg border focus:outline-none focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed pr-10 transition-colors"
|
||||
style={{
|
||||
backgroundColor: 'var(--input-background)',
|
||||
borderColor: 'var(--border-color)',
|
||||
color: 'var(--text-color)',
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((current) => !current)}
|
||||
disabled={isLoading}
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1.5 rounded-full hover-action disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? <EyeSlashIcon /> : <EyeIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remember-me"
|
||||
name="remember_me"
|
||||
checked={rememberMe}
|
||||
onChange={(event) => setRememberMe(event.target.checked)}
|
||||
disabled={isLoading}
|
||||
className="w-4 h-4 rounded focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed accent-sky-900"
|
||||
style={{ borderColor: 'var(--border-color)' }}
|
||||
/>
|
||||
<label htmlFor="remember-me" className="ml-2 text-sm">
|
||||
Remember me for 7 days
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
name="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full py-2.5 px-4 rounded-lg font-medium text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed bg-sky-700 hover:bg-sky-800 disabled:hover:bg-sky-700"
|
||||
aria-label="Sign in"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
Signing in...
|
||||
</span>
|
||||
) : (
|
||||
'Sign In'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { ColumnSchema, ColumnColorHint, Release } from '../types';
|
||||
import { getFormatColor, getLanguageColor, getDownloadTypeColor, ColorStyle } from '../utils/colorMaps';
|
||||
|
||||
interface ReleaseCellProps {
|
||||
column: ColumnSchema;
|
||||
release: Release;
|
||||
compact?: boolean; // When true, renders badges as plain text (for mobile info lines)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a nested value from an object using dot-notation path.
|
||||
* e.g., getNestedValue(obj, "extra.language") returns obj.extra.language
|
||||
*/
|
||||
const getNestedValue = (obj: Record<string, unknown>, path: string): unknown => {
|
||||
return path.split('.').reduce((current, key) => {
|
||||
if (current && typeof current === 'object') {
|
||||
return (current as Record<string, unknown>)[key];
|
||||
}
|
||||
return undefined;
|
||||
}, obj as unknown);
|
||||
};
|
||||
|
||||
const DEFAULT_COLOR_STYLE: ColorStyle = { bg: 'bg-gray-500/20', text: 'text-gray-700 dark:text-gray-300' };
|
||||
|
||||
/**
|
||||
* Get the color style for a value based on the color hint.
|
||||
*/
|
||||
const getColorStyle = (value: string, colorHint?: ColumnColorHint | null): ColorStyle => {
|
||||
if (!colorHint) return DEFAULT_COLOR_STYLE;
|
||||
|
||||
if (colorHint.type === 'static') {
|
||||
// For static hints, assume it's a bg class and pair with default text
|
||||
return { bg: colorHint.value, text: 'text-gray-700 dark:text-gray-300' };
|
||||
}
|
||||
|
||||
if (colorHint.type === 'map') {
|
||||
switch (colorHint.value) {
|
||||
case 'format':
|
||||
return getFormatColor(value);
|
||||
case 'language':
|
||||
return getLanguageColor(value);
|
||||
case 'download_type':
|
||||
return getDownloadTypeColor(value);
|
||||
default:
|
||||
return DEFAULT_COLOR_STYLE;
|
||||
}
|
||||
}
|
||||
|
||||
return DEFAULT_COLOR_STYLE;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic cell renderer for release list columns.
|
||||
* Renders different column types (text, badge, size, number, seeders) based on schema.
|
||||
* When compact=true, badges render as plain text for use in mobile info lines.
|
||||
*/
|
||||
export const ReleaseCell = ({ column, release, compact = false }: ReleaseCellProps) => {
|
||||
const rawValue = getNestedValue(release as unknown as Record<string, unknown>, column.key);
|
||||
const value = rawValue !== undefined && rawValue !== null
|
||||
? String(rawValue)
|
||||
: column.fallback;
|
||||
|
||||
const displayValue = column.uppercase ? value.toUpperCase() : value;
|
||||
|
||||
// Alignment classes
|
||||
const alignClass = {
|
||||
left: 'text-left justify-start',
|
||||
center: 'text-center justify-center',
|
||||
right: 'text-right justify-end',
|
||||
}[column.align];
|
||||
|
||||
// Render based on type
|
||||
switch (column.render_type) {
|
||||
case 'badge': {
|
||||
// Compact mode: render as plain text (for mobile info lines)
|
||||
if (compact) {
|
||||
return <span>{displayValue}</span>;
|
||||
}
|
||||
const colorStyle = getColorStyle(value, column.color_hint);
|
||||
return (
|
||||
<div className={`flex items-center ${alignClass}`}>
|
||||
{value !== column.fallback ? (
|
||||
<span className={`${colorStyle.bg} ${colorStyle.text} text-[10px] sm:text-[11px] font-semibold px-1.5 sm:px-2 py-0.5 rounded-lg tracking-wide`}>
|
||||
{displayValue}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] sm:text-xs text-gray-500 dark:text-gray-400">{column.fallback}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'size':
|
||||
if (compact) {
|
||||
return <span>{displayValue}</span>;
|
||||
}
|
||||
return (
|
||||
<div className={`flex items-center ${alignClass} text-xs text-gray-600 dark:text-gray-300`}>
|
||||
{displayValue}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'peers': {
|
||||
// Peers display: "S/L" string with badge colored by seeder count
|
||||
// Color logic: 0 = red, 1-10 = yellow, 10+ = blue
|
||||
const seeders = release.seeders;
|
||||
const peersValue = value || column.fallback;
|
||||
const isFallback = seeders == null || peersValue === column.fallback;
|
||||
|
||||
// If no data, show plain text like badge type does
|
||||
if (isFallback) {
|
||||
if (compact) {
|
||||
return <span>{column.fallback}</span>;
|
||||
}
|
||||
return (
|
||||
<div className={`flex items-center ${alignClass}`}>
|
||||
<span className="text-[10px] sm:text-xs text-gray-500 dark:text-gray-400">{column.fallback}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Determine color based on seeder count
|
||||
let badgeColors: string;
|
||||
if (seeders >= 10) {
|
||||
badgeColors = 'bg-blue-500/20 text-blue-700 dark:text-blue-300';
|
||||
} else if (seeders >= 1) {
|
||||
badgeColors = 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-300';
|
||||
} else {
|
||||
badgeColors = 'bg-red-500/20 text-red-700 dark:text-red-300';
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return <span className={`font-medium ${badgeColors.split(' ').slice(1).join(' ')}`}>{peersValue}</span>;
|
||||
}
|
||||
return (
|
||||
<div className={`flex items-center ${alignClass}`}>
|
||||
<span className={`${badgeColors} text-[10px] sm:text-[11px] font-semibold px-1.5 sm:px-2 py-0.5 rounded-lg tracking-wide`}>
|
||||
{peersValue}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'number':
|
||||
if (compact) {
|
||||
return <span>{displayValue}</span>;
|
||||
}
|
||||
return (
|
||||
<div className={`flex items-center ${alignClass} text-xs text-gray-600 dark:text-gray-300`}>
|
||||
{displayValue}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'text':
|
||||
default:
|
||||
if (compact) {
|
||||
return <span>{displayValue}</span>;
|
||||
}
|
||||
return (
|
||||
<div className={`flex items-center ${alignClass} text-xs text-gray-600 dark:text-gray-300 truncate`}>
|
||||
{displayValue}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default ReleaseCell;
|
||||
@@ -0,0 +1,310 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Book, ButtonStateInfo, SortOption } from '../types';
|
||||
import { useSearchMode } from '../contexts/SearchModeContext';
|
||||
import { CardView } from './resultsViews/CardView';
|
||||
import { CompactView } from './resultsViews/CompactView';
|
||||
import { ListView } from './resultsViews/ListView';
|
||||
import { Dropdown } from './Dropdown';
|
||||
import { SORT_OPTIONS } from '../data/filterOptions';
|
||||
|
||||
// Grid layout classes by view mode
|
||||
const GRID_CLASSES = {
|
||||
mobile: 'grid-cols-1 items-start',
|
||||
card: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 items-stretch',
|
||||
compact: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 items-start',
|
||||
} as const;
|
||||
|
||||
interface ResultsSectionProps {
|
||||
books: Book[];
|
||||
visible: boolean;
|
||||
onDetails: (id: string) => Promise<void>;
|
||||
onDownload: (book: Book) => Promise<void>;
|
||||
onGetReleases: (book: Book) => Promise<void>;
|
||||
getButtonState: (bookId: string) => ButtonStateInfo;
|
||||
getUniversalButtonState: (bookId: string) => ButtonStateInfo;
|
||||
sortValue: string;
|
||||
onSortChange: (value: string) => void;
|
||||
metadataSortOptions?: SortOption[];
|
||||
}
|
||||
|
||||
export const ResultsSection = ({
|
||||
books,
|
||||
visible,
|
||||
onDetails,
|
||||
onDownload,
|
||||
onGetReleases,
|
||||
getButtonState,
|
||||
getUniversalButtonState,
|
||||
sortValue,
|
||||
onSortChange,
|
||||
metadataSortOptions,
|
||||
}: ResultsSectionProps) => {
|
||||
const { searchMode } = useSearchMode();
|
||||
const [viewMode, setViewMode] = useState<'card' | 'compact' | 'list'>(() => {
|
||||
const saved = localStorage.getItem('bookViewMode');
|
||||
return saved === 'card' || saved === 'compact' || saved === 'list' ? saved : 'compact';
|
||||
});
|
||||
|
||||
const [isDesktop, setIsDesktop] = useState(false);
|
||||
useEffect(() => {
|
||||
localStorage.setItem('bookViewMode', viewMode);
|
||||
}, [viewMode]);
|
||||
|
||||
// Track whether we're in desktop layout (sm breakpoint and above)
|
||||
// Debounced to avoid excessive state updates during resize
|
||||
useEffect(() => {
|
||||
let timeoutId: number;
|
||||
|
||||
const checkDesktop = () => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = window.setTimeout(() => {
|
||||
setIsDesktop(window.innerWidth >= 640); // sm breakpoint
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Initial check without debounce
|
||||
setIsDesktop(window.innerWidth >= 640);
|
||||
|
||||
window.addEventListener('resize', checkDesktop);
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
window.removeEventListener('resize', checkDesktop);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<section id="results-section" className="mb-4 sm:mb-8 w-full">
|
||||
<div className="flex items-center justify-between mb-2 sm:mb-3 relative z-10">
|
||||
<SortControl value={sortValue} onChange={onSortChange} metadataSortOptions={metadataSortOptions} />
|
||||
|
||||
{/* View toggle buttons - Desktop: show all 3, Mobile: show Compact and List only */}
|
||||
<div className="flex items-center gap-2">
|
||||
{isDesktop && (
|
||||
<button
|
||||
onClick={() => setViewMode('card')}
|
||||
className={`p-2 rounded-full transition-all duration-200 ${
|
||||
viewMode === 'card'
|
||||
? searchMode === 'universal'
|
||||
? 'text-white bg-emerald-600 hover:bg-emerald-700'
|
||||
: 'text-white bg-sky-700 hover:bg-sky-800'
|
||||
: 'hover-action text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
title="Card view"
|
||||
aria-label="Card view"
|
||||
aria-pressed={viewMode === 'card'}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setViewMode('compact')}
|
||||
className={`p-2 rounded-full transition-all duration-200 ${
|
||||
viewMode === 'compact'
|
||||
? searchMode === 'universal'
|
||||
? 'text-white bg-emerald-600 hover:bg-emerald-700'
|
||||
: 'text-white bg-sky-700 hover:bg-sky-800'
|
||||
: 'hover-action text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
title="Compact view"
|
||||
aria-label="Compact view"
|
||||
aria-pressed={viewMode === 'compact'}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<rect x="3.75" y="4.5" width="6" height="6" rx="1.125" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6h8.25M12 8.25h6" />
|
||||
<rect x="3.75" y="13.5" width="6" height="6" rx="1.125" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15h8.25M12 17.25h6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`p-2 rounded-full transition-all duration-200 ${
|
||||
viewMode === 'list'
|
||||
? searchMode === 'universal'
|
||||
? 'text-white bg-emerald-600 hover:bg-emerald-700'
|
||||
: 'text-white bg-sky-700 hover:bg-sky-800'
|
||||
: 'hover-action text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
title="List view"
|
||||
aria-label="List view"
|
||||
aria-pressed={viewMode === 'list'}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === 'list' ? (
|
||||
<ListView books={books} onDetails={onDetails} onDownload={onDownload} onGetReleases={onGetReleases} getButtonState={getButtonState} getUniversalButtonState={getUniversalButtonState} />
|
||||
) : (
|
||||
<div
|
||||
id="results-grid"
|
||||
className={`grid gap-8 ${!isDesktop ? GRID_CLASSES.mobile : GRID_CLASSES[viewMode]}`}
|
||||
>
|
||||
{books.map((book, index) => {
|
||||
const shouldUseCardLayout = isDesktop && viewMode === 'card';
|
||||
const animationDelay = index * 50;
|
||||
// Use appropriate button state function based on search mode
|
||||
const buttonState = searchMode === 'universal'
|
||||
? getUniversalButtonState(book.id)
|
||||
: getButtonState(book.id);
|
||||
|
||||
return shouldUseCardLayout ? (
|
||||
<CardView
|
||||
key={book.id}
|
||||
book={book}
|
||||
onDetails={onDetails}
|
||||
onDownload={onDownload}
|
||||
onGetReleases={onGetReleases}
|
||||
buttonState={buttonState}
|
||||
animationDelay={animationDelay}
|
||||
/>
|
||||
) : (
|
||||
<CompactView
|
||||
key={book.id}
|
||||
book={book}
|
||||
onDetails={onDetails}
|
||||
onDownload={onDownload}
|
||||
onGetReleases={onGetReleases}
|
||||
buttonState={buttonState}
|
||||
showDetailsButton={!isDesktop}
|
||||
animationDelay={animationDelay}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{books.length === 0 && (
|
||||
<div className="mt-4 text-sm opacity-80">No results found.</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
interface SortControlProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
metadataSortOptions?: SortOption[];
|
||||
}
|
||||
|
||||
// Default universal mode sort options (fallback if not provided by API)
|
||||
const DEFAULT_UNIVERSAL_SORT_OPTIONS: SortOption[] = [
|
||||
{ value: 'relevance', label: 'Most relevant' },
|
||||
];
|
||||
|
||||
const SortControl = ({ value, onChange, metadataSortOptions }: SortControlProps) => {
|
||||
const { searchMode } = useSearchMode();
|
||||
// Use different sort options based on search mode
|
||||
// For universal mode, use dynamic options from API (with fallback)
|
||||
const sortOptions = searchMode === 'universal'
|
||||
? (metadataSortOptions && metadataSortOptions.length > 0 ? metadataSortOptions : DEFAULT_UNIVERSAL_SORT_OPTIONS)
|
||||
: SORT_OPTIONS;
|
||||
const selected = sortOptions.find(option => option.value === value) ?? sortOptions[0];
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
align="left"
|
||||
widthClassName="w-60 sm:w-72"
|
||||
renderTrigger={({ isOpen, toggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className={`relative flex items-center gap-2 px-3 py-2 rounded-full transition-all duration-200 text-gray-900 dark:text-gray-100 hover-action ${
|
||||
isOpen ? 'bg-gray-100 dark:bg-gray-700' : ''
|
||||
} animate-fade-in-up`}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
aria-label="Change sort order"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-5 h-5 sm:w-6 sm:h-6"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 7.5 7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm font-medium whitespace-nowrap">{selected.label}</span>
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div role="listbox" aria-label="Sort results">
|
||||
{sortOptions.map(option => {
|
||||
const isSelected = option.value === selected.value;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={option.value || 'default'}
|
||||
className={`w-full px-3 py-2 text-left text-base flex items-center justify-between gap-2 hover-surface ${
|
||||
isSelected
|
||||
? searchMode === 'universal'
|
||||
? 'text-emerald-600 dark:text-emerald-400 font-medium'
|
||||
: 'text-sky-600 dark:text-sky-300 font-medium'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
close();
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
{isSelected && (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-4 h-4"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
import { KeyboardEvent, InputHTMLAttributes, useRef, forwardRef, useImperativeHandle } from 'react';
|
||||
import { useSearchMode } from '../contexts/SearchModeContext';
|
||||
|
||||
interface SearchBarProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
isLoading?: boolean;
|
||||
onAdvancedToggle?: () => void;
|
||||
placeholder?: string;
|
||||
inputAriaLabel?: string;
|
||||
className?: string;
|
||||
inputClassName?: string;
|
||||
controlsClassName?: string;
|
||||
clearButtonLabel?: string;
|
||||
clearButtonTitle?: string;
|
||||
advancedButtonLabel?: string;
|
||||
advancedButtonTitle?: string;
|
||||
searchButtonLabel?: string;
|
||||
searchButtonTitle?: string;
|
||||
autoComplete?: string;
|
||||
enterKeyHint?: InputHTMLAttributes<HTMLInputElement>['enterKeyHint'];
|
||||
}
|
||||
|
||||
export interface SearchBarHandle {
|
||||
submit: () => void;
|
||||
}
|
||||
|
||||
export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
onAdvancedToggle,
|
||||
placeholder = 'Search by ISBN, title, author...',
|
||||
inputAriaLabel = 'Search books',
|
||||
className = '',
|
||||
inputClassName = '',
|
||||
controlsClassName = '',
|
||||
clearButtonLabel = 'Clear search input',
|
||||
clearButtonTitle = 'Clear search',
|
||||
advancedButtonLabel = 'Advanced Search',
|
||||
advancedButtonTitle = 'Advanced Search',
|
||||
searchButtonLabel = 'Search books',
|
||||
searchButtonTitle = 'Search',
|
||||
autoComplete = 'off',
|
||||
enterKeyHint = 'search',
|
||||
}, ref) => {
|
||||
const { searchMode } = useSearchMode();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const hasSearchQuery = value.trim().length > 0;
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
submit: () => {
|
||||
buttonRef.current?.click();
|
||||
},
|
||||
}));
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
onSubmit();
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
onChange('');
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const wrapperClasses = ['relative', className].filter(Boolean).join(' ').trim();
|
||||
const inputClasses = [
|
||||
'w-full pl-4 pr-40 py-3 rounded-full border outline-none search-input',
|
||||
inputClassName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.trim();
|
||||
const controlsClasses = [
|
||||
'absolute inset-y-0 right-0 flex items-center gap-1 pr-2',
|
||||
controlsClassName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.trim();
|
||||
|
||||
return (
|
||||
<div className={wrapperClasses}>
|
||||
<input
|
||||
type="search"
|
||||
placeholder={placeholder}
|
||||
aria-label={inputAriaLabel}
|
||||
autoComplete={autoComplete}
|
||||
enterKeyHint={enterKeyHint}
|
||||
className={inputClasses}
|
||||
style={{
|
||||
background: 'var(--bg-soft)',
|
||||
color: 'var(--text)',
|
||||
borderColor: 'var(--border-muted)',
|
||||
}}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={inputRef}
|
||||
/>
|
||||
<div className={controlsClasses}>
|
||||
{hasSearchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearSearch}
|
||||
className="p-2 rounded-full hover-action flex items-center justify-center transition-colors"
|
||||
aria-label={clearButtonLabel}
|
||||
title={clearButtonTitle}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
className="w-5 h-5"
|
||||
style={{ color: 'var(--text)' }}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{onAdvancedToggle && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdvancedToggle}
|
||||
className="p-2 rounded-full hover-action flex items-center justify-center transition-colors"
|
||||
aria-label={advancedButtonLabel}
|
||||
title={advancedButtonTitle}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
style={{ color: 'var(--text)' }}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
className={`p-2 rounded-full text-white disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center transition-colors search-bar-button ${
|
||||
searchMode === 'universal'
|
||||
? 'bg-emerald-600 hover:bg-emerald-700'
|
||||
: 'bg-sky-700 hover:bg-sky-800'
|
||||
}`}
|
||||
aria-label={searchButtonLabel}
|
||||
title={searchButtonTitle}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{!isLoading && (
|
||||
<svg
|
||||
className="w-5 h-5 search-bar-icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{isLoading && (
|
||||
<div className="spinner w-3 h-3 border-2 border-white border-t-transparent search-bar-spinner" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { AdvancedFilterState, Language, MetadataSearchField } from '../types';
|
||||
import { buildSearchQuery } from '../utils/buildSearchQuery';
|
||||
import { useSearchMode } from '../contexts/SearchModeContext';
|
||||
import { AdvancedFilters } from './AdvancedFilters';
|
||||
import { SearchBar } from './SearchBar';
|
||||
|
||||
interface SearchSectionProps {
|
||||
onSearch: (query: string) => void;
|
||||
isLoading: boolean;
|
||||
isInitialState: boolean;
|
||||
bookLanguages: Language[];
|
||||
defaultLanguage: string[];
|
||||
supportedFormats: string[];
|
||||
logoUrl: string;
|
||||
searchInput: string;
|
||||
onSearchInputChange: (value: string) => void;
|
||||
showAdvanced: boolean;
|
||||
onAdvancedToggle: () => void;
|
||||
advancedFilters: AdvancedFilterState;
|
||||
onAdvancedFiltersChange: (updates: Partial<AdvancedFilterState>) => void;
|
||||
// Universal mode props
|
||||
metadataSearchFields?: MetadataSearchField[];
|
||||
searchFieldValues?: Record<string, string | number | boolean>;
|
||||
onSearchFieldChange?: (key: string, value: string | number | boolean) => void;
|
||||
}
|
||||
|
||||
export const SearchSection = ({
|
||||
onSearch,
|
||||
isLoading,
|
||||
isInitialState,
|
||||
bookLanguages,
|
||||
defaultLanguage,
|
||||
supportedFormats,
|
||||
logoUrl,
|
||||
searchInput,
|
||||
onSearchInputChange,
|
||||
showAdvanced,
|
||||
onAdvancedToggle,
|
||||
advancedFilters,
|
||||
onAdvancedFiltersChange,
|
||||
metadataSearchFields,
|
||||
searchFieldValues,
|
||||
onSearchFieldChange,
|
||||
}: SearchSectionProps) => {
|
||||
const { searchMode } = useSearchMode();
|
||||
|
||||
const handleSearch = () => {
|
||||
const query = buildSearchQuery({
|
||||
searchInput,
|
||||
showAdvanced,
|
||||
advancedFilters,
|
||||
bookLanguages,
|
||||
defaultLanguage,
|
||||
searchMode,
|
||||
});
|
||||
onSearch(query);
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
id="search-section"
|
||||
className={`transition-all duration-500 ease-in-out ${
|
||||
isInitialState
|
||||
? 'search-initial-state mb-6'
|
||||
: 'mb-3 sm:mb-4'
|
||||
} ${showAdvanced ? 'search-advanced-visible' : ''}`}
|
||||
>
|
||||
<div className={`flex items-center justify-center gap-3 transition-all duration-300 ${
|
||||
isInitialState ? 'opacity-100 mb-6 sm:mb-8' : 'opacity-0 h-0 mb-0 overflow-hidden'
|
||||
}`}>
|
||||
<img src={logoUrl} alt="Logo" className="h-8 w-8" />
|
||||
<h1 className="text-2xl font-semibold">Book Search & Download</h1>
|
||||
</div>
|
||||
<div className={`flex flex-col gap-3 search-wrapper transition-all duration-500 ${
|
||||
isInitialState ? '' : 'hidden'
|
||||
}`}>
|
||||
<SearchBar
|
||||
value={searchInput}
|
||||
onChange={onSearchInputChange}
|
||||
onSubmit={handleSearch}
|
||||
isLoading={isLoading}
|
||||
onAdvancedToggle={onAdvancedToggle}
|
||||
/>
|
||||
<AdvancedFilters
|
||||
visible={showAdvanced}
|
||||
bookLanguages={bookLanguages}
|
||||
defaultLanguage={defaultLanguage}
|
||||
supportedFormats={supportedFormats}
|
||||
filters={advancedFilters}
|
||||
onFiltersChange={onAdvancedFiltersChange}
|
||||
formClassName="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2"
|
||||
renderWrapper={form => form}
|
||||
metadataSearchFields={metadataSearchFields}
|
||||
searchFieldValues={searchFieldValues}
|
||||
onSearchFieldChange={onSearchFieldChange}
|
||||
onSubmit={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Toast } from '../types';
|
||||
|
||||
interface ToastContainerProps {
|
||||
toasts: Toast[];
|
||||
}
|
||||
|
||||
export const ToastContainer = ({ toasts }: ToastContainerProps) => {
|
||||
const [visibleToasts, setVisibleToasts] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
toasts.forEach(toast => {
|
||||
if (!visibleToasts.has(toast.id)) {
|
||||
setTimeout(() => {
|
||||
setVisibleToasts(prev => new Set([...prev, toast.id]));
|
||||
}, 10);
|
||||
}
|
||||
});
|
||||
}, [toasts]);
|
||||
|
||||
const toastTypeClasses: Record<Toast['type'], string> = {
|
||||
success: 'bg-green-600 text-white',
|
||||
error: 'bg-red-600 text-white',
|
||||
info: 'bg-blue-600 text-white',
|
||||
};
|
||||
|
||||
return (
|
||||
<div id="toast-container" className="fixed bottom-4 right-4 z-[100] space-y-2">
|
||||
{toasts.map(toast => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`toast-notification px-4 py-3 rounded-md shadow-lg text-sm font-medium transition-all duration-300 ${
|
||||
toastTypeClasses[toast.type]
|
||||
} ${visibleToasts.has(toast.id) ? 'toast-visible' : ''}`}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||