mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 20:10:28 +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
154 lines
6.5 KiB
Python
154 lines
6.5 KiB
Python
import requests
|
|
import time
|
|
import os
|
|
import hashlib
|
|
|
|
# Thee server is already running, so let's grab some of the env vars:
|
|
# Use absolute import since the script is run from the root directory
|
|
import env as SERVER_ENV
|
|
from models import BookInfo
|
|
|
|
# Now let's test the server:
|
|
port = SERVER_ENV.FLASK_PORT
|
|
server_url = f"http://localhost:{port}"
|
|
book_title = "077484a10743e5dd5d151013e8c732f4" # "Moby Dick"
|
|
# Directory where downloads should appear
|
|
download_paths = SERVER_ENV.DOWNLOAD_PATHS
|
|
download_dir = SERVER_ENV.INGEST_DIR
|
|
# Timeout for waiting for download
|
|
download_timeout_seconds = 60 * 5
|
|
# Polling interval
|
|
poll_interval_seconds = 5
|
|
|
|
# Helper function to check download status
|
|
def check_download_status(book_id):
|
|
print(f"Polling status for {book_id}...")
|
|
start_time = time.time()
|
|
while time.time() - start_time < download_timeout_seconds:
|
|
try:
|
|
status_response = requests.get(f"{server_url}/api/status")
|
|
status_response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)
|
|
status_data = status_response.json()
|
|
except requests.exceptions.RequestException as e:
|
|
print(f"Error fetching status: {e}. Retrying...")
|
|
time.sleep(poll_interval_seconds)
|
|
continue
|
|
except ValueError: # Includes JSONDecodeError
|
|
print(f"Error decoding status JSON. Response text: {status_response.text}. Retrying...")
|
|
time.sleep(poll_interval_seconds)
|
|
continue
|
|
|
|
# Check success conditions based on download_path
|
|
for status_key in ["available", "done", "complete"]:
|
|
if status_key in status_data and book_id in status_data[status_key]:
|
|
book_status_info = status_data[status_key].get(book_id)
|
|
# Check if the status info is a dictionary and has a non-empty download_path
|
|
if isinstance(book_status_info, dict) and book_status_info.get('download_path'):
|
|
print(f"Book {book_id} has download path '{book_status_info['download_path']}' in status '{status_key}'.")
|
|
return True, book_status_info
|
|
|
|
# Check for error status
|
|
if "error" in status_data and book_id in status_data["error"]:
|
|
book_error_info = status_data["error"].get(book_id, "Unknown error")
|
|
print(f"Book {book_id} failed with error: {book_error_info}")
|
|
return False, book_error_info
|
|
|
|
#print(f"Polling status for {book_id}... Status: {status_data}")
|
|
time.sleep(poll_interval_seconds)
|
|
|
|
print(f"Timeout waiting for book {book_id} download path to appear.")
|
|
return False, None
|
|
|
|
# --- Test Execution ---
|
|
print("--- Starting E2E Test ---")
|
|
|
|
# Step 1 : Search for a book
|
|
print(f"Step 1: Searching for book '{book_title}' (moby dick)...")
|
|
search_params = {'query': book_title}
|
|
search_response = requests.get(f"{server_url}/api/search", params=search_params)
|
|
search_response.raise_for_status()
|
|
search_results = search_response.json()
|
|
|
|
assert isinstance(search_results, list), f"Expected search results to be a list, got {type(search_results)}"
|
|
assert len(search_results) > 0, f"No books found for query: {book_title}"
|
|
print(f"Found {len(search_results)} potential matches.")
|
|
|
|
# Assume the first result is the one we want
|
|
book_to_test = search_results[0]
|
|
book_id = book_to_test.get('id')
|
|
assert book_id, "First search result is missing an 'id'"
|
|
print(f"Selected book ID for testing: {book_id}")
|
|
|
|
# Step 2 : Get book details
|
|
print(f"Step 2: Getting details for book ID: {book_id}...")
|
|
info_params = {'id': book_id}
|
|
info_response = requests.get(f"{server_url}/api/info", params=info_params)
|
|
info_response.raise_for_status()
|
|
book_details = info_response.json()
|
|
|
|
assert isinstance(book_details, dict), f"Expected book details to be a dict, got {type(book_details)}"
|
|
assert book_details.get('id') == book_id, "Book details ID mismatch"
|
|
print(f"Successfully retrieved details for '{book_details.get('title', 'N/A')}'")
|
|
|
|
# Step 3 : Queue the book for download
|
|
print(f"Step 3: Queuing download for book ID: {book_id}...")
|
|
download_params = {'id': book_id}
|
|
download_response = requests.get(f"{server_url}/api/download", params=download_params)
|
|
download_response.raise_for_status()
|
|
download_status = download_response.json()
|
|
|
|
assert download_status.get('status') == 'queued', f"Expected status 'queued', got {download_status}"
|
|
print(f"Book {book_id} successfully queued for download.")
|
|
|
|
# Step 4 : Check the download status until available or timeout
|
|
print(f"Step 4: Checking download status for book ID: {book_id} (timeout: {download_timeout_seconds}s)...")
|
|
is_available, final_status = check_download_status(book_id)
|
|
|
|
assert is_available, f"Book download failed or timed out. Final status check: {final_status}"
|
|
print(f"Book {book_id} download confirmed as available.")
|
|
|
|
# Step 5 : Verify the file exists locally (optional but good)
|
|
print(f"Step 5: Verifying downloaded file exists...")
|
|
# Depend if env.USE_BOOK_TITLE is true or false, the filename will be different
|
|
if SERVER_ENV.USE_BOOK_TITLE:
|
|
# Build expected filename using BookInfo
|
|
book_info = BookInfo(
|
|
id=book_id,
|
|
title=book_details.get('title', ''),
|
|
author=book_details.get('author'),
|
|
year=book_details.get('year'),
|
|
format='epub'
|
|
)
|
|
expected_filename = book_info.get_filename()
|
|
else:
|
|
expected_filename = f"{book_id}.epub"
|
|
|
|
if book_details.get("content"):
|
|
content = book_details.get("content")
|
|
for key, path in SERVER_ENV.DOWNLOAD_PATHS.items():
|
|
if key in content:
|
|
download_dir = path
|
|
break
|
|
expected_filepath = os.path.join(download_dir, expected_filename)
|
|
|
|
assert os.path.exists(expected_filepath), f"Expected downloaded file not found at: {expected_filepath}"
|
|
print(f"Verified file exists: {expected_filepath}")
|
|
|
|
# Step 6 : Download the book
|
|
print(f"Step 6: Downloading book {book_id}...")
|
|
download_response = requests.get(f"{server_url}/api/localdownload?id={book_id}")
|
|
download_response.raise_for_status()
|
|
# Write book to temp file :
|
|
temp_file_path = os.path.join("/tmp", f"{book_id}.epub")
|
|
with open(temp_file_path, 'wb') as f:
|
|
f.write(download_response.content)
|
|
|
|
# Compare the downloaded file to the expected file
|
|
# compare shasum of the two files
|
|
expected_sha256 = hashlib.sha256(open(expected_filepath, 'rb').read()).hexdigest()
|
|
downloaded_sha256 = hashlib.sha256(open(temp_file_path, 'rb').read()).hexdigest()
|
|
assert expected_sha256 == downloaded_sha256, f"Downloaded file SHA256 mismatch. Expected: {expected_sha256}, Got: {downloaded_sha256}"
|
|
print(f"Downloaded file SHA256 matches expected: {expected_sha256}")
|
|
|
|
print("--- E2E Test Completed Successfully ---")
|