mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-04-20 13:59:46 -04: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
111 lines
4.1 KiB
Python
111 lines
4.1 KiB
Python
"""Centralized logging configuration for the book downloader application."""
|
|
|
|
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
|
|
|
|
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 (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 (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()
|
|
available_mb = memory.available / (1024 * 1024)
|
|
memory_used_mb = memory.used / (1024 * 1024)
|
|
cpu_percent = psutil.cpu_percent()
|
|
self.debug(f"Container Memory: Available={available_mb:.2f} MB, Used={memory_used_mb:.2f} MB, CPU: {cpu_percent:.2f}%")
|
|
|
|
|
|
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
|
|
if LOG_LEVEL == "DEBUG":
|
|
log_level = logging.DEBUG
|
|
elif LOG_LEVEL == "INFO":
|
|
log_level = logging.INFO
|
|
elif LOG_LEVEL == "WARNING":
|
|
log_level = logging.WARNING
|
|
elif LOG_LEVEL == "ERROR":
|
|
log_level = logging.ERROR
|
|
elif LOG_LEVEL == "CRITICAL":
|
|
log_level = logging.CRITICAL
|
|
logger.setLevel(log_level)
|
|
|
|
formatter = logging.Formatter(
|
|
'%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s'
|
|
)
|
|
|
|
# Console handler for Docker output
|
|
console_handler = logging.StreamHandler(sys.stdout)
|
|
console_handler.setFormatter(formatter)
|
|
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:
|
|
# Create log directory if it doesn't exist
|
|
log_dir = log_file.parent
|
|
log_dir.mkdir(parents=True, exist_ok=True)
|
|
file_handler = RotatingFileHandler(
|
|
log_file,
|
|
maxBytes=10485760, # 10MB
|
|
backupCount=5
|
|
)
|
|
file_handler.setFormatter(formatter)
|
|
logger.addHandler(file_handler)
|
|
except Exception as e:
|
|
logger.error_trace(f"Failed to create log file: {e}", exc_info=True)
|
|
|
|
return logger
|
|
|