mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 22:05:50 +01:00
This PR was coauthored by alexhb1 and davidemarcoli. It builds on the FE rework created by alex, but adds a myriad of additional tweaks and optimizations to make the frontend feel modern, fast, and responsive. The summary of the changes is as follows: ### Architecture Changes React/TypeScript Migration: Refactored frontend from template/JS structure to React/TypeScript application for better maintainability and scalability WebSocket Integration: Implemented real-time updates for download status and progress with automatic fallback to polling Gevent Worker: Configured production WebSocket support ### UI/UX Improvements <img width="1502" height="890" alt="Screenshot 2025-11-10 at 10 02 59 AM" src="https://github.com/user-attachments/assets/86bf8649-623f-413c-b8e5-656e687e55a8" /> Downloads Sidebar: Replaced bottom downloads section with sidebar interface for better organization <img width="201" height="450" alt="Screenshot 2025-11-10 at 10 07 52 AM" src="https://github.com/user-attachments/assets/92b98e7c-c3bc-4b7e-80f1-252c3a760e33" /> Status Badges: Color-coded download status indicators instead of plain text Pinned Header: Fixed header position for consistent navigation Enhanced Book Cards: Improved layout and hover states with info modal button <img width="1474" height="899" alt="Screenshot 2025-11-10 at 10 08 18 AM" src="https://github.com/user-attachments/assets/9216d8a3-f662-434d-80e6-2a69b96abc31" /> Download Progress: Circular progress indicator on download buttons Toast Notifications: Added user feedback for actions Spinner Feedback: Loading indicators on search and download buttons Animations: Smooth transitions and fluid progress updates ### Mobile & Responsive Design Mobile-friendly Layouts: Optimized book cards and search interface for mobile <img width="225" height="450" alt="Screenshot 2025-11-10 at 10 05 49 AM" src="https://github.com/user-attachments/assets/c8236c1c-5837-4309-9577-46db7292a54b" /> Keyboard Handling: Improved mobile keyboard behavior with proper input types PWA Improvements: Enhanced progressive web app functionality Responsive Search: Better search box width and positioning across devices ### Developer Experience Development Mode: Separate frontend dev server that works with existing backend container Makefile: Added build automation and development commands Documentation: Updated README with frontend architecture details ### Bug Fixes Fixed "Clear completed" functionality Fixed dark mode toggle text Fixed sticky header behavior Fixed mobile search box positioning Removed active downloads requirement for initial state view ### Additional Features ESC Key: Close downloads sidebar with ESC key Calibre-Web Button: Direct link to Calibre-Web instance <img width="282" height="83" alt="Screenshot 2025-11-11 at 9 38 05 AM" src="https://github.com/user-attachments/assets/273075be-9743-4e13-9e48-5bf498f6c067" /> Granular Status Tracking: More detailed download progress information obtained via websockets --------- Co-authored-by: Alex <alex.bilbie1@gmail.com> Co-authored-by: Zack Yancey <yanceyz@proton.me> Co-authored-by: davidemarcoli <davide@marcoli.ch>
139 lines
5.2 KiB
Python
139 lines
5.2 KiB
Python
"""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 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, USING_EXTERNAL_BYPASSER
|
|
if USE_CF_BYPASS:
|
|
if USING_EXTERNAL_BYPASSER:
|
|
from cloudflare_bypasser_external import get_bypassed_page
|
|
else:
|
|
from cloudflare_bypasser import get_bypassed_page
|
|
|
|
logger = setup_logger(__name__)
|
|
|
|
|
|
def html_get_page(url: str, retry: int = MAX_RETRY, use_bypasser: bool = False, status_callback: Optional[Callable[[str], None]] = None) -> str:
|
|
"""Fetch HTML content from a URL with retry mechanism.
|
|
|
|
Args:
|
|
url: Target URL
|
|
retry: Number of retry attempts
|
|
use_bypasser: Whether to use Cloudflare bypasser
|
|
status_callback: Optional callback for status updates
|
|
|
|
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:
|
|
if status_callback:
|
|
status_callback("bypassing")
|
|
logger.info(f"GET Using Cloudflare Bypasser for: {url}")
|
|
return get_bypassed_page(url)
|
|
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, status_callback)
|
|
|
|
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, status_callback)
|
|
|
|
def download_url(link: str, size: str = "", progress_callback: Optional[Callable[[float], None]] = None, cancel_flag: Optional[Event] = None) -> 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))
|
|
if progress_callback is not None:
|
|
progress_callback(pbar.n * 100.0 / total_size)
|
|
if cancel_flag is not None and cancel_flag.is_set():
|
|
logger.info(f"Download cancelled: {link}")
|
|
return None
|
|
|
|
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()
|