mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 09:11:13 +01:00
## Changelog ### 🌐 Network Resilience - **Auto DNS rotation**: New `CUSTOM_DNS=auto` mode (now default) starts with system DNS and automatically rotates through Cloudflare, Google, Quad9, and OpenDNS when failures are detected. DNS results are cached to improve performance. - **Mirror failover**: Anna's Archive requests automatically fail over between mirrors (.org, .se, .li) when one is unreachable - **Round-robin source distribution**: Concurrent downloads are distributed across different AA partner servers to avoid rate limiting ### 📥 Download Reliability - **Much more reliable downloads**: Improved parsing of Anna's Archive pages, smarter source prioritization, and better retry logic with exponential backoff - **Download resume support**: Interrupted downloads can now resume from where they left off (if the server supports Range requests) - **Cookie sharing**: Cloudflare bypass cookies are extracted and shared with subsequent requests, often avoiding the need for re-bypass entirely - **Stall detection**: Downloads with no progress for 5 minutes are automatically cancelled and retried - **Staggered concurrent downloads**: Small delays between starting concurrent downloads to avoid hitting rate limits - **Source failure tracking**: After multiple failures from the same source type (e.g., Libgen), that source is temporarily skipped - **Lazy welib loading**: Welib sources are fetched as a fallback only when primary sources fail (unless `PRIORITIZE_WELIB` is enabled) ### 🛡️ Cloudflare & Protection Bypass - **DDOS-Guard support**: Internal bypasser now detects and handles DDOS-Guard challenges with dedicated bypass strategies - **Cancellation support**: Bypass operations can now be cancelled mid-operation when user cancels a download - **Smart warmup**: Chrome driver is pre-warmed when first client connects (controlled by `BYPASS_WARMUP_ON_CONNECT` env var) and shuts down after periods of inactivity ### 🔌 External Bypasser (FlareSolverr) - **Improved resilience**: Retry with exponential backoff, mirror/DNS rotation on failure, and proper timeout handling - **Cancellation support**: External bypasser operations respect cancellation flags ### 🖥️ Web UI Improvements - **Simplified download status**: Removed intermediate states (bypassing, verifying, ingesting) — now just shows Queued → Resolving → Downloading → Complete - **Status messages**: Downloads show detailed status like "Trying Anna's Archive (Server 3)" or "Server busy, trying next...", or live waitlist countdowns. - **Improved download sidebar**: - Downloads sorted by add time (newest first) - X button moved to top-right corner for better UX - Wave animation on in-progress items - Error messages shown directly on failed items - X button on completed/errored items clears them from the list ### ⚙️ Configuration Changes - **`CUSTOM_DNS=auto`** is now the default (previously empty/system DNS) - **`DOWNLOAD_PROGRESS_UPDATE_INTERVAL`** default changed from 5s to 1s for smoother progress - **`BYPASS_WARMUP_ON_CONNECT`** (default: true) — warm up Chrome when first client connects ### 🐛 Bug Fixes - **Download cancellation actually works**: Fixed issue where cancelling downloads didn't properly stop in-progress operations - **WELIB prioritization**: Fixed `PRIORITIZE_WELIB` not being respected - **File exists handling**: Downloads to same filename now get `_1`, `_2` suffix instead of overwriting - **Empty search results**: "No books found" now returns empty list instead of throwing exception - **Search unavailable error**: Network/mirror failures during search now return proper 503 error to client
160 lines
5.7 KiB
Python
160 lines
5.7 KiB
Python
from logger import setup_logger
|
|
from threading import Event
|
|
from typing import Optional, TYPE_CHECKING
|
|
import requests
|
|
import time
|
|
import random
|
|
|
|
if TYPE_CHECKING:
|
|
import network
|
|
|
|
|
|
class BypassCancelledException(Exception):
|
|
"""Raised when a bypass operation is cancelled."""
|
|
pass
|
|
|
|
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__)
|
|
|
|
# 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
|
|
"""
|
|
if not EXT_BYPASSER_URL or not EXT_BYPASSER_PATH:
|
|
logger.error("External bypasser not configured. Check EXT_BYPASSER_URL and EXT_BYPASSER_PATH.")
|
|
return None
|
|
|
|
bypasser_endpoint = f"{EXT_BYPASSER_URL}{EXT_BYPASSER_PATH}"
|
|
headers = {"Content-Type": "application/json"}
|
|
payload = {
|
|
"cmd": "request.get",
|
|
"url": target_url,
|
|
"maxTimeout": EXT_BYPASSER_TIMEOUT
|
|
}
|
|
|
|
# Calculate read timeout: bypasser timeout (ms → s) + buffer, capped at max
|
|
read_timeout = min((EXT_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
|
|
"""
|
|
import network
|
|
sel = selector or network.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
|