mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 22:05:20 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ea2fee0bb | ||
|
|
1c24312eb0 | ||
|
|
98e3a2f114 | ||
|
|
cd16f09f2e | ||
|
|
527c5d495d | ||
|
|
f5de2ab143 | ||
|
|
4e5c9b788f | ||
|
|
199d8453eb | ||
|
|
a9854b1a5c | ||
|
|
e4d3a372c8 | ||
|
|
ff44881415 | ||
|
|
9ffedc1fc0 | ||
|
|
00370818f0 | ||
|
|
7d9a82bfea | ||
|
|
207cff96d3 | ||
|
|
c8f21b8f8d | ||
|
|
5e04b6bfb8 | ||
|
|
09bd5ae9f0 | ||
|
|
5f6a81d97d |
@@ -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
|
||||
@@ -67,6 +70,7 @@ jobs:
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ steps.date.outputs.date }}-${{ github.sha }}
|
||||
RELEASE_VERSION=${{ github.ref_name }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
|
||||
Vendored
+2
-1
@@ -15,7 +15,8 @@
|
||||
"LOG_ROOT": "/tmp/cwa-book-downloader",
|
||||
"ENABLE_LOGGING": "true",
|
||||
"DOCKERMODE": "false",
|
||||
"DEBUG": "true"
|
||||
"DEBUG": "true",
|
||||
"CUSTOM_DNS": "google",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
+37
-18
@@ -4,6 +4,8 @@ FROM python:3.10-slim AS base
|
||||
# Add build argument for version
|
||||
ARG BUILD_VERSION
|
||||
ENV BUILD_VERSION=${BUILD_VERSION}
|
||||
ARG RELEASE_VERSION
|
||||
ENV RELEASE_VERSION=${RELEASE_VERSION}
|
||||
|
||||
# Set shell to bash with pipefail option
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
@@ -38,18 +40,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 +61,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 +90,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 +137,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"]
|
||||
|
||||
@@ -12,8 +12,8 @@ from flask import url_for as flask_url_for
|
||||
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 config import _SUPPORTED_BOOK_LANGUAGE, BOOK_LANGUAGE, SUPPORTED_FORMATS
|
||||
from env import FLASK_HOST, FLASK_PORT, APP_ENV, CWA_DB_PATH, DEBUG, USING_EXTERNAL_BYPASSER, BUILD_VERSION, RELEASE_VERSION
|
||||
import backend
|
||||
|
||||
from models import SearchFilters
|
||||
@@ -103,7 +103,17 @@ def index() -> str:
|
||||
"""
|
||||
Render main page with search and status table.
|
||||
"""
|
||||
return render_template('index.html', book_languages=_SUPPORTED_BOOK_LANGUAGE, default_language=BOOK_LANGUAGE, debug=DEBUG)
|
||||
return render_template('index.html',
|
||||
book_languages=_SUPPORTED_BOOK_LANGUAGE,
|
||||
default_language=BOOK_LANGUAGE,
|
||||
supported_formats=SUPPORTED_FORMATS,
|
||||
debug=DEBUG,
|
||||
build_version=BUILD_VERSION,
|
||||
release_version=RELEASE_VERSION,
|
||||
app_env=APP_ENV
|
||||
)
|
||||
|
||||
|
||||
|
||||
@app.route('/favico<path:_>')
|
||||
@app.route('/request/favico<path:_>')
|
||||
@@ -117,7 +127,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]]:
|
||||
@@ -245,9 +258,10 @@ def api_download() -> Union[Response, Tuple[Response, int]]:
|
||||
return jsonify({"error": "No book ID provided"}), 400
|
||||
|
||||
try:
|
||||
success = backend.queue_book(book_id)
|
||||
priority = int(request.args.get('priority', 0))
|
||||
success = backend.queue_book(book_id, priority)
|
||||
if success:
|
||||
return jsonify({"status": "queued"})
|
||||
return jsonify({"status": "queued", "priority": priority})
|
||||
return jsonify({"error": "Failed to queue book"}), 500
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Download error: {e}")
|
||||
@@ -306,6 +320,142 @@ def api_local_download() -> Union[Response, Tuple[Response, int]]:
|
||||
logger.error_trace(f"Local download error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/download/<book_id>/cancel', methods=['DELETE'])
|
||||
@login_required
|
||||
def api_cancel_download(book_id: str) -> Union[Response, Tuple[Response, int]]:
|
||||
"""
|
||||
Cancel a download.
|
||||
|
||||
Path Parameters:
|
||||
book_id (str): Book identifier to cancel
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON status indicating success or failure.
|
||||
"""
|
||||
try:
|
||||
success = backend.cancel_download(book_id)
|
||||
if success:
|
||||
return jsonify({"status": "cancelled", "book_id": book_id})
|
||||
return jsonify({"error": "Failed to cancel download or book not found"}), 404
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Cancel download error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/queue/<book_id>/priority', methods=['PUT'])
|
||||
@login_required
|
||||
def api_set_priority(book_id: str) -> Union[Response, Tuple[Response, int]]:
|
||||
"""
|
||||
Set priority for a queued book.
|
||||
|
||||
Path Parameters:
|
||||
book_id (str): Book identifier
|
||||
|
||||
Request Body:
|
||||
priority (int): New priority level (lower number = higher priority)
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON status indicating success or failure.
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data or 'priority' not in data:
|
||||
return jsonify({"error": "Priority not provided"}), 400
|
||||
|
||||
priority = int(data['priority'])
|
||||
success = backend.set_book_priority(book_id, priority)
|
||||
|
||||
if success:
|
||||
return jsonify({"status": "updated", "book_id": book_id, "priority": priority})
|
||||
return jsonify({"error": "Failed to update priority or book not found"}), 404
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid priority value"}), 400
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Set priority error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/queue/reorder', methods=['POST'])
|
||||
@login_required
|
||||
def api_reorder_queue() -> Union[Response, Tuple[Response, int]]:
|
||||
"""
|
||||
Bulk reorder queue by setting new priorities.
|
||||
|
||||
Request Body:
|
||||
book_priorities (dict): Mapping of book_id to new priority
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON status indicating success or failure.
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data or 'book_priorities' not in data:
|
||||
return jsonify({"error": "book_priorities not provided"}), 400
|
||||
|
||||
book_priorities = data['book_priorities']
|
||||
if not isinstance(book_priorities, dict):
|
||||
return jsonify({"error": "book_priorities must be a dictionary"}), 400
|
||||
|
||||
# Validate all priorities are integers
|
||||
for book_id, priority in book_priorities.items():
|
||||
if not isinstance(priority, int):
|
||||
return jsonify({"error": f"Invalid priority for book {book_id}"}), 400
|
||||
|
||||
success = backend.reorder_queue(book_priorities)
|
||||
|
||||
if success:
|
||||
return jsonify({"status": "reordered", "updated_count": len(book_priorities)})
|
||||
return jsonify({"error": "Failed to reorder queue"}), 500
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Reorder queue error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/queue/order', methods=['GET'])
|
||||
@login_required
|
||||
def api_queue_order() -> Union[Response, Tuple[Response, int]]:
|
||||
"""
|
||||
Get current queue order for display.
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON array of queued books with their order and priorities.
|
||||
"""
|
||||
try:
|
||||
queue_order = backend.get_queue_order()
|
||||
return jsonify({"queue": queue_order})
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Queue order error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/downloads/active', methods=['GET'])
|
||||
@login_required
|
||||
def api_active_downloads() -> Union[Response, Tuple[Response, int]]:
|
||||
"""
|
||||
Get list of currently active downloads.
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON array of active download book IDs.
|
||||
"""
|
||||
try:
|
||||
active_downloads = backend.get_active_downloads()
|
||||
return jsonify({"active_downloads": active_downloads})
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Active downloads error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/queue/clear', methods=['DELETE'])
|
||||
@login_required
|
||||
def api_clear_completed() -> Union[Response, Tuple[Response, int]]:
|
||||
"""
|
||||
Clear all completed, errored, or cancelled books from tracking.
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON with count of removed books.
|
||||
"""
|
||||
try:
|
||||
removed_count = backend.clear_completed()
|
||||
return jsonify({"status": "cleared", "removed_count": removed_count})
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Clear completed error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found_error(error: Exception) -> Union[Response, Tuple[Response, int]]:
|
||||
"""
|
||||
@@ -357,7 +507,10 @@ def authenticate() -> bool:
|
||||
|
||||
# Validate credentials against database
|
||||
try:
|
||||
conn = sqlite3.connect(CWA_DB_PATH)
|
||||
# Open database in true read-only mode to avoid journal/WAL writes on RO mounts
|
||||
db_path = os.fspath(CWA_DB_PATH)
|
||||
db_uri = f"file:{db_path}?mode=ro&immutable=1"
|
||||
conn = sqlite3.connect(db_uri, uri=True)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT password FROM user WHERE name = ?", (username,))
|
||||
row = cur.fetchone()
|
||||
|
||||
+190
-45
@@ -6,10 +6,12 @@ from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any, Tuple
|
||||
import subprocess
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor, Future
|
||||
from threading import Event
|
||||
|
||||
from logger import setup_logger
|
||||
from config import CUSTOM_SCRIPT
|
||||
from env import INGEST_DIR, TMP_DIR, MAIN_LOOP_SLEEP_TIME, USE_BOOK_TITLE
|
||||
from env import INGEST_DIR, TMP_DIR, MAIN_LOOP_SLEEP_TIME, USE_BOOK_TITLE, MAX_CONCURRENT_DOWNLOADS, DOWNLOAD_PROGRESS_UPDATE_INTERVAL
|
||||
from models import book_queue, BookInfo, QueueStatus, SearchFilters
|
||||
import book_manager
|
||||
|
||||
@@ -53,19 +55,20 @@ def get_book_info(book_id: str) -> Optional[Dict[str, Any]]:
|
||||
logger.error_trace(f"Error getting book info: {e}")
|
||||
return None
|
||||
|
||||
def queue_book(book_id: str) -> bool:
|
||||
"""Add a book to the download queue.
|
||||
def queue_book(book_id: str, priority: int = 0) -> bool:
|
||||
"""Add a book to the download queue with specified priority.
|
||||
|
||||
Args:
|
||||
book_id: Book identifier
|
||||
priority: Priority level (lower number = higher priority)
|
||||
|
||||
Returns:
|
||||
bool: True if book was successfully queued
|
||||
"""
|
||||
try:
|
||||
book_info = book_manager.get_book_info(book_id)
|
||||
book_queue.add(book_id, book_info)
|
||||
logger.info(f"Book queued: {book_info.title}")
|
||||
book_queue.add(book_id, book_info, priority)
|
||||
logger.info(f"Book queued with priority {priority}: {book_info.title}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Error queueing book: {e}")
|
||||
@@ -78,6 +81,12 @@ def queue_status() -> Dict[str, Dict[str, Any]]:
|
||||
Dict: Queue status organized by status type
|
||||
"""
|
||||
status = book_queue.get_status()
|
||||
for _, books in status.items():
|
||||
for _, book_info in books.items():
|
||||
if book_info.download_path:
|
||||
if not os.path.exists(book_info.download_path):
|
||||
book_info.download_path = None
|
||||
|
||||
# Convert Enum keys to strings and properly format the response
|
||||
return {
|
||||
status_type.value: books
|
||||
@@ -100,8 +109,9 @@ def get_book_data(book_id: str) -> Tuple[Optional[bytes], BookInfo]:
|
||||
return f.read(), book_info
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Error getting book data: {e}")
|
||||
book_info.download_path = None
|
||||
return None, ""
|
||||
if book_info:
|
||||
book_info.download_path = None
|
||||
return None, book_info if book_info else BookInfo(id=book_id, title="Unknown")
|
||||
|
||||
def _book_info_to_dict(book: BookInfo) -> Dict[str, Any]:
|
||||
"""Convert BookInfo object to dictionary representation."""
|
||||
@@ -110,17 +120,24 @@ def _book_info_to_dict(book: BookInfo) -> Dict[str, Any]:
|
||||
if value is not None
|
||||
}
|
||||
|
||||
def _download_book(book_id: str) -> Optional[str]:
|
||||
"""Download and process a book.
|
||||
def _download_book_with_cancellation(book_id: str, cancel_flag: Event) -> Optional[str]:
|
||||
"""Download and process a book with cancellation support.
|
||||
|
||||
Args:
|
||||
book_id: Book identifier
|
||||
cancel_flag: Threading event to signal cancellation
|
||||
|
||||
Returns:
|
||||
str: Path to the downloaded book if successful, None otherwise
|
||||
"""
|
||||
try:
|
||||
# Check for cancellation before starting
|
||||
if cancel_flag.is_set():
|
||||
logger.info(f"Download cancelled before starting: {book_id}")
|
||||
return None
|
||||
|
||||
book_info = book_queue._book_data[book_id]
|
||||
logger.info(f"Starting download: {book_info.title}")
|
||||
|
||||
if USE_BOOK_TITLE:
|
||||
book_name = _sanitize_filename(book_info.title)
|
||||
@@ -129,63 +146,191 @@ def _download_book(book_id: str) -> Optional[str]:
|
||||
book_name += f".{book_info.format}"
|
||||
book_path = TMP_DIR / book_name
|
||||
|
||||
success = book_manager.download_book(book_info, book_path)
|
||||
# Check cancellation before download
|
||||
if cancel_flag.is_set():
|
||||
logger.info(f"Download cancelled before book manager call: {book_id}")
|
||||
return None
|
||||
|
||||
progress_callback = lambda progress: update_download_progress(book_id, progress)
|
||||
success = book_manager.download_book(book_info, book_path, progress_callback, cancel_flag)
|
||||
|
||||
# Stop progress updates
|
||||
cancel_flag.wait(0.1) # Brief pause for progress thread cleanup
|
||||
|
||||
if cancel_flag.is_set():
|
||||
logger.info(f"Download cancelled during download: {book_id}")
|
||||
# Clean up partial download
|
||||
if book_path.exists():
|
||||
book_path.unlink()
|
||||
return None
|
||||
|
||||
if not success:
|
||||
raise Exception("Unkown error downloading book")
|
||||
raise Exception("Unknown error downloading book")
|
||||
|
||||
# Check cancellation before post-processing
|
||||
if cancel_flag.is_set():
|
||||
logger.info(f"Download cancelled before post-processing: {book_id}")
|
||||
if book_path.exists():
|
||||
book_path.unlink()
|
||||
return None
|
||||
|
||||
if CUSTOM_SCRIPT:
|
||||
logger.info(f"Running custom script: {CUSTOM_SCRIPT}")
|
||||
subprocess.run([CUSTOM_SCRIPT, book_path])
|
||||
|
||||
intermediate_path = INGEST_DIR / f"{book_id}.crdownload"
|
||||
final_path = INGEST_DIR / book_name
|
||||
final_path = INGEST_DIR / book_name
|
||||
|
||||
if os.path.exists(book_path):
|
||||
logger.info(f"Moving book to ingest directory then renaming: {book_path} -> {intermediate_path} -> {final_path}")
|
||||
logger.info(f"Moving book to ingest directory: {book_path} -> {final_path}")
|
||||
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)
|
||||
logger.info(f"Renaming book: {intermediate_path} -> {final_path}")
|
||||
|
||||
# Final cancellation check before completing
|
||||
if cancel_flag.is_set():
|
||||
logger.info(f"Download cancelled before final rename: {book_id}")
|
||||
if intermediate_path.exists():
|
||||
intermediate_path.unlink()
|
||||
return None
|
||||
|
||||
os.rename(intermediate_path, final_path)
|
||||
logger.info(f"Download completed successfully: {book_info.title}")
|
||||
|
||||
return str(final_path)
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Error downloading book: {e}")
|
||||
if cancel_flag.is_set():
|
||||
logger.info(f"Download cancelled during error handling: {book_id}")
|
||||
else:
|
||||
logger.error_trace(f"Error downloading book: {e}")
|
||||
return None
|
||||
|
||||
def download_loop() -> None:
|
||||
"""Background thread for processing download queue."""
|
||||
logger.info("Starting download loop")
|
||||
def update_download_progress(book_id: str, progress: float) -> None:
|
||||
"""Update download progress."""
|
||||
book_queue.update_progress(book_id, progress)
|
||||
|
||||
def cancel_download(book_id: str) -> bool:
|
||||
"""Cancel a download.
|
||||
|
||||
while True:
|
||||
book_id = book_queue.get_next()
|
||||
if not book_id:
|
||||
time.sleep(MAIN_LOOP_SLEEP_TIME)
|
||||
continue
|
||||
|
||||
try:
|
||||
book_queue.update_status(book_id, QueueStatus.DOWNLOADING)
|
||||
download_path = _download_book(book_id)
|
||||
if download_path:
|
||||
book_queue.update_download_path(book_id, download_path)
|
||||
Args:
|
||||
book_id: Book identifier to cancel
|
||||
|
||||
Returns:
|
||||
bool: True if cancellation was successful
|
||||
"""
|
||||
return book_queue.cancel_download(book_id)
|
||||
|
||||
new_status = (
|
||||
QueueStatus.AVAILABLE if download_path else QueueStatus.ERROR
|
||||
)
|
||||
book_queue.update_status(book_id, new_status)
|
||||
def set_book_priority(book_id: str, priority: int) -> bool:
|
||||
"""Set priority for a queued book.
|
||||
|
||||
Args:
|
||||
book_id: Book identifier
|
||||
priority: New priority level (lower = higher priority)
|
||||
|
||||
Returns:
|
||||
bool: True if priority was successfully changed
|
||||
"""
|
||||
return book_queue.set_priority(book_id, priority)
|
||||
|
||||
def reorder_queue(book_priorities: Dict[str, int]) -> bool:
|
||||
"""Bulk reorder queue.
|
||||
|
||||
Args:
|
||||
book_priorities: Dict mapping book_id to new priority
|
||||
|
||||
Returns:
|
||||
bool: True if reordering was successful
|
||||
"""
|
||||
return book_queue.reorder_queue(book_priorities)
|
||||
|
||||
def get_queue_order() -> List[Dict[str, any]]:
|
||||
"""Get current queue order for display."""
|
||||
return book_queue.get_queue_order()
|
||||
|
||||
def get_active_downloads() -> List[str]:
|
||||
"""Get list of currently active downloads."""
|
||||
return book_queue.get_active_downloads()
|
||||
|
||||
def clear_completed() -> int:
|
||||
"""Clear all completed downloads from tracking."""
|
||||
return book_queue.clear_completed()
|
||||
|
||||
def _process_single_download(book_id: str, cancel_flag: Event) -> None:
|
||||
"""Process a single download job."""
|
||||
try:
|
||||
book_queue.update_status(book_id, QueueStatus.DOWNLOADING)
|
||||
download_path = _download_book_with_cancellation(book_id, cancel_flag)
|
||||
|
||||
if cancel_flag.is_set():
|
||||
book_queue.update_status(book_id, QueueStatus.CANCELLED)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Book {book_id} download {'successful' if download_path else 'failed'}"
|
||||
)
|
||||
if download_path:
|
||||
book_queue.update_download_path(book_id, download_path)
|
||||
new_status = QueueStatus.AVAILABLE
|
||||
else:
|
||||
new_status = QueueStatus.ERROR
|
||||
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Error in download loop: {e}")
|
||||
book_queue.update_status(book_id, new_status)
|
||||
|
||||
logger.info(
|
||||
f"Book {book_id} download {'successful' if download_path else 'failed'}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
if not cancel_flag.is_set():
|
||||
logger.error_trace(f"Error in download processing: {e}")
|
||||
book_queue.update_status(book_id, QueueStatus.ERROR)
|
||||
else:
|
||||
logger.info(f"Download cancelled: {book_id}")
|
||||
book_queue.update_status(book_id, QueueStatus.CANCELLED)
|
||||
|
||||
# Start download loop in background thread
|
||||
download_thread = threading.Thread(
|
||||
target=download_loop,
|
||||
daemon=True
|
||||
def concurrent_download_loop() -> None:
|
||||
"""Main download coordinator using ThreadPoolExecutor for concurrent downloads."""
|
||||
logger.info(f"Starting concurrent download loop with {MAX_CONCURRENT_DOWNLOADS} workers")
|
||||
|
||||
with ThreadPoolExecutor(max_workers=MAX_CONCURRENT_DOWNLOADS, thread_name_prefix="BookDownload") as executor:
|
||||
active_futures: Dict[Future, str] = {} # Track active download futures
|
||||
|
||||
while True:
|
||||
# Clean up completed futures
|
||||
completed_futures = [f for f in active_futures if f.done()]
|
||||
for future in completed_futures:
|
||||
book_id = active_futures.pop(future)
|
||||
try:
|
||||
future.result() # This will raise any exceptions from the worker
|
||||
except Exception as e:
|
||||
logger.error_trace(f"Future exception for {book_id}: {e}")
|
||||
|
||||
# Start new downloads if we have capacity
|
||||
while len(active_futures) < MAX_CONCURRENT_DOWNLOADS:
|
||||
next_download = book_queue.get_next()
|
||||
if not next_download:
|
||||
break
|
||||
|
||||
book_id, cancel_flag = next_download
|
||||
logger.info(f"Starting concurrent download: {book_id}")
|
||||
|
||||
# Submit download job to thread pool
|
||||
future = executor.submit(_process_single_download, book_id, cancel_flag)
|
||||
active_futures[future] = book_id
|
||||
|
||||
# Brief sleep to prevent busy waiting
|
||||
time.sleep(MAIN_LOOP_SLEEP_TIME)
|
||||
|
||||
# Start concurrent download coordinator
|
||||
download_coordinator_thread = threading.Thread(
|
||||
target=concurrent_download_loop,
|
||||
daemon=True,
|
||||
name="DownloadCoordinator"
|
||||
)
|
||||
download_thread.start()
|
||||
download_coordinator_thread.start()
|
||||
|
||||
logger.info(f"Download system initialized with {MAX_CONCURRENT_DOWNLOADS} concurrent workers")
|
||||
|
||||
+55
-18
@@ -3,18 +3,19 @@
|
||||
import time, json, re
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
from typing import List, Optional, Dict, Union
|
||||
from typing import List, Optional, Dict, Union, Callable
|
||||
from threading import Event
|
||||
from bs4 import BeautifulSoup, Tag, NavigableString, ResultSet
|
||||
|
||||
import downloader
|
||||
from logger import setup_logger
|
||||
from config import SUPPORTED_FORMATS, BOOK_LANGUAGE, AA_BASE_URL
|
||||
from env import AA_DONATOR_KEY, USE_CF_BYPASS
|
||||
from env import AA_DONATOR_KEY, USE_CF_BYPASS, PRIORITIZE_WELIB, ALLOW_USE_WELIB
|
||||
from models import BookInfo, SearchFilters
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
|
||||
def search_books(query: str, filters: SearchFilters) -> List[BookInfo]:
|
||||
"""Search for books matching the query.
|
||||
|
||||
@@ -168,8 +169,6 @@ 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()
|
||||
|
||||
every_url = soup.find_all("a")
|
||||
slow_urls_no_waitlist = set()
|
||||
@@ -198,7 +197,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.bz', 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"]:
|
||||
@@ -206,11 +206,13 @@ def _parse_book_info_page(soup: BeautifulSoup, book_id: str) -> BookInfo:
|
||||
except:
|
||||
pass
|
||||
|
||||
external_urls_welib = _get_download_urls_from_welib(book_id) if USE_CF_BYPASS else set()
|
||||
|
||||
urls = []
|
||||
urls += list(external_urls_welib) if PRIORITIZE_WELIB else []
|
||||
urls += list(slow_urls_no_waitlist) if USE_CF_BYPASS else []
|
||||
urls += list(external_urls_libgen)
|
||||
urls += list( _get_download_urls_from_welib(book_id)) if USE_CF_BYPASS else []
|
||||
urls += list(external_urls_welib) if not PRIORITIZE_WELIB else []
|
||||
urls += list(slow_urls_with_waitlist) if USE_CF_BYPASS else []
|
||||
urls += list(external_urls_z_lib)
|
||||
|
||||
@@ -220,20 +222,49 @@ def _parse_book_info_page(soup: BeautifulSoup, book_id: str) -> BookInfo:
|
||||
# Remove empty urls
|
||||
urls = [url for url in urls if url != ""]
|
||||
|
||||
# Filter out divs that are not text
|
||||
original_divs = divs
|
||||
divs = [div.text.strip() for div in divs if div.text.strip() != ""]
|
||||
|
||||
separator_index = 6
|
||||
for i, div in enumerate(divs):
|
||||
if "·" in div.strip():
|
||||
separator_index = i
|
||||
break
|
||||
|
||||
_details = divs[separator_index].lower().split(" · ")
|
||||
format = ""
|
||||
size = ""
|
||||
for f in _details:
|
||||
if format == "" and f.strip().lower() in SUPPORTED_FORMATS:
|
||||
format = f.strip().lower()
|
||||
if size == "" and any(u in f.strip().lower() for u in ["mb", "kb", "gb"]):
|
||||
size = f.strip().lower()
|
||||
|
||||
if format == "" or size == "":
|
||||
for f in _details:
|
||||
if f == "" and not " " in f.strip().lower():
|
||||
format = f.strip().lower()
|
||||
if size == "" and "." in f.strip().lower():
|
||||
size = f.strip().lower()
|
||||
|
||||
|
||||
book_title = divs[separator_index-3].strip("🔍")
|
||||
|
||||
# Extract basic information
|
||||
book_info = BookInfo(
|
||||
id=book_id,
|
||||
preview=preview,
|
||||
title=divs[7].next.strip(),
|
||||
publisher=divs[11].text.strip(),
|
||||
author=divs[9].text.strip(),
|
||||
title=book_title,
|
||||
publisher=divs[separator_index-1],
|
||||
author=divs[separator_index-2],
|
||||
format=format,
|
||||
size=size,
|
||||
download_urls=urls,
|
||||
)
|
||||
|
||||
# Extract additional metadata
|
||||
info = _extract_book_metadata(divs[-6])
|
||||
info = _extract_book_metadata(original_divs[-6])
|
||||
book_info.info = info
|
||||
|
||||
# Set language and year from metadata if available
|
||||
@@ -244,9 +275,12 @@ def _parse_book_info_page(soup: BeautifulSoup, book_id: str) -> BookInfo:
|
||||
|
||||
return book_info
|
||||
|
||||
def _get_download_urls_from_welib(book_id: str) -> List[str]:
|
||||
def _get_download_urls_from_welib(book_id: str) -> set[str]:
|
||||
if ALLOW_USE_WELIB == False:
|
||||
return set()
|
||||
"""Get download urls from welib.org."""
|
||||
url = f"https://welib.org/md5/{book_id}"
|
||||
logger.info(f"Getting download urls from welib.org for {book_id}. While this uses the bypasser, it will not start downloading them yet.")
|
||||
html = downloader.html_get_page(url, use_bypasser=True)
|
||||
if not html:
|
||||
return []
|
||||
@@ -297,7 +331,7 @@ def _extract_book_metadata(
|
||||
}
|
||||
|
||||
|
||||
def download_book(book_info: BookInfo, book_path: Path) -> bool:
|
||||
def download_book(book_info: BookInfo, book_path: Path, progress_callback: Optional[Callable[[float], None]] = None, cancel_flag: Optional[Event] = None) -> bool:
|
||||
"""Download a book from available sources.
|
||||
|
||||
Args:
|
||||
@@ -321,10 +355,11 @@ def download_book(book_info: BookInfo, book_path: Path) -> bool:
|
||||
|
||||
for link in download_links:
|
||||
try:
|
||||
download_url = _get_download_url(link, book_info.title)
|
||||
download_url = _get_download_url(link, book_info.title, cancel_flag)
|
||||
if download_url != "":
|
||||
logger.info(f"Downloading `{book_info.title}` from `{download_url}`")
|
||||
data = downloader.download_url(download_url, book_info.size or "")
|
||||
|
||||
data = downloader.download_url(download_url, book_info.size or "", progress_callback, cancel_flag)
|
||||
if not data:
|
||||
raise Exception("No data received")
|
||||
|
||||
@@ -341,7 +376,7 @@ def download_book(book_info: BookInfo, book_path: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _get_download_url(link: str, title: str) -> str:
|
||||
def _get_download_url(link: str, title: str, cancel_flag: Optional[Event] = None) -> str:
|
||||
"""Extract actual download URL from various source pages."""
|
||||
|
||||
url = ""
|
||||
@@ -368,8 +403,10 @@ def _get_download_url(link: str, title: str) -> str:
|
||||
if countdown:
|
||||
sleep_time = int(countdown[0].text)
|
||||
logger.info(f"Waiting {sleep_time}s for {title}")
|
||||
time.sleep(sleep_time)
|
||||
url = _get_download_url(link, title)
|
||||
if cancel_flag is not None and cancel_flag.wait(timeout=sleep_time):
|
||||
logger.info(f"Cancelled wait for {title}")
|
||||
return ""
|
||||
url = _get_download_url(link, title, cancel_flag)
|
||||
else:
|
||||
url = download_links[0]["href"]
|
||||
else:
|
||||
|
||||
+217
-37
@@ -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,69 +45,193 @@ 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:
|
||||
title = sb.get_title().lower()
|
||||
body = sb.get_text("body").lower()
|
||||
# Get page information with error handling
|
||||
try:
|
||||
title = sb.get_title().lower()
|
||||
except:
|
||||
title = ""
|
||||
|
||||
try:
|
||||
body = sb.get_text("body").lower()
|
||||
except:
|
||||
body = ""
|
||||
|
||||
try:
|
||||
current_url = sb.get_current_url()
|
||||
except:
|
||||
current_url = ""
|
||||
|
||||
# Check both title and body for verification messages
|
||||
# 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.com/products/turnstile/?utm_source=turnstile"
|
||||
]
|
||||
|
||||
# Check for Cloudflare indicators
|
||||
for text in verification_texts:
|
||||
if text in title.lower() or text in body.lower():
|
||||
if text in title or text in body:
|
||||
logger.debug(f"Cloudflare indicator found: '{text}' in page")
|
||||
return False
|
||||
|
||||
|
||||
# Additional checks for specific Cloudflare patterns
|
||||
if "cf-" in body or "cloudflare" in current_url.lower():
|
||||
logger.debug("Cloudflare patterns detected in page")
|
||||
return False
|
||||
|
||||
# Check if we're still on a challenge page (common Cloudflare pattern)
|
||||
if "/cdn-cgi/" in current_url:
|
||||
logger.debug("Still on Cloudflare CDN challenge page")
|
||||
return False
|
||||
|
||||
# If page is mostly empty, it might still be loading
|
||||
if len(body.strip()) < 50:
|
||||
logger.debug("Page content too short, might still be loading")
|
||||
return False
|
||||
|
||||
logger.debug(f"Bypass check passed - Title: '{title[:100]}', Body length: {len(body)}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error checking page title: {e}")
|
||||
logger.warning(f"Error checking bypass status: {e}")
|
||||
# If we can't check, assume we're not bypassed
|
||||
return False
|
||||
|
||||
def _bypass_method_1(sb) -> bool:
|
||||
"""Original bypass method using uc_gui_click_captcha"""
|
||||
try:
|
||||
logger.debug("Attempting bypass method 1: uc_gui_click_captcha")
|
||||
sb.uc_gui_click_captcha()
|
||||
time.sleep(3)
|
||||
return _is_bypassed(sb)
|
||||
except Exception as e:
|
||||
logger.debug_trace(f"Error clicking captcha: {e}")
|
||||
time.sleep(5)
|
||||
sb.wait_for_element_visible('body')
|
||||
logger.debug(f"Method 1 failed on first try: {e}")
|
||||
try:
|
||||
time.sleep(5)
|
||||
sb.wait_for_element_visible('body', timeout=10)
|
||||
sb.uc_gui_click_captcha()
|
||||
time.sleep(3)
|
||||
return _is_bypassed(sb)
|
||||
except Exception as e2:
|
||||
logger.debug(f"Method 1 failed on second try: {e2}")
|
||||
try:
|
||||
time.sleep(DEFAULT_SLEEP)
|
||||
sb.uc_gui_click_captcha()
|
||||
time.sleep(5)
|
||||
return _is_bypassed(sb)
|
||||
except Exception as e3:
|
||||
logger.debug(f"Method 1 completely failed: {e3}")
|
||||
return False
|
||||
|
||||
def _bypass_method_2(sb) -> bool:
|
||||
"""Alternative bypass method using longer waits and manual interaction"""
|
||||
try:
|
||||
logger.debug("Attempting bypass method 2: wait and reload")
|
||||
# Wait longer for page to load completely
|
||||
time.sleep(10)
|
||||
|
||||
# Try refreshing the page
|
||||
sb.refresh()
|
||||
time.sleep(8)
|
||||
|
||||
# Check if bypass worked after refresh
|
||||
if _is_bypassed(sb):
|
||||
return True
|
||||
|
||||
# Try clicking on the page center (sometimes helps trigger bypass)
|
||||
try:
|
||||
sb.click_if_visible("body", timeout=5)
|
||||
time.sleep(5)
|
||||
except:
|
||||
pass
|
||||
|
||||
return _is_bypassed(sb)
|
||||
except Exception as e:
|
||||
logger.debug(f"Method 2 failed: {e}")
|
||||
return False
|
||||
|
||||
def _bypass_method_3(sb) -> bool:
|
||||
"""Third bypass method using user-agent rotation and stealth mode"""
|
||||
try:
|
||||
logger.debug("Attempting bypass method 3: stealth approach")
|
||||
# Wait a random amount to appear more human
|
||||
import random
|
||||
wait_time = random.uniform(8, 15)
|
||||
time.sleep(wait_time)
|
||||
|
||||
# Try to scroll the page (human-like behavior)
|
||||
try:
|
||||
sb.scroll_to_bottom()
|
||||
time.sleep(2)
|
||||
sb.scroll_to_top()
|
||||
time.sleep(3)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Check if this helped
|
||||
if _is_bypassed(sb):
|
||||
return True
|
||||
|
||||
# Try the original captcha click as last resort
|
||||
try:
|
||||
sb.uc_gui_click_captcha()
|
||||
except Exception as e:
|
||||
logger.debug_trace(f"Error clicking captcha again: {e}")
|
||||
time.sleep(DEFAULT_SLEEP)
|
||||
sb.uc_gui_click_captcha()
|
||||
return _is_bypassed(sb)
|
||||
time.sleep(5)
|
||||
except:
|
||||
pass
|
||||
|
||||
return _is_bypassed(sb)
|
||||
except Exception as e:
|
||||
logger.debug(f"Method 3 failed: {e}")
|
||||
return False
|
||||
|
||||
def _bypass(sb, max_retries: int = MAX_RETRY) -> None:
|
||||
"""Enhanced bypass function with multiple strategies"""
|
||||
try_count = 0
|
||||
methods = [_bypass_method_1, _bypass_method_2, _bypass_method_3]
|
||||
|
||||
while not _is_bypassed(sb):
|
||||
if try_count >= max_retries:
|
||||
logger.warning("Exceeded maximum retries. Bypass failed.")
|
||||
break
|
||||
logger.info(f"Bypass attempt {try_count + 1} / {max_retries}")
|
||||
|
||||
|
||||
method_index = try_count % len(methods)
|
||||
method = methods[method_index]
|
||||
|
||||
logger.info(f"Bypass attempt {try_count + 1} / {max_retries} using {method.__name__}")
|
||||
|
||||
try_count += 1
|
||||
|
||||
wait_time = DEFAULT_SLEEP * (try_count - 1)
|
||||
logger.info(f"Waiting {wait_time}s before trying...")
|
||||
time.sleep(wait_time)
|
||||
# Progressive backoff: wait longer between retries
|
||||
wait_time = min(DEFAULT_SLEEP * (try_count - 1), 15)
|
||||
if wait_time > 0:
|
||||
logger.info(f"Waiting {wait_time}s before trying...")
|
||||
time.sleep(wait_time)
|
||||
|
||||
if _bypass_method_1(sb):
|
||||
return
|
||||
try:
|
||||
if method(sb):
|
||||
logger.info(f"Bypass successful using {method.__name__}")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"Exception in {method.__name__}: {e}")
|
||||
|
||||
logger.info("Bypass failed.")
|
||||
logger.info(f"Bypass method {method.__name__} failed.")
|
||||
|
||||
def _get_chromium_args():
|
||||
|
||||
@@ -148,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
|
||||
@@ -161,18 +286,56 @@ def _get(url, retry : int = MAX_RETRY):
|
||||
try:
|
||||
logger.info(f"SB_GET: {url}")
|
||||
sb = _get_driver()
|
||||
|
||||
# Enhanced page loading with better error handling
|
||||
logger.debug("Opening URL with SeleniumBase...")
|
||||
sb.uc_open_with_reconnect(url, DEFAULT_SLEEP)
|
||||
time.sleep(DEFAULT_SLEEP)
|
||||
|
||||
# Log current page title and URL for debugging
|
||||
try:
|
||||
current_url = sb.get_current_url()
|
||||
current_title = sb.get_title()
|
||||
logger.debug(f"Page loaded - URL: {current_url}, Title: {current_title}")
|
||||
except Exception as debug_e:
|
||||
logger.debug(f"Could not get page info: {debug_e}")
|
||||
|
||||
# Attempt bypass
|
||||
logger.debug("Starting bypass process...")
|
||||
_bypass(sb)
|
||||
|
||||
if _is_bypassed(sb):
|
||||
logger.info("Bypass successful.")
|
||||
return sb.page_source
|
||||
else:
|
||||
logger.warning("Bypass completed but page still shows Cloudflare protection")
|
||||
# Log page content for debugging (truncated)
|
||||
try:
|
||||
page_text = sb.get_text("body")[:500] + "..." if len(sb.get_text("body")) > 500 else sb.get_text("body")
|
||||
logger.debug(f"Page content: {page_text}")
|
||||
except:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
# Enhanced error logging with full stack trace
|
||||
import traceback
|
||||
error_details = f"Exception type: {type(e).__name__}, Message: {str(e)}"
|
||||
stack_trace = traceback.format_exc()
|
||||
|
||||
if retry == 0:
|
||||
logger.error_trace(f"Failed to initialize browser: {e}")
|
||||
logger.error(f"Failed to initialize browser after all retries: {error_details}")
|
||||
logger.debug(f"Full stack trace: {stack_trace}")
|
||||
_reset_driver()
|
||||
raise e
|
||||
logger.error_trace(f"Failed to bypass Cloudflare: {e}. Will retry...")
|
||||
|
||||
logger.warning(f"Failed to bypass Cloudflare (retry {MAX_RETRY - retry + 1}/{MAX_RETRY}): {error_details}")
|
||||
logger.debug(f"Stack trace: {stack_trace}")
|
||||
|
||||
# Reset driver on certain errors
|
||||
if "WebDriverException" in str(type(e)) or "SessionNotCreatedException" in str(type(e)):
|
||||
logger.info("Resetting driver due to WebDriver error...")
|
||||
_reset_driver()
|
||||
|
||||
return _get(url, retry - 1)
|
||||
|
||||
def get(url, retry : int = MAX_RETRY):
|
||||
@@ -309,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")
|
||||
|
||||
@@ -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']
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
+8
-2
@@ -1,6 +1,9 @@
|
||||
services:
|
||||
calibre-web-automated-book-downloader:
|
||||
image: ghcr.io/calibrain/calibre-web-automated-book-downloader:latest
|
||||
# Uncomment to build the image from the Dockerfile for local testing changes.
|
||||
# Remember to comment out the image line above.
|
||||
#build: .
|
||||
container_name: calibre-web-automated-book-downloader
|
||||
environment:
|
||||
FLASK_PORT: 8084
|
||||
@@ -11,7 +14,10 @@ services:
|
||||
APP_ENV: prod
|
||||
UID: 1000
|
||||
GID: 100
|
||||
CWA_DB_PATH: /auth/app.db
|
||||
# CWA_DB_PATH: /auth/app.db # Comment out to disable authentication
|
||||
# Queue management settings
|
||||
MAX_CONCURRENT_DOWNLOADS: 3
|
||||
DOWNLOAD_PROGRESS_UPDATE_INTERVAL: 5
|
||||
ports:
|
||||
- 8084:8084
|
||||
restart: unless-stopped
|
||||
@@ -20,5 +26,5 @@ services:
|
||||
# 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
|
||||
# details. Comment out to disable authentication
|
||||
#- /cwa/config/path/app.db:/auth/app.db:ro
|
||||
|
||||
+14
-10
@@ -8,12 +8,16 @@ 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
|
||||
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__)
|
||||
|
||||
@@ -34,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)
|
||||
@@ -71,7 +70,7 @@ def html_get_page(url: str, retry: int = MAX_RETRY, use_bypasser: bool = False)
|
||||
time.sleep(sleep_time)
|
||||
return html_get_page(url, retry - 1, use_bypasser)
|
||||
|
||||
def download_url(link: str, size: str = "") -> Optional[BytesIO]:
|
||||
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:
|
||||
@@ -99,6 +98,11 @@ def download_url(link: str, size: str = "") -> Optional[BytesIO]:
|
||||
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:
|
||||
|
||||
+3
-2
@@ -20,6 +20,7 @@ set -e
|
||||
|
||||
# Print build version
|
||||
echo "Build version: $BUILD_VERSION"
|
||||
echo "Release version: $RELEASE_VERSION"
|
||||
|
||||
# Configure timezone
|
||||
if [ "$TZ" ]; then
|
||||
@@ -112,8 +113,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"
|
||||
|
||||
@@ -25,7 +25,15 @@ _BOOK_LANGUAGE = os.getenv("BOOK_LANGUAGE", "en").lower()
|
||||
_CUSTOM_SCRIPT = os.getenv("CUSTOM_SCRIPT", "").strip()
|
||||
FLASK_HOST = os.getenv("FLASK_HOST", "0.0.0.0")
|
||||
FLASK_PORT = int(os.getenv("FLASK_PORT", "8084"))
|
||||
DEBUG = string_to_bool(os.getenv("DEBUG", "False"))
|
||||
DEBUG = string_to_bool(os.getenv("DEBUG", "false"))
|
||||
APP_ENV = os.getenv("APP_ENV", "N/A").lower()
|
||||
PRIORITIZE_WELIB = string_to_bool(os.getenv("PRIORITIZE_WELIB", "false"))
|
||||
ALLOW_USE_WELIB = string_to_bool(os.getenv("ALLOW_USE_WELIB", "true"))
|
||||
|
||||
# Version information from Docker build
|
||||
BUILD_VERSION = os.getenv("BUILD_VERSION", "N/A")
|
||||
RELEASE_VERSION = os.getenv("RELEASE_VERSION", "N/A")
|
||||
|
||||
# If debug is true, we want to log everything
|
||||
if DEBUG:
|
||||
LOG_LEVEL = "DEBUG"
|
||||
@@ -33,14 +41,22 @@ else:
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
ENABLE_LOGGING = string_to_bool(os.getenv("ENABLE_LOGGING", "true"))
|
||||
MAIN_LOOP_SLEEP_TIME = int(os.getenv("MAIN_LOOP_SLEEP_TIME", "5"))
|
||||
MAX_CONCURRENT_DOWNLOADS = int(os.getenv("MAX_CONCURRENT_DOWNLOADS", "3"))
|
||||
DOWNLOAD_PROGRESS_UPDATE_INTERVAL = int(os.getenv("DOWNLOAD_PROGRESS_UPDATE_INTERVAL", "5"))
|
||||
DOCKERMODE = string_to_bool(os.getenv("DOCKERMODE", "false"))
|
||||
_CUSTOM_DNS = os.getenv("CUSTOM_DNS", "").strip()
|
||||
USE_DOH = string_to_bool(os.getenv("USE_DOH", "false"))
|
||||
BYPASS_RELEASE_INACTIVE_MIN = int(os.getenv("BYPASS_RELEASE_INACTIVE_MIN", "5"))
|
||||
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:
|
||||
|
||||
+5
-5
@@ -3,7 +3,7 @@
|
||||
# Set up log paths
|
||||
LOG_ROOT=${LOG_ROOT:-"/var/log"}
|
||||
LOG_DIR="$LOG_ROOT/cwa-book-downloader"
|
||||
OUTPUT_FILE_NAME="cwa-book-downloader-debug_BUILD-${BUILD_VERSION:-local}_$(date +%Y%m%d-%H%M%S)"
|
||||
OUTPUT_FILE_NAME="cwa-book-downloader-debug_BUILD-${BUILD_VERSION:-local}_RELEASE-${RELEASE_VERSION:-NA}_$(date +%Y%m%d-%H%M%S)"
|
||||
OUTPUT_FILE="/tmp/$OUTPUT_FILE_NAME.zip"
|
||||
|
||||
# Create LOG_DIR if it doesn't exist
|
||||
@@ -125,16 +125,16 @@ fi
|
||||
env | grep -v -E "(AA_DONATOR_KEY)" | sort > "$LOG_DIR/environment.txt"
|
||||
|
||||
echo "--- HTTPBin ---" > $LOG_DIR/network_info.txt
|
||||
pyrequests https://httpbin.org/get >> $LOG_DIR/network_info.txt
|
||||
curl -s https://httpbin.org/get >> $LOG_DIR/network_info.txt
|
||||
ehco ""
|
||||
echo "--- HowsMySSL ---" >> $LOG_DIR/network_info.txt
|
||||
pyrequests https://www.howsmyssl.com/a/check >> $LOG_DIR/network_info.txt
|
||||
curl -s https://www.howsmyssl.com/a/check >> $LOG_DIR/network_info.txt
|
||||
ehco ""
|
||||
echo "--- IPInfo ---" >> $LOG_DIR/network_info.txt
|
||||
pyrequests https://ipinfo.io >> $LOG_DIR/network_info.txt
|
||||
curl -s https://ipinfo.io >> $LOG_DIR/network_info.txt
|
||||
ehco ""
|
||||
echo "--- Cloudflare Trace ---" >> $LOG_DIR/network_info.txt
|
||||
pyrequests https://1.1.1.1/cdn-cgi/trace >> $LOG_DIR/network_info.txt
|
||||
curl -s https://1.1.1.1/cdn-cgi/trace >> $LOG_DIR/network_info.txt
|
||||
|
||||
# Create the zip file directly from LOG_DIR
|
||||
ln -s "$LOG_DIR" /tmp/$OUTPUT_FILE_NAME
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Data structures and models used across the application."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from enum import Enum
|
||||
from datetime import datetime, timedelta
|
||||
from threading import Lock
|
||||
from threading import Lock, Event
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import time
|
||||
from env import INGEST_DIR, STATUS_TIMEOUT
|
||||
|
||||
class QueueStatus(str, Enum):
|
||||
@@ -15,6 +17,20 @@ class QueueStatus(str, Enum):
|
||||
AVAILABLE = "available"
|
||||
ERROR = "error"
|
||||
DONE = "done"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
@dataclass
|
||||
class QueueItem:
|
||||
"""Queue item with priority and metadata."""
|
||||
book_id: str
|
||||
priority: int
|
||||
added_time: float
|
||||
|
||||
def __lt__(self, other):
|
||||
"""Compare items for priority queue (lower priority number = higher precedence)."""
|
||||
if self.priority != other.priority:
|
||||
return self.priority < other.priority
|
||||
return self.added_time < other.added_time
|
||||
|
||||
@dataclass
|
||||
class BookInfo:
|
||||
@@ -31,28 +47,63 @@ class BookInfo:
|
||||
info: Optional[Dict[str, List[str]]] = None
|
||||
download_urls: List[str] = field(default_factory=list)
|
||||
download_path: Optional[str] = None
|
||||
priority: int = 0
|
||||
progress: Optional[float] = None
|
||||
|
||||
class BookQueue:
|
||||
"""Thread-safe book queue manager."""
|
||||
"""Thread-safe book queue manager with priority support and cancellation."""
|
||||
def __init__(self) -> None:
|
||||
self._queue: set[str] = set()
|
||||
self._queue: queue.PriorityQueue[QueueItem] = queue.PriorityQueue()
|
||||
self._lock = Lock()
|
||||
self._status: dict[str, QueueStatus] = {}
|
||||
self._book_data: dict[str, BookInfo]= {}
|
||||
self._book_data: dict[str, BookInfo] = {}
|
||||
self._status_timestamps: dict[str, datetime] = {} # Track when each status was last updated
|
||||
self._status_timeout = timedelta(seconds=STATUS_TIMEOUT) # 1 hour timeout
|
||||
self._cancel_flags: dict[str, Event] = {} # Cancellation flags for active downloads
|
||||
self._active_downloads: dict[str, bool] = {} # Track currently downloading books
|
||||
|
||||
def add(self, book_id: str, book_data: BookInfo) -> None:
|
||||
"""Add a book to the queue."""
|
||||
def add(self, book_id: str, book_data: BookInfo, priority: int = 0) -> None:
|
||||
"""Add a book to the queue with specified priority.
|
||||
|
||||
Args:
|
||||
book_id: Unique identifier for the book
|
||||
book_data: Book information
|
||||
priority: Priority level (lower number = higher priority)
|
||||
"""
|
||||
with self._lock:
|
||||
self._queue.add(book_id)
|
||||
# Don't add if already exists and not in error/done state
|
||||
if book_id in self._status and self._status[book_id] not in [QueueStatus.ERROR, QueueStatus.DONE, QueueStatus.CANCELLED]:
|
||||
return
|
||||
|
||||
book_data.priority = priority
|
||||
queue_item = QueueItem(book_id, priority, time.time())
|
||||
self._queue.put(queue_item)
|
||||
self._book_data[book_id] = book_data
|
||||
self._update_status(book_id, QueueStatus.QUEUED)
|
||||
|
||||
def get_next(self) -> Optional[str]:
|
||||
"""Get next book ID from queue."""
|
||||
with self._lock:
|
||||
return self._queue.pop() if self._queue else None
|
||||
def get_next(self) -> Optional[Tuple[str, Event]]:
|
||||
"""Get next book ID from queue with cancellation flag.
|
||||
|
||||
Returns:
|
||||
Tuple of (book_id, cancel_flag) or None if queue is empty
|
||||
"""
|
||||
try:
|
||||
queue_item = self._queue.get_nowait()
|
||||
book_id = queue_item.book_id
|
||||
|
||||
with self._lock:
|
||||
# Check if book was cancelled while in queue
|
||||
if book_id in self._status and self._status[book_id] == QueueStatus.CANCELLED:
|
||||
return self.get_next() # Recursively get next non-cancelled item
|
||||
|
||||
# Create cancellation flag for this download
|
||||
cancel_flag = Event()
|
||||
self._cancel_flags[book_id] = cancel_flag
|
||||
self._active_downloads[book_id] = True
|
||||
|
||||
return book_id, cancel_flag
|
||||
except queue.Empty:
|
||||
return None
|
||||
|
||||
def _update_status(self, book_id: str, status: QueueStatus) -> None:
|
||||
"""Internal method to update status and timestamp."""
|
||||
@@ -63,11 +114,23 @@ class BookQueue:
|
||||
"""Update status of a book in the queue."""
|
||||
with self._lock:
|
||||
self._update_status(book_id, status)
|
||||
|
||||
# Clean up active download tracking when finished
|
||||
if status in [QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.DONE, QueueStatus.CANCELLED]:
|
||||
self._active_downloads.pop(book_id, None)
|
||||
self._cancel_flags.pop(book_id, None)
|
||||
|
||||
def update_download_path(self, book_id: str, download_path: str) -> None:
|
||||
"""Update the download path of a book in the queue."""
|
||||
with self._lock:
|
||||
self._book_data[book_id].download_path = download_path
|
||||
if book_id in self._book_data:
|
||||
self._book_data[book_id].download_path = download_path
|
||||
|
||||
def update_progress(self, book_id: str, progress: float) -> None:
|
||||
"""Update download progress for a book."""
|
||||
with self._lock:
|
||||
if book_id in self._book_data:
|
||||
self._book_data[book_id].progress = progress
|
||||
|
||||
def get_status(self) -> Dict[QueueStatus, Dict[str, BookInfo]]:
|
||||
"""Get current queue status."""
|
||||
@@ -78,6 +141,160 @@ class BookQueue:
|
||||
if book_id in self._book_data:
|
||||
result[status][book_id] = self._book_data[book_id]
|
||||
return result
|
||||
|
||||
def get_queue_order(self) -> List[Dict[str, any]]:
|
||||
"""Get current queue order for display."""
|
||||
with self._lock:
|
||||
queue_items = []
|
||||
|
||||
# Get items from priority queue without removing them
|
||||
temp_items = []
|
||||
while not self._queue.empty():
|
||||
try:
|
||||
item = self._queue.get_nowait()
|
||||
temp_items.append(item)
|
||||
if item.book_id in self._book_data:
|
||||
book_info = self._book_data[item.book_id]
|
||||
queue_items.append({
|
||||
'id': item.book_id,
|
||||
'title': book_info.title,
|
||||
'author': book_info.author,
|
||||
'priority': item.priority,
|
||||
'added_time': item.added_time,
|
||||
'status': self._status.get(item.book_id, QueueStatus.QUEUED)
|
||||
})
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Put items back in queue
|
||||
for item in temp_items:
|
||||
self._queue.put(item)
|
||||
|
||||
return sorted(queue_items, key=lambda x: (x['priority'], x['added_time']))
|
||||
|
||||
def cancel_download(self, book_id: str) -> bool:
|
||||
"""Cancel a download and mark it as cancelled.
|
||||
|
||||
Args:
|
||||
book_id: Book identifier to cancel
|
||||
|
||||
Returns:
|
||||
bool: True if cancellation was successful
|
||||
"""
|
||||
with self._lock:
|
||||
current_status = self._status.get(book_id)
|
||||
|
||||
if current_status == QueueStatus.DOWNLOADING:
|
||||
# Signal active download to stop
|
||||
if book_id in self._cancel_flags:
|
||||
self._cancel_flags[book_id].set()
|
||||
self._update_status(book_id, QueueStatus.CANCELLED)
|
||||
return True
|
||||
elif current_status == QueueStatus.QUEUED:
|
||||
# Remove from queue and mark as cancelled
|
||||
self._update_status(book_id, QueueStatus.CANCELLED)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def set_priority(self, book_id: str, new_priority: int) -> bool:
|
||||
"""Change the priority of a queued book.
|
||||
|
||||
Args:
|
||||
book_id: Book identifier
|
||||
new_priority: New priority level (lower = higher priority)
|
||||
|
||||
Returns:
|
||||
bool: True if priority was successfully changed
|
||||
"""
|
||||
with self._lock:
|
||||
if book_id not in self._status or self._status[book_id] != QueueStatus.QUEUED:
|
||||
return False
|
||||
|
||||
# Remove book from queue and re-add with new priority
|
||||
temp_items = []
|
||||
found = False
|
||||
|
||||
while not self._queue.empty():
|
||||
try:
|
||||
item = self._queue.get_nowait()
|
||||
if item.book_id == book_id:
|
||||
# Create new item with updated priority
|
||||
new_item = QueueItem(book_id, new_priority, item.added_time)
|
||||
temp_items.append(new_item)
|
||||
found = True
|
||||
# Update book data priority
|
||||
if book_id in self._book_data:
|
||||
self._book_data[book_id].priority = new_priority
|
||||
else:
|
||||
temp_items.append(item)
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Put all items back
|
||||
for item in temp_items:
|
||||
self._queue.put(item)
|
||||
|
||||
return found
|
||||
|
||||
def reorder_queue(self, book_priorities: Dict[str, int]) -> bool:
|
||||
"""Bulk reorder queue by setting new priorities.
|
||||
|
||||
Args:
|
||||
book_priorities: Dict mapping book_id to new priority
|
||||
|
||||
Returns:
|
||||
bool: True if reordering was successful
|
||||
"""
|
||||
with self._lock:
|
||||
# Extract all items from queue
|
||||
all_items = []
|
||||
while not self._queue.empty():
|
||||
try:
|
||||
item = self._queue.get_nowait()
|
||||
# Update priority if specified
|
||||
if item.book_id in book_priorities:
|
||||
new_priority = book_priorities[item.book_id]
|
||||
item = QueueItem(item.book_id, new_priority, item.added_time)
|
||||
# Update book data priority
|
||||
if item.book_id in self._book_data:
|
||||
self._book_data[item.book_id].priority = new_priority
|
||||
all_items.append(item)
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Put all items back with updated priorities
|
||||
for item in all_items:
|
||||
self._queue.put(item)
|
||||
|
||||
return True
|
||||
|
||||
def get_active_downloads(self) -> List[str]:
|
||||
"""Get list of currently active download book IDs."""
|
||||
with self._lock:
|
||||
return list(self._active_downloads.keys())
|
||||
|
||||
def clear_completed(self) -> int:
|
||||
"""Remove all completed, errored, or cancelled books from tracking.
|
||||
|
||||
Returns:
|
||||
int: Number of books removed
|
||||
"""
|
||||
with self._lock:
|
||||
to_remove = []
|
||||
for book_id, status in self._status.items():
|
||||
if status in [QueueStatus.DONE, QueueStatus.ERROR, QueueStatus.CANCELLED]:
|
||||
to_remove.append(book_id)
|
||||
|
||||
removed_count = len(to_remove)
|
||||
for book_id in to_remove:
|
||||
self._status.pop(book_id, None)
|
||||
self._status_timestamps.pop(book_id, None)
|
||||
self._book_data.pop(book_id, None)
|
||||
self._cancel_flags.pop(book_id, None)
|
||||
self._active_downloads.pop(book_id, None)
|
||||
|
||||
return removed_count
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Remove any books that are done downloading or have stale status."""
|
||||
@@ -101,7 +318,7 @@ class BookQueue:
|
||||
# Check for stale status entries
|
||||
last_update = self._status_timestamps.get(book_id)
|
||||
if last_update and (current_time - last_update) > self._status_timeout:
|
||||
if status == QueueStatus.DONE or status == QueueStatus.ERROR or status == QueueStatus.AVAILABLE:
|
||||
if status in [QueueStatus.DONE, QueueStatus.ERROR, QueueStatus.AVAILABLE, QueueStatus.CANCELLED]:
|
||||
to_remove.append(book_id)
|
||||
|
||||
# Remove stale entries
|
||||
|
||||
@@ -82,6 +82,8 @@ Note that if using TOR, the TZ will be calculated automatically based on IP.
|
||||
| `BOOK_LANGUAGE` | Preferred language for books | `en` |
|
||||
| `AA_DONATOR_KEY` | Optional Donator key for Anna's Archive fast download API | `` |
|
||||
| `USE_BOOK_TITLE` | Use book title as filename instead of ID | `false` |
|
||||
| `PRIORITIZE_WELIB` | When downloading, download from WELIB first instead of AA | `false` |
|
||||
| `ALLOW_USE_WELIB` | Allow usage of welib for downloading books if found there | `true` |
|
||||
|
||||
If you change `BOOK_LANGUAGE`, you can add multiple comma separated languages, such as `en,fr,ru` etc.
|
||||
|
||||
@@ -90,7 +92,7 @@ If you change `BOOK_LANGUAGE`, you can add multiple comma separated languages, s
|
||||
| Variable | Description | Default Value |
|
||||
| ---------------------- | --------------------------------------------------------- | --------------------------------- |
|
||||
| `AA_BASE_URL` | Base URL of Annas-Archive (could be changed for a proxy) | `https://annas-archive.org` |
|
||||
| `USE_CF_BYPASS` | Disable CF bypass and use alternative links instead | `true` |
|
||||
| `USE_CF_BYPASS` | Disable CF bypass and use alternative links instead | `true` |
|
||||
|
||||
If you are a donator on AA, you can use your Key in `AA_DONATOR_KEY` to speed up downloads and bypass the wait times.
|
||||
If disabling the cloudflare bypass, you will be using alternative download hosts, such as libgen or z-lib, but they usually have a delay before getting the more recent books and their collection is not as big as aa's. But this setting should work for the majority of books.
|
||||
@@ -174,7 +176,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.
|
||||
|
||||
@@ -195,6 +199,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:
|
||||
|
||||
@@ -2,10 +2,7 @@ flask
|
||||
requests[socks]
|
||||
beautifulsoup4
|
||||
tqdm
|
||||
pyvirtualdisplay
|
||||
dnspython
|
||||
pyautogui
|
||||
seleniumbase==4.41
|
||||
gunicorn
|
||||
python-xlib
|
||||
psutil
|
||||
emoji
|
||||
@@ -0,0 +1,4 @@
|
||||
pyvirtualdisplay
|
||||
pyautogui
|
||||
seleniumbase>=4.41.1
|
||||
python-xlib
|
||||
@@ -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"] {
|
||||
|
||||
+381
-881
File diff suppressed because it is too large
Load Diff
+208
-200
@@ -3,231 +3,239 @@
|
||||
<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 {{ 'opacity-50 cursor-not-allowed' if 'pdf' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-pdf" value="pdf" {% if 'pdf' not in supported_formats %}disabled{% endif %}>
|
||||
PDF
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'epub' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-epub" value="epub" {% if 'epub' in supported_formats %}checked{% endif %} {% if 'epub' not in supported_formats %}disabled{% endif %}>
|
||||
EPUB
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'mobi' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-mobi" value="mobi" {% if 'mobi' in supported_formats %}checked{% endif %} {% if 'mobi' not in supported_formats %}disabled{% endif %}>
|
||||
MOBI
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'azw3' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-azw3" value="azw3" {% if 'azw3' in supported_formats %}checked{% endif %} {% if 'azw3' not in supported_formats %}disabled{% endif %}>
|
||||
AZW3
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'fb2' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-fb2" value="fb2" {% if 'fb2' in supported_formats %}checked{% endif %} {% if 'fb2' not in supported_formats %}disabled{% endif %}>
|
||||
FB2
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'djvu' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-djvu" value="djvu" {% if 'djvu' in supported_formats %}checked{% endif %} {% if 'djvu' not in supported_formats %}disabled{% endif %}>
|
||||
DJVU
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'cbz' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-cbz" value="cbz" {% if 'cbz' in supported_formats %}checked{% endif %} {% if 'cbz' not in supported_formats %}disabled{% endif %}>
|
||||
CBZ
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 {{ 'opacity-50 cursor-not-allowed' if 'cbr' not in supported_formats else '' }}">
|
||||
<input type="checkbox" id="format-cbr" value="cbr" {% if 'cbr' in supported_formats %}checked{% endif %} {% if 'cbr' not in supported_formats %}disabled{% endif %}>
|
||||
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 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">
|
||||
<table id="status-table" class="uk-table uk-table-hover uk-table-divider" role="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Book ID</th>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Preview</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">
|
||||
<div>
|
||||
<p class="text-sm opacity-80">Calibre Web Book Downloader</p>
|
||||
<p class="text-xs opacity-60 mt-1">
|
||||
Build: {{ build_version }} • Release: {{ release_version }} • Env: {{ app_env }}
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
Reference in New Issue
Block a user