Compare commits

...
7 Commits
Author SHA1 Message Date
BMillerCodesandBMillerCodes a9854b1a5c UI Overhaul - Tailwind CSS (#259)
New Homepage
<img width="2559" height="1388" alt="image"
src="https://github.com/user-attachments/assets/787668c6-61a9-4a2d-9878-9daaee8ae114"
/>
New Card Layout:
<img width="2547" height="1250" alt="image"
src="https://github.com/user-attachments/assets/44d015f9-c29d-4cac-a8b4-1c4ff7d40009"
/>
Light-Mode:
<img width="2547" height="1253" alt="image"
src="https://github.com/user-attachments/assets/7f81f6d7-ec69-4680-8ddc-4737601d00bb"
/>
New Download Queue and Status:
<img width="1260" height="146" alt="image"
src="https://github.com/user-attachments/assets/4acbb5a3-e985-4b23-8527-392e21151fa4"
/>

Haven't contributed to open-source before, but figured I could try and
help out on the UI side of the house. Appreciate everything you've done
this far!

Wanted to get something that was a tad bit more mobile-friendly.

Open to any feedback/comments/questions/concerns. :)

---------

Co-authored-by: BMillerCodes <BMillerCodes@users.noreply.github.com>
2025-08-29 22:48:25 -04:00
CaliBrain e4d3a372c8 Add retry logic for failed file copy (#261) 2025-08-29 22:32:04 -04:00
CaliBrain ff44881415 Pyautogui bug fix (#260) 2025-08-29 21:14:06 -04:00
CaliBrain 9ffedc1fc0 Several Bug fixes (#256)
Emoji check fix Fix multi language books
Fix DNS in Chromium Headless
Fix DNS IPv6 address by un-abreviating them
Fix typo in Quad9 DNS
2025-08-29 12:47:05 -04:00
CaliBrain 00370818f0 Fix eager cloudflare check (#247) 2025-08-28 17:42:50 -04:00
CaliBrain 7d9a82bfea Add default flaresolverr values (#253) 2025-08-28 17:41:15 -04:00
Federico Della Rovere 207cff96d3 External CloudFlare resolver (#245)
Adding support for an external CloudFlare bypasser service and
introducing a new Docker image build with a dedicated target.

Key Changes
- Added `cloudflare_bypasser_external.py` for external bypasser
integration.
- Updated Docker Compose files to support the new service.
- Introduced a new Docker target for building a separate image for the
external bypasser.
- Refactored relevant modules to utilize the external bypasser when
configured.
- Documentation and configuration updates to reflect new options and
Docker targets.

Impact
- Users can now choose between internal and external CloudFlare
bypassing.
- New Docker image and target streamline deployment of the external
bypasser.
- Improved modularity and maintainability.
- No breaking changes for existing workflows.

Testing
- Manual and E2E tests performed for both bypasser modes.
- Docker Compose setups and new image build verified for development and
production.

Notes
Please review the new configuration options and Docker targets. Update
your environment and deployment scripts as needed. Feedback and
suggestions are welcome!
2025-08-28 17:37:59 -04:00
20 changed files with 824 additions and 1364 deletions
@@ -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
+2 -1
View File
@@ -15,7 +15,8 @@
"LOG_ROOT": "/tmp/cwa-book-downloader",
"ENABLE_LOGGING": "true",
"DOCKERMODE": "false",
"DEBUG": "true"
"DEBUG": "true",
"CUSTOM_DNS": "google",
},
},
{
+35 -18
View File
@@ -38,18 +38,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,17 +59,12 @@ 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 . .
@@ -101,10 +88,34 @@ 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
# 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
@@ -124,3 +135,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"]
+7 -2
View File
@@ -13,7 +13,7 @@ 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
from env import FLASK_HOST, FLASK_PORT, APP_ENV, CWA_DB_PATH, DEBUG, USING_EXTERNAL_BYPASSER
import backend
from models import SearchFilters
@@ -105,6 +105,8 @@ def index() -> str:
"""
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:_>')
@@ -117,7 +119,10 @@ from typing import Union, Tuple
if DEBUG:
import subprocess
import time
from cloudflare_bypasser import _reset_driver as STOP_GUI
if USING_EXTERNAL_BYPASSER:
STOP_GUI = lambda: None # No-op for external bypasser
else:
from cloudflare_bypasser import _reset_driver as STOP_GUI
@app.route('/debug', methods=['GET'])
@login_required
def debug() -> Union[Response, Tuple[Response, int]]:
+6 -2
View File
@@ -180,8 +180,12 @@ def _download_book_with_cancellation(book_id: str, cancel_flag: Event) -> Option
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)
try:
logger.debug(f"Error moving book: {e}, will try copying instead")
shutil.move(book_path, intermediate_path)
except Exception as e:
logger.debug(f"Error copying book: {e}, will try copying without permissions instead")
shutil.copyfile(book_path, intermediate_path)
os.remove(book_path)
# Final cancellation check before completing
+4 -3
View File
@@ -169,8 +169,8 @@ def _parse_book_info_page(soup: BeautifulSoup, book_id: str) -> BookInfo:
data = soup.find_all("div", {"class": "main-inner"})[0].find_next("div")
divs = list(data.children)
format = divs[13].text.split(" · ")[1].strip().lower()
size = divs[13].text.split(" · ")[2].strip().lower()
format = divs[13].text.split(" · ")[-6].strip().lower()
size = divs[13].text.split(" · ")[-5].strip().lower()
every_url = soup.find_all("a")
slow_urls_no_waitlist = set()
@@ -199,7 +199,8 @@ def _parse_book_info_page(soup: BeautifulSoup, book_id: str) -> BookInfo:
):
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.la', url["href"])
libgen_url = re.sub(r'libgen\.(lc|is|bz|st)', 'libgen.gl', url["href"])
external_urls_libgen.add(libgen_url)
elif url.text.strip().lower().startswith("z-lib"):
if ".onion/" not in url["href"]:
+36 -22
View File
@@ -8,6 +8,8 @@ from env import LOG_DIR, DEBUG
import signal
from datetime import datetime
import subprocess
import requests
from typing import Optional
# --- SeleniumBase Import ---
from seleniumbase import Driver
@@ -43,7 +45,7 @@ def _reset_pyautogui_display_state():
except Exception as e:
logger.warning(f"Error resetting pyautogui display state: {e}")
def _is_bypassed(sb) -> bool:
def _is_bypassed(sb, escape_emojis : bool = True) -> bool:
"""Enhanced bypass detection with more comprehensive checks"""
try:
# Get page information with error handling
@@ -62,29 +64,25 @@ def _is_bypassed(sb) -> bool:
except:
current_url = ""
# Check if page is too long, if so we are probably bypassed
if len(body.strip()) > 100000:
logger.debug(f"Page content too long, we are probably bypassed len: {len(body.strip())}")
return True
# Detect if there is an emoji in the page, any utf8 emoji, if so we are probably bypassed
if escape_emojis:
import emoji
emoji_list = emoji.emoji_list(body)
if len(emoji_list) >= 3:
logger.debug(f"Detected emoji in page, we are probably bypassed len: {len(emoji_list)}")
return True
# Enhanced verification texts for newer Cloudflare versions
verification_texts = [
"just a moment",
"verify you are human",
"verifying you are human",
"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",
"moment please",
"hold on",
"loading",
"one more step",
"challenge"
"cloudflare.com/products/turnstile/?utm_source=turnstile"
]
# Check for Cloudflare indicators
@@ -276,9 +274,8 @@ def _get_chromium_args():
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)}')
arguments.append(f'--dns-server="{",".join(CUSTOM_DNS)}"')
arguments.append(f'--disable-features=DnsOverHttps')
except Exception as e:
logger.error_trace(f"Error configuring DNS settings: {e}")
return arguments
@@ -475,3 +472,20 @@ def wait_for_result(func, timeout : int = 10, condition : any = True):
time.sleep(0.5)
return None
_init_cleanup_thread()
def get_bypassed_page(url: str) -> Optional[str]:
"""Fetch HTML content from a URL using the internal Cloudflare Bypasser.
Args:
url: Target URL
Returns:
str: HTML content if successful, None otherwise
"""
response_html = get(url)
logger.debug(f"Cloudflare Bypasser response length: {len(response_html)}")
if response_html.strip() != "":
return response_html
else:
raise requests.exceptions.RequestException("Failed to bypass Cloudflare")
+34
View File
@@ -0,0 +1,34 @@
from logger import setup_logger
from typing import Optional
import requests
try:
from env import EXT_BYPASSER_PATH, EXT_BYPASSER_TIMEOUT, EXT_BYPASSER_URL
except ImportError:
raise RuntimeError("Failed to import environment variables. Are you using an `extbp` image?")
logger = setup_logger(__name__)
def get_bypassed_page(url: str) -> Optional[str]:
"""Fetch HTML content from a URL using an External Cloudflare Resolver.
Args:
url: Target URL
Returns:
str: HTML content if successful, None otherwise
"""
if not EXT_BYPASSER_URL or not EXT_BYPASSER_PATH:
logger.error("Wrong External Bypass configuration. Please check your environment configuration.")
return None
ext_url = f"{EXT_BYPASSER_URL}{EXT_BYPASSER_PATH}"
headers = {"Content-Type": "application/json"}
data = {
"cmd": "request.get",
"url": url,
"maxTimeout": EXT_BYPASSER_TIMEOUT
}
response = requests.post(ext_url, headers=headers, json=data)
response.raise_for_status()
logger.debug(f"External Bypass response for '{url}': {response.json()['status']} - {response.json()['message']}")
return response.json()['solution']['response']
+10 -8
View File
@@ -36,16 +36,16 @@ logger.info(f"CROSS_FILE_SYSTEM: {CROSS_FILE_SYSTEM}")
_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"]
CUSTOM_DNS = ["8.8.8.8", "8.8.4.4", "2001:4860:4860:0000:0000:0000:0000:8888", "2001:4860:4860:0000:0000:0000:0000:8844"]
_doh_server = "https://dns.google/dns-query"
elif _custom_dns == "quad9":
CUSTOM_DNS = ["9.9.9.9", "149.112.112.112", "2620:fe::fe", "26620:fe::9"]
CUSTOM_DNS = ["9.9.9.9", "149.112.112.112", "2620:00fe:0000:0000:0000:0000:0000:00fe", "2620:00fe:0000:0000:0000:0000:0000:0009"]
_doh_server = "https://dns.quad9.net/dns-query"
elif _custom_dns == "cloudflare":
CUSTOM_DNS = ["1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001"]
CUSTOM_DNS = ["1.1.1.1", "1.0.0.1", "2606:4700:4700:0000:0000:0000:0000:1111", "2606:4700:4700:0000:0000:0000:0000:1001"]
_doh_server = "https://cloudflare-dns.com/dns-query"
elif _custom_dns == "opendns":
CUSTOM_DNS = ["208.67.222.222", "208.67.220.220", "2620:119:35::35", "2620:119:53::53"]
CUSTOM_DNS = ["208.67.222.222", "208.67.220.220", "2620:0119:0035:0000:0000:0000:0000:0035", "2620:0119:0053:0000:0000:0000:0000:0053"]
_doh_server = "https://doh.opendns.com/dns-query"
else:
_custom_dns_ip = _custom_dns.split(",")
@@ -93,7 +93,9 @@ if CUSTOM_SCRIPT:
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)
if not env.USING_EXTERNAL_BYPASSER:
# Virtual display settings for debugging internal cloudflare bypasser
VIRTUAL_SCREEN_SIZE = (1024, 768)
RECORDING_DIR = env.LOG_DIR / "recording"
if env.DEBUG:
RECORDING_DIR.mkdir(parents=True, exist_ok=True)
+35
View File
@@ -0,0 +1,35 @@
services:
calibre-web-automated-book-downloader-extbp-dev:
container_name: cwa-bd-extbp-dev
extends:
file: ./docker-compose.extbp.yml
service: calibre-web-automated-book-downloader-extbp
build:
context: .
dockerfile: Dockerfile
target: cwa-bd-extbp
environment:
DEBUG: true
APP_ENV: dev
USE_DOH: true
CUSTOM_DNS: cloudflare
USE_CF_BYPASS: true # Enable Cloudflare bypass (default: true)
# External Cloudflare Bypass environment variables
EXT_BYPASSER_URL: "http://flaresolverr:8191" # URL of the external Cloudflare resolver service (used FlareSolverr)
EXT_BYPASSER_PATH: "/v1" # Path for external Cloudflare resolver API (default: /v1)
EXT_BYPASSER_TIMEOUT: 60000 # Timeout for external Cloudflare resolver requests (default: 60000)
volumes:
#- /tmp/cwa-book-downloader:/tmp/cwa-book-downloader
#- /tmp/cwa-book-downloader-log:/var/log/cwa-book-downloader
- ./deploy/ingest:/cwa-book-ingest
- ./deploy/log:/var/log/cwa-book-downloader
- ./deploy/tmp:/tmp/cwa-book-downloader
flaresolverr: # External Cloudflare resolver service
image: ghcr.io/flaresolverr/flaresolverr:v3.3.22
container_name: flaresolverr
environment:
LOG_LEVEL: info
LOG_HTML: false
CAPTCHA_SOLVER: none
TZ: Europe/Rome
+25
View File
@@ -0,0 +1,25 @@
services:
calibre-web-automated-book-downloader-extbp:
image: ghcr.io/calibrain/calibre-web-automated-book-downloader-extbp:latest
environment:
FLASK_PORT: 8084
LOG_LEVEL: info
BOOK_LANGUAGE: en
USE_BOOK_TITLE: true
TZ: America/New_York
APP_ENV: prod
UID: 1000
GID: 100
EXT_BYPASSER_URL: http://flaresolverr:8191
ports:
- 8084:8084
restart: unless-stopped
volumes:
# This is where the books will be downloaded to, usually it would be
# the same as whatever you gave in "calibre-web-automated"
- /tmp/data/calibre-web/ingest:/cwa-book-ingest
# This is the location of CWA's app.db, which contains authentication
# details
#- /cwa/config/path/app.db:/auth/app.db:ro
flaresolverr:
image: ghcr.io/flaresolverr/flaresolverr:latest
+6 -8
View File
@@ -12,9 +12,12 @@ from typing import Callable
from threading import Event
from logger import setup_logger
from config import PROXIES
from env import MAX_RETRY, DEFAULT_SLEEP, USE_CF_BYPASS
from env import MAX_RETRY, DEFAULT_SLEEP, USE_CF_BYPASS, USING_EXTERNAL_BYPASSER
if USE_CF_BYPASS:
import cloudflare_bypasser
if USING_EXTERNAL_BYPASSER:
from cloudflare_bypasser_external import get_bypassed_page
else:
from cloudflare_bypasser import get_bypassed_page
logger = setup_logger(__name__)
@@ -35,12 +38,7 @@ def html_get_page(url: str, retry: int = MAX_RETRY, use_bypasser: bool = False)
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")
return get_bypassed_page(url)
else:
logger.info(f"GET: {url}")
response = requests.get(url, proxies=PROXIES)
+2 -2
View File
@@ -112,8 +112,8 @@ else
command="python3 app.py"
fi
# 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"
+6
View File
@@ -45,6 +45,12 @@ APP_ENV = os.getenv("APP_ENV", "prod").lower()
# 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:
+45 -1
View File
@@ -175,7 +175,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,6 +198,48 @@ 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.
- This approach can improve reliability and performance, especially if your external resolver is optimized or shared across multiple applications.
#### 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.
#### Benefits:
- Centralizes Cloudflare bypass logic for easier maintenance.
- Can leverage more powerful or distributed resolver infrastructure.
- Reduces load on the main application container.
## 🏗️ Architecture
The application consists of a single service:
+1 -4
View File
@@ -2,10 +2,7 @@ flask
requests[socks]
beautifulsoup4
tqdm
pyvirtualdisplay
dnspython
pyautogui
seleniumbase>=4.41.1
gunicorn
python-xlib
psutil
emoji
+4
View File
@@ -0,0 +1,4 @@
pyvirtualdisplay
pyautogui
seleniumbase>=4.41.1
python-xlib
+7
View File
@@ -15,6 +15,13 @@
--table-border-color: #e5e5e5;
--input-background: #fff;
--heading-color: #333;
/* Modern UI alias tokens */
--bg: var(--background-color);
--text: var(--text-color);
--border-muted: var(--border-color);
--bg-soft: var(--card-background);
--footer-bg: var(--header-bg);
}
[data-theme="dark"] {
+377 -1073
View File
File diff suppressed because it is too large Load Diff
+179 -220
View File
@@ -3,251 +3,210 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Calibre Web Book Downloader Application">
<meta name="description" content="Calibre Web Book Downloader - Modern UI">
<meta name="theme-color" content="#333333">
<title>Calibre Web Book Downloader</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<!-- Add favicon -->
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
<!-- UIkit CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.21.16/dist/css/uikit.min.css" />
<title>Book Downloader • Modern</title>
<!-- UIkit JS -->
<script src="https://cdn.jsdelivr.net/npm/uikit@3.21.16/dist/js/uikit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/uikit@3.21.16/dist/js/uikit-icons.min.js"></script>
<!-- Base styles and theme variables -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='media/favicon.ico') }}">
<!-- Tailwind (no-build) for rapid iteration) -->
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
<header>
<div class="uk-flex uk-flex-between uk-flex-middle">
<h1 class="uk-heading-medium" style="color: white;">Book Search & Download</h1>
<div class="uk-margin-right">
<body class="min-h-screen" style="background: var(--bg); color: var(--text);">
<!-- Header -->
<header class="w-full border-b border-[color:var(--border-muted)]" style="background: var(--header-bg);">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<div class="flex items-center gap-3">
<img src="{{ url_for('static', filename='media/logo.png') }}" alt="Logo" class="h-8 w-8">
<h1 class="text-lg font-semibold">Book Search & Download</h1>
</div>
<div class="flex items-center gap-2">
{% if debug %}
<form action="/api/restart" method="get" style="display: inline;" id="restart-form">
<button class="uk-button uk-button-danger uk-margin-small-right" id="restart-button" type="submit">
RESTART <span uk-spinner="ratio: 0.8" class="uk-hidden" id="restart-spinner"></span>
<form action="/request/api/restart" method="get" id="restart-form">
<button class="px-3 py-1 rounded bg-red-600 text-white text-sm" id="restart-button" type="submit">
RESTART
</button>
</form>
<form action="/debug" method="get" style="display: inline;" id="debug-form">
<button class="uk-button uk-button-danger uk-margin-small-right" id="debug-button" type="submit">
DEBUG <span uk-spinner="ratio: 0.8" class="uk-hidden" id="debug-spinner"></span>
<form action="/request/debug" method="get" id="debug-form">
<button class="px-3 py-1 rounded bg-red-600/80 text-white text-sm" id="debug-button" type="submit">
DEBUG
</button>
</form>
{% endif %}
<button class="uk-button uk-button-default" type="button" id="theme-toggle">
<span uk-icon="icon: paint-bucket"></span>
<span id="theme-text">Theme</span>
</button>
<div uk-dropdown="mode: click">
<ul class="uk-nav uk-dropdown-nav">
<li><a href="#" data-theme="light">Light</a></li>
<li><a href="#" data-theme="dark">Dark</a></li>
<li><a href="#" data-theme="auto">Auto (System)</a></li>
</ul>
<!-- Theme Dropdown -->
<div class="relative">
<button id="theme-toggle" class="px-3 py-1 rounded border text-sm" style="border-color: var(--border-muted);">
<span id="theme-text">Theme</span>
</button>
<div id="theme-menu" class="absolute right-0 mt-2 w-36 rounded-md shadow-lg ring-1 ring-black/5 hidden" style="background: var(--bg-soft);">
<ul class="py-1 text-sm">
<li><a href="#" data-theme="light" class="block px-3 py-1 hover:bg-black/10">Light</a></li>
<li><a href="#" data-theme="dark" class="block px-3 py-1 hover:bg-black/10">Dark</a></li>
<li><a href="#" data-theme="auto" class="block px-3 py-1 hover:bg-black/10">Auto (System)</a></li>
</ul>
</div>
</div>
</div>
</div>
</header>
<main>
<div class="uk-container">
<!-- Search Section -->
<section class="uk-section-xxsmall uk-padding">
<div class="uk-container uk-width-expand">
<form class="uk-search uk-search-default uk-width-expand">
<input class="uk-search-input" type="search" placeholder="Search by ISBN, title, author..." aria-label="Search books" id="search-input">
<button class="uk-search-icon-flip" uk-search-icon id="search-button" type="button"></button>
</form>
<button class="uk-button uk-button-default uk-float-right" uk-toggle="target: #search-filters" type="button">Advanced Search</button>
<form hidden id="search-filters" class="uk-search uk-search-default uk-inline uk-width-expand uk-flex uk-flex-wrap">
<div class="search-filter">
<label for="isbn-input">ISBN</label>
<div class="uk-flex uk-flex-wrap">
<input class="uk-search-input uk-width-4-5" type="search" placeholder="ISBN..." aria-label="Search by ISBN" id="isbn-input">
</div>
</div>
<div class="search-filter">
<label for="author-input">Author</label>
<div class="uk-flex uk-flex-wrap">
<input class="uk-search-input uk-width-4-5" type="search" placeholder="Author..." aria-label="Search by author" id="author-input">
</div>
</div>
<div class="search-filter">
<label for="title-input">Title</label>
<div class="uk-flex uk-flex-wrap">
<input class="uk-search-input uk-width-4-5" type="search" placeholder="Title" aria-label="Search by title" id="title-input">
</div>
</div>
<div class="search-filter">
<label for="lang-input">Language</label>
<div class="uk-flex uk-flex-wrap">
<select class="uk-select uk-width-4-5" id="lang-input">
<option value="all">All</option>
{% for lang in book_languages %}
<option value="{{ lang.code }}"
{% if lang.code == default_language[0] %}selected{% endif %}>
{{ lang.language }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="search-filter">
<label for="sort-input">Sort</label>
<div class="uk-flex uk-flex-wrap">
<select class="uk-select uk-width-4-5" id="sort-input">
<option value="">Most relevant</option>
<option value="newest">Newest (publication year)</option>
<option value="oldest">Oldest (publication year)</option>
<option value="largest">Largest (filesize)</option>
<option value="smallest">Smallest (filesize)</option>
<option value="newest_added">Newest (open sourced)</option>
<option value="oldest_added">Oldest (open sourced)</option>
</select>
</div>
</div>
<div class="search-filter">
<label for="content-input">Content</label>
<div class="uk-flex uk-flex-wrap">
<select class="uk-select uk-width-4-5" id="content-input">
<option value="">All</option>
<option value="book_nonfiction">Book (non-fiction)</option>
<option value="book_fiction">Book (fiction)</option>
<option value="book_unknown">Book (unkown)</option>
<option value="magazine">Magazine</option>
<option value="book_comic">Comic Book</option>
<option value="standards_document">Standards document</option>
<option value="other">Other</option>
<option value="musical_score">Musical score</option>
<option value="audiobook">Audiobook</option>
</select>
</div>
</div>
<div class="search-filter">
<label>Formats</label>
<div class="uk-margin uk-grid-small uk-child-width-auto uk-grid">
<label><input class="uk-checkbox" type="checkbox" id="format-pdf" value="pdf"> PDF</label>
<label><input class="uk-checkbox" type="checkbox" id="format-epub" value="epub" checked> EPUB</label>
<label><input class="uk-checkbox" type="checkbox" id="format-mobi" value="mobi" checked> MOBI</label>
<label><input class="uk-checkbox" type="checkbox" id="format-azw3" value="azw3" checked> AZW3</label>
<label><input class="uk-checkbox" type="checkbox" id="format-fb2" value="fb2" checked> FB2</label>
<label><input class="uk-checkbox" type="checkbox" id="format-djvu" value="djvu" checked> DJVU</label>
<label><input class="uk-checkbox" type="checkbox" id="format-cbz" value="cbz" checked> CBZ</label>
<label><input class="uk-checkbox" type="checkbox" id="format-cbr" value="cbr" checked> CBR</label>
</div>
</div>
<div class="uk-flex uk-margin-auto-top uk-margin-auto-left">
<button class="uk-button uk-button-default uk-margin-small-top" id="adv-search-button" type="button">Search</button>
</div>
</form>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<!-- Hero / Search -->
<section class="mb-6">
<div class="flex flex-col gap-3">
<div class="flex gap-2">
<input id="search-input" type="search" placeholder="Search by ISBN, title, author..." aria-label="Search books"
class="flex-1 px-4 py-3 rounded-md border outline-none"
style="background: var(--bg-soft); color: var(--text); border-color: var(--border-muted);">
<button id="search-button" class="px-4 py-3 rounded-md text-white bg-blue-600 hover:bg-blue-700">
Search
</button>
</div>
</section>
<!-- Results Section -->
<ul uk-accordion="animation: false" id="results-section-accordion">
<li id="search-accordion">
<a class="uk-accordion-title" href><h1 class="uk-heading-xsmall">Search Results</h1></a>
<div class="uk-accordion-content">
<div id="search-loading" class="uk-flex uk-flex-center" role="status" hidden>
<div uk-spinner="ratio: 2"></div>
<span>Loading...</span>
<div>
<button id="toggle-advanced" class="text-sm underline opacity-80 hover:opacity-100">Advanced Search</button>
</div>
<!-- Advanced Filters -->
<form id="search-filters" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 hidden">
<div>
<label for="isbn-input" class="block text-sm mb-1 opacity-80">ISBN</label>
<input id="isbn-input" type="search" placeholder="ISBN"
class="w-full px-3 py-2 rounded-md border"
style="background: var(--bg-soft); color: var(--text); border-color: var(--border-muted);">
</div>
<div class="results-content">
<button class="uk-button uk-button-primary uk-margin-small" id="download-selected-button" disabled>Download Selected</button>
<div class="uk-overflow-auto">
<table id="results-table" class="uk-table uk-table-hover uk-table-divider" role="grid">
<thead>
<tr>
<th>
<input type="checkbox" id="select-all-checkbox" class="uk-checkbox" />
</th>
<th scope="col" data-sort="index"># <span class="sort-icon" uk-icon></span></th>
<th scope="col">Preview</th>
<th scope="col" data-sort="title">Title <span class="sort-icon" uk-icon></span></th>
<th scope="col" data-sort="author">Author <span class="sort-icon" uk-icon></span></th>
<th scope="col" data-sort="publisher">Publisher <span class="sort-icon" uk-icon></span></th>
<th scope="col" data-sort="year">Year <span class="sort-icon" uk-icon></span></th>
<th scope="col" data-sort="language">Language <span class="sort-icon" uk-icon></span></th>
<th scope="col" data-sort="format">Format <span class="sort-icon" uk-icon></span></th>
<th scope="col" data-sort="size">Size <span class="sort-icon" uk-icon></span></th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<!-- Search results will be injected here -->
</tbody>
</table>
<div>
<label for="author-input" class="block text-sm mb-1 opacity-80">Author</label>
<input id="author-input" type="search" placeholder="Author"
class="w-full px-3 py-2 rounded-md border"
style="background: var(--bg-soft); color: var(--text); border-color: var(--border-muted);">
</div>
<div>
<label for="title-input" class="block text-sm mb-1 opacity-80">Title</label>
<input id="title-input" type="search" placeholder="Title"
class="w-full px-3 py-2 rounded-md border"
style="background: var(--bg-soft); color: var(--text); border-color: var(--border-muted);">
</div>
<div>
<label for="lang-input" class="block text-sm mb-1 opacity-80">Language</label>
<select id="lang-input" class="w-full px-3 py-2 rounded-md border"
style="background: var(--bg-soft); color: var(--text); border-color: var(--border-muted);">
<option value="all">All</option>
{% for lang in book_languages %}
<option value="{{ lang.code }}" {% if lang.code == default_language[0] %}selected{% endif %}>
{{ lang.language }}
</option>
{% endfor %}
</select>
</div>
<div>
<label for="sort-input" class="block text-sm mb-1 opacity-80">Sort</label>
<select id="sort-input" class="w-full px-3 py-2 rounded-md border"
style="background: var(--bg-soft); color: var(--text); border-color: var(--border-muted);">
<option value="">Most relevant</option>
<option value="newest">Newest (publication year)</option>
<option value="oldest">Oldest (publication year)</option>
<option value="largest">Largest (filesize)</option>
<option value="smallest">Smallest (filesize)</option>
<option value="newest_added">Newest (open sourced)</option>
<option value="oldest_added">Oldest (open sourced)</option>
</select>
</div>
<div>
<label for="content-input" class="block text sm mb-1 opacity-80">Content</label>
<select id="content-input" class="w-full px-3 py-2 rounded-md border"
style="background: var(--bg-soft); color: var(--text); border-color: var(--border-muted);">
<option value="">All</option>
<option value="book_nonfiction">Book (non-fiction)</option>
<option value="book_fiction">Book (fiction)</option>
<option value="book_unknown">Book (unknown)</option>
<option value="magazine">Magazine</option>
<option value="book_comic">Comic Book</option>
<option value="standards_document">Standards document</option>
<option value="other">Other</option>
<option value="musical_score">Musical score</option>
<option value="audiobook">Audiobook</option>
</select>
</div>
<div class="md:col-span-2 lg:col-span-3">
<label class="block text-sm mb-1 opacity-80">Formats</label>
<div class="flex flex-wrap gap-3 text-sm">
<label class="inline-flex items-center gap-2"><input type="checkbox" id="format-pdf" value="pdf"> PDF</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" id="format-epub" value="epub" checked> EPUB</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" id="format-mobi" value="mobi" checked> MOBI</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" id="format-azw3" value="azw3" checked> AZW3</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" id="format-fb2" value="fb2" checked> FB2</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" id="format-djvu" value="djvu" checked> DJVU</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" id="format-cbz" value="cbz" checked> CBZ</label>
<label class="inline-flex items-center gap-2"><input type="checkbox" id="format-cbr" value="cbr" checked> CBR</label>
</div>
</div>
</div>
</li>
</ul>
<div class="md:col-span-2 lg:col-span-3 flex justify-end">
<button id="adv-search-button" type="button" class="px-4 py-2 rounded-md border"
style="border-color: var(--border-muted);">Search</button>
</div>
</form>
</div>
</section>
<!-- Modal -->
<div class="modal-overlay" id="modal-overlay" role="dialog" aria-modal="true">
<div class="details-container" id="details-container">
<!-- Details will be dynamically injected here -->
<!-- Active Downloads (Top) -->
<section id="active-downloads-top" class="mb-6 hidden">
<div class="flex items-center justify-between mb-2">
<h2 class="text-lg font-semibold">Active Downloads</h2>
<button id="active-refresh-button" class="px-3 py-1 rounded border text-sm" style="border-color: var(--border-muted);">Refresh</button>
</div>
<div id="active-downloads-list" class="space-y-2"></div>
</section>
<!-- Results -->
<section class="mb-8">
<div class="flex items-center justify-between mb-3">
<h2 class="text-xl font-semibold">Search Results</h2>
<div id="search-loading" class="text-sm opacity-80 hidden">Loading…</div>
</div>
<div id="results-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<!-- Cards will be injected here -->
</div>
<div id="no-results" class="mt-4 text-sm opacity-80 hidden">No results found.</div>
</section>
<!-- Modal -->
<div class="modal-overlay" id="modal-overlay" role="dialog" aria-modal="true">
<div class="details-container" id="details-container"></div>
</div>
<!-- Status -->
<section>
<div class="flex items-center flex-wrap mb-3">
<h2 class="text-xl font-semibold mr-4 sm:mr-6">Download Queue & Status</h2>
<div class="flex items-center gap-3 ml-4 sm:ml-auto">
<button id="refresh-status-button" class="px-3 py-1 rounded border text-sm" style="border-color: var(--border-muted);">Refresh</button>
<button id="clear-completed-button" class="px-3 py-1 rounded border text-sm" style="border-color: var(--border-muted);">Clear Completed</button>
<span id="active-downloads-count" class="text-sm opacity-80">Active: 0</span>
</div>
</div>
<!-- Status Section -->
<ul uk-accordion="animation: false" id="status-section">
<li id="search-accordion">
<a class="uk-accordion-title" href><h1 class="uk-heading-xsmall">Download Queue & Status</h1></a>
<div class="uk-accordion-content">
<div id="status-loading" class="uk-flex uk-flex-center" role="status" hidden>
<div uk-spinner="ratio: 2"></div>
<span>Loading...</span>
</div>
<div class="status-content">
<div class="uk-overflow-auto">
<!-- Queue Management Controls -->
<div class="uk-margin-small-bottom">
<div class="uk-flex uk-flex-between uk-flex-wrap uk-flex-middle">
<div class="uk-flex uk-flex-wrap uk-flex-middle">
<button class="uk-button uk-button-default uk-button-small uk-margin-small-right" id="refresh-status-button">
<span uk-icon="icon: refresh"></span> Refresh
</button>
<button class="uk-button uk-button-secondary uk-button-small uk-margin-small-right" id="clear-completed-button">
<span uk-icon="icon: trash"></span> Clear Completed
</button>
<span class="uk-text-small uk-text-muted uk-margin-small-right" id="active-downloads-count">Active: 0</span>
</div>
<div class="uk-flex uk-flex-middle">
<label class="uk-text-small uk-margin-small-right">Max Concurrent:</label>
<span class="uk-text-small uk-text-bold" id="max-concurrent-display">3</span>
</div>
</div>
</div>
<table id="status-table" class="uk-table uk-table-hover uk-table-divider uk-table-small" role="grid">
<thead>
<tr>
<th scope="col">Status</th>
<th scope="col">Priority</th>
<th scope="col">Title</th>
<th scope="col">Progress</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<!-- Search results will be injected here -->
</tbody>
</table>
</div>
</div>
</div>
</li>
</ul>
</div>
<div id="status-loading" class="text-sm opacity-80 hidden">Loading…</div>
<div id="status-list" class="space-y-2"></div>
</section>
</main>
<footer>
<h1 class="uk-heading-xxsmall" style="color: white;">Calibre Web Book Downloader</h1>
<a href="https://github.com/calibrain/calibre-web-automated-book-downloader" class="uk-icon-link" uk-icon="icon:github; ratio: 2"></a>
<footer class="mt-10 border-t pt-6 pb-10" style="border-color: var(--border-muted); background: var(--footer-bg);">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-between">
<p class="text-sm opacity-80">Calibre Web Book Downloader</p>
<a href="https://github.com/calibrain/calibre-web-automated-book-downloader" class="opacity-80 hover:opacity-100" aria-label="GitHub">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6">
<path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 005.47 7.59c.4.07.55-.17.55-.38
0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52
-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95
0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.54 7.54 0 012 0c1.53-1.03 2.2-.82 2.2-.82
.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2
0 .21.15.46.55.38A8 8 0 0016 8c0-4.42-3.58-8-8-8z"/>
</svg>
</a>
</div>
</footer>
<!-- Scripts -->
<script src="{{ url_for('static', filename='js/main.js') }}" defer></script>
</body>
</html>