Innitial Release
@@ -0,0 +1,53 @@
|
||||
name: Create and publish a Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Use Python slim image for smaller size
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Set environment variables
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV DOCKERMODE=true
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PIP_NO_CACHE_DIR=1
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
ENV PIP_DEFAULT_TIMEOUT=100
|
||||
ENV NAME=Calibre-Web-Automated-Book-Downloader
|
||||
ENV FLASK_HOST=0.0.0.0
|
||||
ENV FLASK_PORT=8084
|
||||
ENV FLASK_DEBUG=0
|
||||
ENV CLOUDFLARE_PROXY_URL=http://localhost:8000
|
||||
ENV INGEST_DIR=/cwa-book-ingest
|
||||
ENV STATUS_TIMEOUT=3600
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
RUN mkdir -p ${INGEST_DIR}
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends --no-install-suggests\
|
||||
calibre p7zip curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN chmod +x /app/check_health.sh
|
||||
|
||||
# Expose port
|
||||
EXPOSE ${FLASK_PORT}
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:${FLASK_PORT}/request/api/status || exit 1
|
||||
|
||||
# Start application
|
||||
CMD ["python", "-m", "app"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 CaliBrain
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
After Width: | Height: | Size: 874 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 419 KiB |
|
After Width: | Height: | Size: 244 KiB |
@@ -0,0 +1,233 @@
|
||||
"""Flask web application for book download service with URL rewrite support."""
|
||||
|
||||
import io, re, os
|
||||
from flask import Flask, request, jsonify, render_template, send_file, send_from_directory
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from flask import url_for as flask_url_for
|
||||
from functools import partial
|
||||
|
||||
from logger import setup_logger
|
||||
from config import FLASK_HOST, FLASK_PORT, FLASK_DEBUG
|
||||
import backend
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
app = Flask(__name__)
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app)
|
||||
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 # Disable caching
|
||||
app.config['APPLICATION_ROOT'] = '/'
|
||||
|
||||
|
||||
def register_dual_routes(app):
|
||||
"""
|
||||
Register each route both with and without the /request prefix.
|
||||
This function should be called after all routes are defined.
|
||||
"""
|
||||
# Store original url_map rules
|
||||
rules = list(app.url_map.iter_rules())
|
||||
|
||||
# Add /request prefix to each rule
|
||||
for rule in rules:
|
||||
if rule.rule != '/request/' and rule.rule != '/request': # Skip if it's already a request route
|
||||
# Create new routes with /request prefix, both with and without trailing slash
|
||||
base_rule = rule.rule[:-1] if rule.rule.endswith('/') else rule.rule
|
||||
if base_rule == '': # Special case for root path
|
||||
app.add_url_rule('/request', f"root_request",
|
||||
view_func=app.view_functions[rule.endpoint],
|
||||
methods=rule.methods)
|
||||
app.add_url_rule('/request/', f"root_request_slash",
|
||||
view_func=app.view_functions[rule.endpoint],
|
||||
methods=rule.methods)
|
||||
else:
|
||||
app.add_url_rule(f"/request{base_rule}",
|
||||
f"{rule.endpoint}_request",
|
||||
view_func=app.view_functions[rule.endpoint],
|
||||
methods=rule.methods)
|
||||
app.add_url_rule(f"/request{base_rule}/",
|
||||
f"{rule.endpoint}_request_slash",
|
||||
view_func=app.view_functions[rule.endpoint],
|
||||
methods=rule.methods)
|
||||
app.jinja_env.globals['url_for'] = url_for_with_request
|
||||
|
||||
def url_for_with_request(endpoint, **values):
|
||||
"""Generate URLs with /request prefix by default."""
|
||||
if endpoint == 'static':
|
||||
# For static files, add /request prefix
|
||||
url = flask_url_for(endpoint, **values)
|
||||
return f"/request{url}"
|
||||
return flask_url_for(endpoint, **values)
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""
|
||||
Render main page with search and status table.
|
||||
"""
|
||||
return render_template('index.html')
|
||||
|
||||
@app.route('/favico<path:favicon_path>')
|
||||
@app.route('/request/favico<path:favicon_path>')
|
||||
@app.route('/request/static/favico<path:favicon_path>')
|
||||
def favicon(_):
|
||||
return send_from_directory(os.path.join(app.root_path, 'static', 'media'),
|
||||
'favicon.ico', mimetype='image/vnd.microsoft.icon')
|
||||
|
||||
@app.route('/api/search', methods=['GET'])
|
||||
def api_search():
|
||||
"""
|
||||
Search for books matching the provided query.
|
||||
|
||||
Query Parameters:
|
||||
query (str): Search term (ISBN, title, author, etc.)
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON array of matching books or empty array if no query.
|
||||
"""
|
||||
query = request.args.get('query', '')
|
||||
if not query:
|
||||
return jsonify([])
|
||||
|
||||
try:
|
||||
books = backend.search_books(query)
|
||||
return jsonify(books)
|
||||
except Exception as e:
|
||||
logger.error(f"Search error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/info', methods=['GET'])
|
||||
def api_info():
|
||||
"""
|
||||
Get detailed book information.
|
||||
|
||||
Query Parameters:
|
||||
id (str): Book identifier (MD5 hash)
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON object with book details, or an error message.
|
||||
"""
|
||||
book_id = request.args.get('id', '')
|
||||
if not book_id:
|
||||
return jsonify({"error": "No book ID provided"}), 400
|
||||
|
||||
try:
|
||||
book = backend.get_book_info(book_id)
|
||||
if book:
|
||||
return jsonify(book)
|
||||
return jsonify({"error": "Book not found"}), 404
|
||||
except Exception as e:
|
||||
logger.error(f"Info error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/download', methods=['GET'])
|
||||
def api_download():
|
||||
"""
|
||||
Queue a book for download.
|
||||
|
||||
Query Parameters:
|
||||
id (str): Book identifier (MD5 hash)
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON status object indicating success or failure.
|
||||
"""
|
||||
book_id = request.args.get('id', '')
|
||||
if not book_id:
|
||||
return jsonify({"error": "No book ID provided"}), 400
|
||||
|
||||
try:
|
||||
success = backend.queue_book(book_id)
|
||||
if success:
|
||||
return jsonify({"status": "queued"})
|
||||
return jsonify({"error": "Failed to queue book"}), 500
|
||||
except Exception as e:
|
||||
logger.error(f"Download error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/status', methods=['GET'])
|
||||
def api_status():
|
||||
"""
|
||||
Get current download queue status.
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON object with queue status.
|
||||
"""
|
||||
try:
|
||||
status = backend.queue_status()
|
||||
return jsonify(status)
|
||||
except Exception as e:
|
||||
logger.error(f"Status error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/localdownload', methods=['GET'])
|
||||
def api_local_download():
|
||||
"""
|
||||
Download an EPUB file from local storage if available.
|
||||
|
||||
Query Parameters:
|
||||
id (str): Book identifier (MD5 hash)
|
||||
|
||||
Returns:
|
||||
flask.Response: The EPUB file if found, otherwise an error response.
|
||||
"""
|
||||
book_id = request.args.get('id', '')
|
||||
if not book_id:
|
||||
return jsonify({"error": "No book ID provided"}), 400
|
||||
|
||||
try:
|
||||
file_data = backend.get_book_data(book_id)
|
||||
if file_data is None:
|
||||
# Book data not found or not available
|
||||
return jsonify({"error": "File not found"}), 404
|
||||
|
||||
file_data, file_name = file_data
|
||||
# Santize the file name
|
||||
file_name = re.sub(r'[\\/:*?"<>|]', '_', file_name.strip())[:255]
|
||||
# Prepare the file for sending to the client
|
||||
epub_file = io.BytesIO(file_data)
|
||||
# Typically EPUB mime-type: 'application/epub+zip'
|
||||
return send_file(
|
||||
epub_file,
|
||||
mimetype='application/epub+zip',
|
||||
download_name=f"{file_name}.epub",
|
||||
as_attachment=True
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Local download error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found_error(error):
|
||||
"""
|
||||
Handle 404 (Not Found) errors.
|
||||
|
||||
Args:
|
||||
error (HTTPException): The 404 error raised by Flask.
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON error message with 404 status.
|
||||
"""
|
||||
logger.warning(f"404 error: {request.url}")
|
||||
return jsonify({"error": "Resource not found"}), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(error):
|
||||
"""
|
||||
Handle 500 (Internal Server) errors.
|
||||
|
||||
Args:
|
||||
error (HTTPException): The 500 error raised by Flask.
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON error message with 500 status.
|
||||
"""
|
||||
logger.error(f"500 error: {error}")
|
||||
return jsonify({"error": "Internal server error"}), 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Register all routes with /request prefix
|
||||
register_dual_routes(app)
|
||||
|
||||
logger.info(f"Starting Flask application on {FLASK_HOST}:{FLASK_PORT}")
|
||||
app.run(
|
||||
host=FLASK_HOST,
|
||||
port=FLASK_PORT,
|
||||
debug=FLASK_DEBUG # Disable debug mode in production
|
||||
)
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Backend logic for the book download application."""
|
||||
|
||||
import threading, time
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
from logger import setup_logger
|
||||
from config import TMP_DIR, MAIN_LOOP_SLEEP_TIME, INGEST_DIR
|
||||
from models import book_queue, BookInfo, QueueStatus
|
||||
import book_manager
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
def search_books(query: str) -> List[Dict[str, Any]]:
|
||||
"""Search for books matching the query.
|
||||
|
||||
Args:
|
||||
query: Search term
|
||||
|
||||
Returns:
|
||||
List[Dict]: List of book information dictionaries
|
||||
"""
|
||||
try:
|
||||
books = book_manager.search_books(query)
|
||||
return [_book_info_to_dict(book) for book in books]
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching books: {e}")
|
||||
return []
|
||||
|
||||
def get_book_info(book_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get detailed information for a specific book.
|
||||
|
||||
Args:
|
||||
book_id: Book identifier
|
||||
|
||||
Returns:
|
||||
Optional[Dict]: Book information dictionary if found
|
||||
"""
|
||||
try:
|
||||
book = book_manager.get_book_info(book_id)
|
||||
return _book_info_to_dict(book)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting book info: {e}")
|
||||
return None
|
||||
|
||||
def queue_book(book_id: str) -> bool:
|
||||
"""Add a book to the download queue.
|
||||
|
||||
Args:
|
||||
book_id: Book identifier
|
||||
|
||||
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}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error queueing book: {e}")
|
||||
return False
|
||||
|
||||
def queue_status() -> Dict[str, Dict[str, Any]]:
|
||||
"""Get current status of the download queue.
|
||||
|
||||
Returns:
|
||||
Dict: Queue status organized by status type
|
||||
"""
|
||||
status = book_queue.get_status()
|
||||
# Convert Enum keys to strings and properly format the response
|
||||
return {
|
||||
status_type.value: books
|
||||
for status_type, books in status.items()
|
||||
}
|
||||
|
||||
def get_book_data(book_id: str) -> Optional[bytes]:
|
||||
"""Get book data for a specific book.
|
||||
|
||||
Args:
|
||||
book_id: Book identifier
|
||||
|
||||
Returns:
|
||||
Optional[bytes]: Book data if available
|
||||
"""
|
||||
try:
|
||||
book_info = book_queue._book_data[book_id]
|
||||
path = INGEST_DIR / f"{book_id}.epub"
|
||||
with open(path, "rb") as f:
|
||||
return f.read(), book_info.title
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting book data: {e}")
|
||||
return None
|
||||
|
||||
def _book_info_to_dict(book: BookInfo) -> Dict[str, Any]:
|
||||
"""Convert BookInfo object to dictionary representation."""
|
||||
return {
|
||||
key: value for key, value in book.__dict__.items()
|
||||
if value is not None
|
||||
}
|
||||
|
||||
def _process_book(book_path: str) -> bool:
|
||||
"""Check if downloaded book is valid.
|
||||
|
||||
Args:
|
||||
book_path: Path to downloaded book file
|
||||
|
||||
Returns:
|
||||
bool: True if book is valid
|
||||
"""
|
||||
try:
|
||||
script_path = Path(__file__).parent / "check_health.sh"
|
||||
result = subprocess.run(
|
||||
[str(script_path), book_path],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
return result.returncode == 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking book health: {e}")
|
||||
return False
|
||||
|
||||
def _download_book(book_id: str) -> bool:
|
||||
"""Download and process a book.
|
||||
|
||||
Args:
|
||||
book_id: Book identifier
|
||||
|
||||
Returns:
|
||||
bool: True if download and processing successful
|
||||
"""
|
||||
try:
|
||||
book_info = book_queue._book_data[book_id]
|
||||
data = book_manager.download_book(book_id, book_info.title)
|
||||
|
||||
if not data:
|
||||
raise Exception("No data received")
|
||||
|
||||
book_path = TMP_DIR / f"{book_id}.{book_info.format}"
|
||||
with open(book_path, "wb") as f:
|
||||
f.write(data.getbuffer())
|
||||
|
||||
return _process_book(str(book_path))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading book: {e}")
|
||||
return False
|
||||
|
||||
def download_loop():
|
||||
"""Background thread for processing download queue."""
|
||||
logger.info("Starting download loop")
|
||||
|
||||
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)
|
||||
success = _download_book(book_id)
|
||||
|
||||
new_status = (
|
||||
QueueStatus.AVAILABLE if success else QueueStatus.ERROR
|
||||
)
|
||||
book_queue.update_status(book_id, new_status)
|
||||
|
||||
logger.info(
|
||||
f"Book {book_id} download {'successful' if success else 'failed'}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in download loop: {e}")
|
||||
book_queue.update_status(book_id, QueueStatus.ERROR)
|
||||
|
||||
# Start download loop in background thread
|
||||
download_thread = threading.Thread(
|
||||
target=download_loop,
|
||||
daemon=True
|
||||
)
|
||||
download_thread.start()
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Book download manager handling search and retrieval operations."""
|
||||
|
||||
import time
|
||||
from urllib.parse import urlparse, quote
|
||||
from typing import List, Optional, Dict
|
||||
from bs4 import BeautifulSoup
|
||||
from io import BytesIO
|
||||
|
||||
from logger import setup_logger
|
||||
from config import SUPPORTED_FORMATS
|
||||
from models import BookInfo
|
||||
import network
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
def search_books(query: str) -> List[BookInfo]:
|
||||
"""Search for books matching the query.
|
||||
|
||||
Args:
|
||||
query: Search term (ISBN, title, author, etc.)
|
||||
|
||||
Returns:
|
||||
List[BookInfo]: List of matching books
|
||||
|
||||
Raises:
|
||||
Exception: If no books found or parsing fails
|
||||
"""
|
||||
query_html = quote(query)
|
||||
url = (
|
||||
f"https://annas-archive.org/search?index=&page=1&display=table"
|
||||
f"&acc=aa_download&acc=external_download&lang=en&sort="
|
||||
f"&ext={'&ext='.join(SUPPORTED_FORMATS)}&lang=en&q={query_html}"
|
||||
)
|
||||
|
||||
html = network.html_get_page(url)
|
||||
if not html:
|
||||
raise Exception("Failed to fetch search results")
|
||||
|
||||
if "No files found." in html:
|
||||
logger.info(f"No books found for query: {query}")
|
||||
raise Exception("No books found. Please try another query.")
|
||||
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
tbody = soup.find('table')
|
||||
|
||||
if not tbody:
|
||||
logger.warning(f"No results table found for query: {query}")
|
||||
raise Exception("No books found. Please try another query.")
|
||||
|
||||
books = []
|
||||
for line_tr in tbody.find_all('tr'):
|
||||
try:
|
||||
book = _parse_search_result_row(line_tr)
|
||||
if book:
|
||||
books.append(book)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to parse search result row: {e}")
|
||||
|
||||
books.sort(
|
||||
key=lambda x: (
|
||||
SUPPORTED_FORMATS.index(x.format)
|
||||
if x.format in SUPPORTED_FORMATS
|
||||
else len(SUPPORTED_FORMATS)
|
||||
)
|
||||
)
|
||||
|
||||
return books
|
||||
|
||||
def _parse_search_result_row(row) -> Optional[BookInfo]:
|
||||
"""Parse a single search result row into a BookInfo object."""
|
||||
try:
|
||||
cells = row.find_all('td')
|
||||
preview_img = cells[0].find('img')
|
||||
preview = preview_img['src'] if preview_img else None
|
||||
|
||||
return BookInfo(
|
||||
id=row.find('a')['href'].split('/')[-1],
|
||||
preview=preview,
|
||||
title=cells[1].find('span').next,
|
||||
author=cells[2].find('span').next,
|
||||
publisher=cells[3].find('span').next,
|
||||
year=cells[4].find('span').next,
|
||||
language=cells[7].find('span').next,
|
||||
format=cells[9].find('span').next.lower(),
|
||||
size=cells[10].find('span').next
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing search result row: {e}")
|
||||
return None
|
||||
|
||||
def get_book_info(book_id: str) -> BookInfo:
|
||||
"""Get detailed information for a specific book.
|
||||
|
||||
Args:
|
||||
book_id: Book identifier (MD5 hash)
|
||||
|
||||
Returns:
|
||||
BookInfo: Detailed book information
|
||||
"""
|
||||
url = f"https://annas-archive.org/md5/{book_id}"
|
||||
html = network.html_get_page(url)
|
||||
|
||||
if not html:
|
||||
raise Exception(f"Failed to fetch book info for ID: {book_id}")
|
||||
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
data = soup.select_one('body > main > div:nth-of-type(1)')
|
||||
|
||||
if not data:
|
||||
raise Exception(f"Failed to parse book info for ID: {book_id}")
|
||||
|
||||
return _parse_book_info_page(data, book_id)
|
||||
|
||||
def _parse_book_info_page(data, book_id: str) -> BookInfo:
|
||||
"""Parse the book info page HTML into a BookInfo object."""
|
||||
preview = data.select_one(
|
||||
'div:nth-of-type(1) > img'
|
||||
)['src']
|
||||
|
||||
# Find the start of book information
|
||||
divs = data.find_all('div')
|
||||
start_div_id = next(
|
||||
(i for i, div in enumerate(divs) if "🔍" in div.text),
|
||||
3
|
||||
)
|
||||
|
||||
format_div = divs[start_div_id - 1].text
|
||||
format_parts = format_div.split(".")
|
||||
if len(format_parts) > 1:
|
||||
format = format_parts[1].split(",")[0].strip().lower()
|
||||
else:
|
||||
format = None
|
||||
|
||||
size = next(
|
||||
(token.strip() for token in format_div.split(",")
|
||||
if token.strip() and token.strip()[0].isnumeric()),
|
||||
None
|
||||
)
|
||||
|
||||
# Extract basic information
|
||||
book_info = BookInfo(
|
||||
id=book_id,
|
||||
preview=preview,
|
||||
title=divs[start_div_id].next,
|
||||
publisher=divs[start_div_id + 1].next,
|
||||
author=divs[start_div_id + 2].next,
|
||||
format=format,
|
||||
size=size
|
||||
)
|
||||
|
||||
# Extract additional metadata
|
||||
info = _extract_book_metadata(divs[start_div_id + 3:])
|
||||
book_info.info = info
|
||||
|
||||
# Set language and year from metadata if available
|
||||
if info.get("Language"):
|
||||
book_info.language = info["Language"][0]
|
||||
if info.get("Year"):
|
||||
book_info.year = info["Year"][0]
|
||||
|
||||
return book_info
|
||||
|
||||
def _extract_book_metadata(metadata_divs) -> Dict[str, List[str]]:
|
||||
"""Extract metadata from book info divs."""
|
||||
info = {}
|
||||
|
||||
# Process the first set of metadata
|
||||
sub_data = metadata_divs[0].find_all('div')
|
||||
for i in range(0, len(sub_data) - 1, 2):
|
||||
key = sub_data[i].next
|
||||
value = sub_data[i + 1].next
|
||||
if key not in info:
|
||||
info[key] = []
|
||||
info[key].append(value)
|
||||
|
||||
# Process the second set of metadata (spans)
|
||||
# Find elements where aria-label="code tabs"
|
||||
meta_spans = []
|
||||
for div in metadata_divs:
|
||||
if div.find_all('div', {'aria-label': 'code tabs'}):
|
||||
meta_spans = div.find_all('span')
|
||||
break
|
||||
for i in range(0, len(meta_spans) - 1, 2):
|
||||
key = meta_spans[i].next
|
||||
value = meta_spans[i + 1].next
|
||||
if key not in info:
|
||||
info[key] = []
|
||||
info[key].append(value)
|
||||
|
||||
# Filter relevant metadata
|
||||
relevant_prefixes = [
|
||||
"ISBN-", "ALTERNATIVE", "ASIN", "Goodreads", "Language", "Year"
|
||||
]
|
||||
return {
|
||||
k.strip(): v for k, v in info.items()
|
||||
if any(k.lower().startswith(prefix.lower()) for prefix in relevant_prefixes)
|
||||
and "filename" not in k.lower()
|
||||
}
|
||||
|
||||
def download_book(book_id: str, title: str) -> Optional[BytesIO]:
|
||||
"""Download a book from available sources.
|
||||
|
||||
Args:
|
||||
book_id: Book identifier (MD5 hash)
|
||||
title: Book title for logging
|
||||
|
||||
Returns:
|
||||
Optional[BytesIO]: Book content buffer if successful
|
||||
"""
|
||||
download_links = [
|
||||
f"https://annas-archive.org/slow_download/{book_id}/0/2",
|
||||
f"https://libgen.li/ads.php?md5={book_id}",
|
||||
f"https://library.lol/fiction/{book_id}",
|
||||
f"https://library.lol/main/{book_id}",
|
||||
f"https://annas-archive.org/slow_download/{book_id}/0/0",
|
||||
f"https://annas-archive.org/slow_download/{book_id}/0/1"
|
||||
]
|
||||
|
||||
for link in download_links:
|
||||
try:
|
||||
download_url = _get_download_url(link, title)
|
||||
if download_url:
|
||||
logger.info(f"Downloading {title} from {download_url}")
|
||||
return network.download_url(download_url)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download from {link}: {e}")
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def _get_download_url(link: str, title: str) -> Optional[str]:
|
||||
"""Extract actual download URL from various source pages."""
|
||||
html = network.html_get_page_cf(link)
|
||||
if not html:
|
||||
return None
|
||||
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
|
||||
if link.startswith("https://z-lib.gs"):
|
||||
download_link = soup.find_all('a', href=True, class_="addDownloadedBook")
|
||||
if download_link:
|
||||
parsed = urlparse(download_link[0]['href'])
|
||||
return f"{parsed.scheme}://{parsed.netloc}{download_link[0]['href']}"
|
||||
|
||||
elif link.startswith("https://libgen.li"):
|
||||
get_section = soup.find_all('h2', string="GET")
|
||||
if get_section:
|
||||
href = get_section[0].parent['href']
|
||||
parsed = urlparse(href)
|
||||
return f"{parsed.scheme}://{parsed.netloc}/{href}"
|
||||
|
||||
elif link.startswith("https://library.lol/fiction/"):
|
||||
get_section = soup.find_all('h2', string="GET")
|
||||
if get_section:
|
||||
return get_section[0].parent['href']
|
||||
|
||||
elif link.startswith("https://annas-archive.org/slow_download/"):
|
||||
download_links = soup.find_all('a', href=True, string="📚 Download now")
|
||||
if not download_links:
|
||||
countdown = soup.find_all('span', class_="js-partner-countdown")
|
||||
if countdown:
|
||||
sleep_time = int(countdown[0].text)
|
||||
logger.info(f"Waiting {sleep_time}s for {title}")
|
||||
time.sleep(sleep_time + 5)
|
||||
return _get_download_url(link, title)
|
||||
else:
|
||||
return download_links[0]['href']
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/bin/bash
|
||||
OUTPUTFOLDER=${TMP_DIR:-/tmp/cwa-book-downloader}
|
||||
mkdir -p $TMP_DIR
|
||||
|
||||
OUTPUTFOLDER=${INGEST_DIR:-/cwa-book-ingest}
|
||||
mkdir -p $OUTPUTFOLDER
|
||||
|
||||
# Get a list of files to process
|
||||
|
||||
# Check if a file was supplied through command line arguments
|
||||
if [ "$#" -gt 0 ]; then
|
||||
files=("$@")
|
||||
else
|
||||
files=($TMP_DIR*)
|
||||
fi
|
||||
|
||||
# Total number of files
|
||||
total_files=${#files[@]}
|
||||
|
||||
|
||||
good=0
|
||||
bad=0
|
||||
manual=0
|
||||
|
||||
|
||||
# Process files in the 'downloads' directory
|
||||
for file in "${files[@]}"; do
|
||||
|
||||
# Skip if it's not a regular file
|
||||
[ -f "$file" ] || continue
|
||||
|
||||
# Extract filename and extension
|
||||
filenamewithext="${file##*/}"
|
||||
filename="${filenamewithext%.*}"
|
||||
fileextension="${filenamewithext##*.}"
|
||||
|
||||
case "$fileextension" in
|
||||
epub)
|
||||
# Check if the EPUB file is a valid archive
|
||||
7z t "$file" >/dev/null 2>&1;
|
||||
exit_code=$?
|
||||
if [ "$exit_code" -eq 0 ] || [ "$exit_code" -eq 1 ]; then
|
||||
mv "$file" "$OUTPUTFOLDER/$filenamewithext"
|
||||
good=$((good + 1))
|
||||
else
|
||||
ebook-convert "$file" /tmp/tmpepub.epub >/dev/null 2>&1
|
||||
exit_code=$?
|
||||
rm -f /tmp/tmpepub.epub
|
||||
if [ "$exit_code" -eq 0 ]; then
|
||||
mv "$file" "$OUTPUTFOLDER/$filenamewithext"
|
||||
good=$((good + 1))
|
||||
else
|
||||
rm "$file"
|
||||
bad=$((bad + 1))
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
mobi|azw3|fb2|djvu|cbz|cbr)
|
||||
# Attempt to convert the file to EPUB
|
||||
ebook-convert "$file" "$OUTPUTFOLDER/$filename.epub" >/dev/null 2>&1
|
||||
if [ "$exit_code" -eq 0 ]; then
|
||||
good=$((good + 1))
|
||||
else
|
||||
bad=$((bad + 1))
|
||||
fi
|
||||
rm "$file"
|
||||
;;
|
||||
*)
|
||||
# Move other files to the 'other' directory
|
||||
rm "$file"
|
||||
bad=$((manual + 1))
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Move to a new line after the progress bar completes
|
||||
echo
|
||||
|
||||
echo "Out of $total_files, $good are good, $bad are corrupt and $manual need manual inspection"
|
||||
|
||||
if [ "$bad" -gt 0 ]; then
|
||||
exit 2
|
||||
fi
|
||||
if [ "$manual" -gt 0 ]; then
|
||||
exut 1
|
||||
fi
|
||||
exit 0
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Configuration settings for the book downloader application."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Directory settings
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
LOG_DIR = "/var/logs"
|
||||
LOG_DIR = Path(LOG_DIR)
|
||||
|
||||
TMP_DIR = os.getenv("TMP_DIR", "/tmp/cwa-book-downloader")
|
||||
TMP_DIR = Path(TMP_DIR)
|
||||
|
||||
INGEST_DIR = os.getenv("INGEST_DIR", "/cwa-book-ingest")
|
||||
INGEST_DIR = Path(INGEST_DIR)
|
||||
STATUS_TIMEOUT = int(os.getenv("STATUS_TIMEOUT", 3600))
|
||||
|
||||
# Create necessary directories
|
||||
TMP_DIR.mkdir(exist_ok=True)
|
||||
LOG_DIR.mkdir(exist_ok=True)
|
||||
INGEST_DIR.mkdir(exist_ok=True)
|
||||
|
||||
# Network settings
|
||||
MAX_RETRY = int(os.getenv("MAX_RETRY", 3))
|
||||
DEFAULT_SLEEP = int(os.getenv("DEFAULT_SLEEP", 5))
|
||||
CLOUDFLARE_PROXY = os.getenv("CLOUDFLARE_PROXY_URL", "http://localhost:8000")
|
||||
|
||||
# File format settings
|
||||
SUPPORTED_FORMATS = os.getenv("SUPPORTED_FORMATS", "epub,mobi,azw3,fb2,djvu,cbz,cbr")
|
||||
SUPPORTED_FORMATS = SUPPORTED_FORMATS.split(",")
|
||||
|
||||
# API settings
|
||||
FLASK_HOST = os.getenv("FLASK_HOST", "0.0.0.0")
|
||||
FLASK_PORT = int(os.getenv("FLASK_PORT", 5003))
|
||||
FLASK_DEBUG = os.getenv("FLASK_DEBUG", "False").lower() == "true"
|
||||
|
||||
# Logging settings
|
||||
LOG_FILE = f"{LOG_DIR}/cwa-bookd-ownloader.log"
|
||||
MAIN_LOOP_SLEEP_TIME = int(os.getenv("MAIN_LOOP_SLEEP_TIME", 5))
|
||||
@@ -0,0 +1,26 @@
|
||||
services:
|
||||
calibre-web-automated-book-downloader:
|
||||
image: ghcr.io/calibrain/calibre-web-automated-book-downloader:latest
|
||||
environment:
|
||||
FLASK_PORT: 8084
|
||||
FLASK_DEBUG: false
|
||||
CLOUDFLARE_PROXY_URL: http://cloudflarebypassforscraping:8000
|
||||
INGEST_DIR: /cwa-book-ingest
|
||||
ports:
|
||||
- "${FLASK_PORT:-8084}:${FLASK_PORT:-8084}"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:${FLASK_PORT:-8084}/request/api/status"]
|
||||
interval: 30s
|
||||
timeout: 30s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
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:${INGEST_DIR:-/cwa-book-ingest}
|
||||
|
||||
cloudflarebypassforscraping:
|
||||
image: ghcr.io/sarperavci/cloudflarebypassforscraping:latest
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Centralized logging configuration for the book downloader application."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from config import FLASK_DEBUG
|
||||
|
||||
def setup_logger(name: str, log_file: str = None) -> logging.Logger:
|
||||
"""Set up and configure a logger instance.
|
||||
|
||||
Args:
|
||||
name: The name of the logger instance
|
||||
log_file: Optional path to log file. If None, logs only to stdout/stderr
|
||||
|
||||
Returns:
|
||||
logging.Logger: Configured logger instance
|
||||
"""
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
# Console handler for Docker output
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setFormatter(formatter)
|
||||
if FLASK_DEBUG:
|
||||
console_handler.setLevel(logging.DEBUG)
|
||||
else:
|
||||
console_handler.setLevel(logging.INFO)
|
||||
console_handler.addFilter(lambda record: record.levelno < logging.ERROR) # Only allow logs below ERROR
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# Error handler for stderr
|
||||
error_handler = logging.StreamHandler(sys.stderr)
|
||||
error_handler.setLevel(logging.ERROR)
|
||||
error_handler.setFormatter(formatter)
|
||||
logger.addHandler(error_handler)
|
||||
|
||||
# File handler if log file is specified
|
||||
if log_file:
|
||||
file_handler = RotatingFileHandler(
|
||||
log_file,
|
||||
maxBytes=10485760, # 10MB
|
||||
backupCount=5
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
return logger
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Data structures and models used across the application."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional
|
||||
from enum import Enum
|
||||
from config import INGEST_DIR, STATUS_TIMEOUT
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
class QueueStatus(str, Enum):
|
||||
"""Enum for possible book queue statuses."""
|
||||
QUEUED = "queued"
|
||||
DOWNLOADING = "downloading"
|
||||
AVAILABLE = "available"
|
||||
ERROR = "error"
|
||||
DONE = "done"
|
||||
|
||||
@dataclass
|
||||
class BookInfo:
|
||||
"""Data class representing book information."""
|
||||
id: str
|
||||
title: str
|
||||
preview: Optional[str] = None
|
||||
author: Optional[str] = None
|
||||
publisher: Optional[str] = None
|
||||
year: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
format: Optional[str] = None
|
||||
size: Optional[str] = None
|
||||
info: Optional[Dict[str, List[str]]] = None
|
||||
|
||||
class BookQueue:
|
||||
"""Thread-safe book queue manager."""
|
||||
def __init__(self):
|
||||
from threading import Lock
|
||||
self._queue = set()
|
||||
self._lock = Lock()
|
||||
self._status = {}
|
||||
self._book_data = {}
|
||||
self._status_timestamps = {} # Track when each status was last updated
|
||||
self._status_timeout = timedelta(seconds=STATUS_TIMEOUT) # 1 hour timeout
|
||||
|
||||
def add(self, book_id: str, book_data: BookInfo) -> None:
|
||||
"""Add a book to the queue."""
|
||||
with self._lock:
|
||||
self._queue.add(book_id)
|
||||
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 _update_status(self, book_id: str, status: QueueStatus) -> None:
|
||||
"""Internal method to update status and timestamp."""
|
||||
self._status[book_id] = status
|
||||
self._status_timestamps[book_id] = datetime.now()
|
||||
|
||||
def update_status(self, book_id: str, status: QueueStatus) -> None:
|
||||
"""Update status of a book in the queue."""
|
||||
with self._lock:
|
||||
self._update_status(book_id, status)
|
||||
|
||||
def get_status(self) -> Dict[str, Dict[str, BookInfo]]:
|
||||
"""Get current queue status."""
|
||||
self.refresh()
|
||||
with self._lock:
|
||||
result = {status: {} for status in QueueStatus}
|
||||
for book_id, status in self._status.items():
|
||||
if book_id in self._book_data:
|
||||
result[status][book_id] = self._book_data[book_id]
|
||||
return result
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Remove any books that are done downloading or have stale status."""
|
||||
with self._lock:
|
||||
current_time = datetime.now()
|
||||
|
||||
# Create a list of items to remove to avoid modifying dict during iteration
|
||||
to_remove = []
|
||||
|
||||
for book_id, status in self._status.items():
|
||||
# Check for completed downloads
|
||||
if status == QueueStatus.AVAILABLE:
|
||||
path = INGEST_DIR / f"{book_id}.epub"
|
||||
if not path.exists():
|
||||
self._update_status(book_id, QueueStatus.DONE)
|
||||
|
||||
# Check for stale status entries
|
||||
last_update = self._status_timestamps.get(book_id)
|
||||
if last_update and (current_time - last_update) > self._status_timeout:
|
||||
# Don't remove DONE status
|
||||
if status == QueueStatus.DONE:
|
||||
to_remove.append(book_id)
|
||||
|
||||
# Remove stale entries
|
||||
for book_id in to_remove:
|
||||
del self._status[book_id]
|
||||
del self._status_timestamps[book_id]
|
||||
if book_id in self._book_data:
|
||||
del self._book_data[book_id]
|
||||
|
||||
def set_status_timeout(self, hours: int) -> None:
|
||||
"""Set the status timeout duration in hours."""
|
||||
with self._lock:
|
||||
self._status_timeout = timedelta(hours=hours)
|
||||
|
||||
|
||||
# Global instance of BookQueue
|
||||
book_queue = BookQueue()
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Network operations manager for the book downloader application."""
|
||||
|
||||
import requests
|
||||
import time
|
||||
from io import BytesIO
|
||||
import urllib.request
|
||||
from typing import Optional
|
||||
|
||||
from logger import setup_logger
|
||||
from config import MAX_RETRY, DEFAULT_SLEEP, CLOUDFLARE_PROXY
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
def setup_urllib_opener():
|
||||
"""Configure urllib opener with appropriate headers."""
|
||||
opener = urllib.request.build_opener()
|
||||
opener.addheaders = [
|
||||
('User-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
|
||||
'AppleWebKit/537.36 (KHTML, like Gecko) '
|
||||
'Chrome/129.0.0.0 Safari/537.3')
|
||||
]
|
||||
urllib.request.install_opener(opener)
|
||||
|
||||
setup_urllib_opener()
|
||||
|
||||
def html_get_page(url: str, retry: int = MAX_RETRY, skip_404: bool = False) -> Optional[str]:
|
||||
"""Fetch HTML content from a URL with retry mechanism.
|
||||
|
||||
Args:
|
||||
url: Target URL
|
||||
retry: Number of retry attempts
|
||||
skip_404: Whether to skip 404 errors
|
||||
|
||||
Returns:
|
||||
str: HTML content if successful, None otherwise
|
||||
"""
|
||||
try:
|
||||
logger.info(f"GET: {url}")
|
||||
response = requests.get(url)
|
||||
|
||||
if skip_404 and response.status_code == 404:
|
||||
logger.warning(f"404 error for URL: {url}")
|
||||
return None
|
||||
|
||||
response.raise_for_status()
|
||||
time.sleep(1)
|
||||
return response.text
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
if retry == 0:
|
||||
logger.error(f"Failed to fetch page: {url}, error: {e}")
|
||||
return None
|
||||
|
||||
sleep_time = DEFAULT_SLEEP * (MAX_RETRY - retry + 1)
|
||||
logger.warning(
|
||||
f"Retrying GET {url} in {sleep_time} seconds due to error: {e}"
|
||||
)
|
||||
time.sleep(sleep_time)
|
||||
return html_get_page(url, retry - 1)
|
||||
|
||||
def html_get_page_cf(url: str, retry: int = MAX_RETRY) -> Optional[str]:
|
||||
"""Fetch HTML content through Cloudflare proxy.
|
||||
|
||||
Args:
|
||||
url: Target URL
|
||||
retry: Number of retry attempts
|
||||
|
||||
Returns:
|
||||
str: HTML content if successful, None otherwise
|
||||
"""
|
||||
try:
|
||||
logger.info(f"GET_CF: {url}")
|
||||
response = requests.get(
|
||||
f"{CLOUDFLARE_PROXY}//html?url={url}&retries=3"
|
||||
)
|
||||
time.sleep(1)
|
||||
return response.text
|
||||
|
||||
except Exception as e:
|
||||
if retry == 0:
|
||||
logger.error(f"Failed to fetch page through CF: {url}, error: {e}")
|
||||
return None
|
||||
|
||||
sleep_time = DEFAULT_SLEEP * (MAX_RETRY - retry + 1)
|
||||
logger.warning(
|
||||
f"Retrying GET_CF {url} in {sleep_time} seconds due to error: {e}"
|
||||
)
|
||||
time.sleep(sleep_time)
|
||||
return html_get_page_cf(url, retry - 1)
|
||||
|
||||
def download_url(link: str) -> Optional[BytesIO]:
|
||||
"""Download content from URL into a BytesIO buffer.
|
||||
|
||||
Args:
|
||||
link: URL to download from
|
||||
|
||||
Returns:
|
||||
BytesIO: Buffer containing downloaded content if successful
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Downloading from: {link}")
|
||||
response = requests.get(link, stream=True)
|
||||
response.raise_for_status()
|
||||
|
||||
buffer = BytesIO()
|
||||
buffer.write(response.content)
|
||||
return buffer
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to download from {link}: {e}")
|
||||
return None
|
||||
@@ -0,0 +1,124 @@
|
||||
# 📚 Calibre-Web-Automated-Book-Downloader
|
||||
|
||||

|
||||
|
||||
An intuitive web interface for searching and requesting book downloads, designed to work seamlessly with [Calibre-Web-Automated](https://github.com/crocodilestick/Calibre-Web-Automated). This project streamlines the process of downloading books and preparing them for integration into your Calibre library.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🌐 User-friendly web interface for book search and download
|
||||
- 🔄 Automated download to your specified ingest folder
|
||||
- 🔌 Seamless integration with Calibre-Web-Automated
|
||||
- 📖 Support for multiple book formats (epub, mobi, azw3, fb2, djvu, cbz, cbr)
|
||||
- 🛡️ Cloudflare bypass capability for reliable downloads
|
||||
- 🐳 Docker-based deployment for quick setup
|
||||
|
||||
## 🖼️ Screenshots
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Docker
|
||||
- Docker Compose
|
||||
- A running instance of [Calibre-Web-Automated](https://github.com/crocodilestick/Calibre-Web-Automated) (recommended)
|
||||
|
||||
### Installation Steps
|
||||
1. Get the docker-compose.yml:
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/calibrain/Calibre-Web-Automated-BookDownloader/main/docker-compose.yml
|
||||
```
|
||||
|
||||
2. Start the service:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
3. Access the web interface at `http://localhost:8084`
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
#### Application Settings
|
||||
| Variable | Description | Default Value |
|
||||
|----------|-------------|---------------|
|
||||
| `FLASK_PORT` | Web interface port | `8084` |
|
||||
| `FLASK_DEBUG` | Debug mode toggle | `false` |
|
||||
| `FLASK_HOST` | Web interface binding | `0.0.0.0` |
|
||||
| `INGEST_DIR` | Book download directory | `/cwa-book-ingest` |
|
||||
|
||||
#### Download Settings
|
||||
| Variable | Description | Default Value |
|
||||
|----------|-------------|---------------|
|
||||
| `MAX_RETRY` | Maximum retry attempts | `3` |
|
||||
| `DEFAULT_SLEEP` | Retry delay (seconds) | `5` |
|
||||
| `MAIN_LOOP_SLEEP_TIME` | Processing loop delay (seconds) | `5` |
|
||||
| `SUPPORTED_FORMATS` | Supported book formats | `epub,mobi,azw3,fb2,djvu,cbz,cbr` |
|
||||
|
||||
Note that PDF are NOT supported at the moment (they do not get ingested by CWA, but if you want to just dowload them loclaly, you can add `pdf` to the `SUPPORTED_FORMATS` env
|
||||
|
||||
#### Network Settings
|
||||
| Variable | Description | Default Value |
|
||||
|----------|-------------|---------------|
|
||||
| `CLOUDFLARE_PROXY_URL` | Cloudflare bypass service URL | `http://localhost:8000` |
|
||||
| `PORT` | Container external port | `8084` |
|
||||
|
||||
### Volume Configuration
|
||||
```yaml
|
||||
volumes:
|
||||
- /your/local/path:/cwa-book-ingest
|
||||
```
|
||||
Mount should align with your Calibre-Web-Automated ingest folder.
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
The application consists of two key services:
|
||||
1. **calibre-web-automated-bookdownloader**: Main application providing web interface and download functionality
|
||||
2. **cloudflarebypassforscraping**: Support service for handling Cloudflare-protected websites
|
||||
|
||||
## 🏥 Health Monitoring
|
||||
|
||||
Built-in health checks monitor:
|
||||
- Web interface availability
|
||||
- Download service status
|
||||
- Cloudflare bypass service connection
|
||||
|
||||
Checks run every 30 seconds with a 30-second timeout and 3 retries.
|
||||
|
||||
## 📝 Logging
|
||||
|
||||
Logs are available in:
|
||||
- Container: `/var/logs/calibre-web-automated-bookdownloader.log`
|
||||
- Docker logs: Access via `docker logs`
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Feel free to submit a Pull Request.
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## ⚠️ Important Disclaimers
|
||||
|
||||
### Copyright Notice
|
||||
While this tool can access various sources including those that might contain copyrighted material (e.g., Anna's Archive), it is designed for legitimate use only. Users are responsible for:
|
||||
- Ensuring they have the right to download requested materials
|
||||
- Respecting copyright laws and intellectual property rights
|
||||
- Using the tool in compliance with their local regulations
|
||||
|
||||
### Duplicate Downloads Warning
|
||||
Please note that the current version:
|
||||
- Does not check for existing files in the download directory
|
||||
- Does not verify if books already exist in your Calibre database
|
||||
- Exercise caution when requesting multiple books to avoid duplicates
|
||||
|
||||
## 💬 Support
|
||||
|
||||
For issues or questions, please file an issue on the GitHub repository.
|
||||
@@ -0,0 +1,4 @@
|
||||
flask
|
||||
requests
|
||||
beautifulsoup4
|
||||
tqdm
|
||||
@@ -0,0 +1,361 @@
|
||||
/* Base styles and CSS reset */
|
||||
:root {
|
||||
--primary-color: #0073e6;
|
||||
--primary-dark: #005bb5;
|
||||
--text-color: #333;
|
||||
--background-color: #fdfdfd;
|
||||
--border-color: #ccc;
|
||||
--header-bg: #333;
|
||||
--header-text: #fff;
|
||||
--loading-overlay: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--text-color);
|
||||
background: var(--background-color);
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
header {
|
||||
background: var(--header-bg);
|
||||
color: var(--header-text);
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
margin-top: 2rem;
|
||||
background: var(--header-bg);
|
||||
color: var(--header-text);
|
||||
}
|
||||
|
||||
/* Search Section */
|
||||
.search-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.search-container input {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.search-container input:focus {
|
||||
border-color: var(--primary-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-container button,
|
||||
.download-button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.download-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-container button:hover,
|
||||
.download-button:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
/* Table Styles */
|
||||
.table-responsive {
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: white;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
text-align: left;
|
||||
justify-content:center;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f5f5f5;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.details-button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #27ae60;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background-color 0.3s ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.details-button:hover {
|
||||
background: #1f894b;
|
||||
}
|
||||
|
||||
/* Results Section */
|
||||
.results-section {
|
||||
margin-bottom: 2rem;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.results-heading {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.collapsed .toggle-icon {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.results-content {
|
||||
transition: max-height 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.collapsed .results-content {
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
/* Loading Indicator */
|
||||
.loading-indicator {
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--primary-color);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--loading-overlay);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-overlay.active {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.details-container {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 4px;
|
||||
max-width: 800px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.details-header {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.details-header img {
|
||||
max-width: 200px;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.details-info h3 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.details-info p {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.details-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.details-actions button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.details-actions button:first-child {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.details-actions button:last-child {
|
||||
background: #f5f5f5;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.details-actions button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.search-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.details-header {
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.details-header img {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.details-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Accessibility */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Status indicators */
|
||||
.status-queued {
|
||||
color: #f39c12;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-downloading {
|
||||
color: #3498db;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-available {
|
||||
color: #27ae60;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: #e74c3c;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-done {
|
||||
color: black;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Status table specific styles */
|
||||
#status-table img {
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
#status-table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #e74c3c;
|
||||
text-align: center;
|
||||
padding: 1rem !important;
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
// Main application JavaScript
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// DOM Elements
|
||||
const elements = {
|
||||
searchInput: document.getElementById('search-input'),
|
||||
searchButton: document.getElementById('search-button'),
|
||||
resultsSection: document.getElementById('results-section'),
|
||||
resultsHeading: document.getElementById('results-heading'),
|
||||
resultsTable: document.getElementById('results-table'),
|
||||
resultsTableBody: document.querySelector('#results-table tbody'),
|
||||
searchLoading: document.getElementById('search-loading'),
|
||||
statusLoading: document.getElementById('status-loading'),
|
||||
statusTable: document.getElementById('status-table'),
|
||||
statusTableBody: document.querySelector('#status-table tbody'),
|
||||
modalOverlay: document.getElementById('modal-overlay'),
|
||||
detailsContainer: document.getElementById('details-container')
|
||||
};
|
||||
|
||||
// State
|
||||
let currentBookDetails = null;
|
||||
const STATE = {
|
||||
isSearching: false,
|
||||
isLoadingDetails: false
|
||||
};
|
||||
|
||||
// Constants
|
||||
const REFRESH_INTERVAL = 60000; // 60 seconds
|
||||
const API_ENDPOINTS = {
|
||||
search: '/request/api/search',
|
||||
info: '/request/api/info',
|
||||
download: '/request/api/download',
|
||||
status: '/request/api/status'
|
||||
};
|
||||
|
||||
// Utility Functions
|
||||
const utils = {
|
||||
debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
},
|
||||
|
||||
showLoading(element) {
|
||||
element.style.display = 'block';
|
||||
},
|
||||
|
||||
hideLoading(element) {
|
||||
element.style.display = 'none';
|
||||
},
|
||||
|
||||
async fetchJson(url, options = {}) {
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Fetch error:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
createElement(tag, attributes = {}, children = []) {
|
||||
const element = document.createElement(tag);
|
||||
Object.entries(attributes).forEach(([key, value]) => {
|
||||
element[key] = value;
|
||||
});
|
||||
children.forEach(child => {
|
||||
if (typeof child === 'string') {
|
||||
element.appendChild(document.createTextNode(child));
|
||||
} else {
|
||||
element.appendChild(child);
|
||||
}
|
||||
});
|
||||
return element;
|
||||
}
|
||||
};
|
||||
|
||||
// Search Functions
|
||||
const search = {
|
||||
async performSearch(query) {
|
||||
if (STATE.isSearching) return;
|
||||
|
||||
try {
|
||||
STATE.isSearching = true;
|
||||
elements.resultsSection.classList.remove('collapsed');
|
||||
utils.showLoading(elements.searchLoading);
|
||||
|
||||
const data = await utils.fetchJson(
|
||||
`${API_ENDPOINTS.search}?query=${encodeURIComponent(query)}`
|
||||
);
|
||||
|
||||
this.displayResults(data);
|
||||
} catch (error) {
|
||||
this.handleSearchError(error);
|
||||
} finally {
|
||||
STATE.isSearching = false;
|
||||
utils.hideLoading(elements.searchLoading);
|
||||
}
|
||||
},
|
||||
|
||||
displayResults(books) {
|
||||
elements.resultsTableBody.innerHTML = '';
|
||||
|
||||
if (!books.length) {
|
||||
this.displayNoResults();
|
||||
return;
|
||||
}
|
||||
|
||||
books.forEach((book, index) => {
|
||||
const row = this.createBookRow(book, index);
|
||||
elements.resultsTableBody.appendChild(row);
|
||||
});
|
||||
},
|
||||
|
||||
displayNoResults() {
|
||||
const row = utils.createElement('tr', {}, [
|
||||
utils.createElement('td', {
|
||||
colSpan: '10',
|
||||
textContent: 'No results found.'
|
||||
})
|
||||
]);
|
||||
elements.resultsTableBody.appendChild(row);
|
||||
},
|
||||
|
||||
createBookRow(book, index) {
|
||||
return utils.createElement('tr', {}, [
|
||||
utils.createElement('td', { textContent: index + 1 }),
|
||||
this.createPreviewCell(book.preview),
|
||||
utils.createElement('td', { textContent: book.title || 'N/A' }),
|
||||
utils.createElement('td', { textContent: book.author || 'N/A' }),
|
||||
utils.createElement('td', { textContent: book.publisher || 'N/A' }),
|
||||
utils.createElement('td', { textContent: book.year || 'N/A' }),
|
||||
utils.createElement('td', { textContent: book.language || 'N/A' }),
|
||||
utils.createElement('td', { textContent: book.format || 'N/A' }),
|
||||
utils.createElement('td', { textContent: book.size || 'N/A' }),
|
||||
this.createActionCell(book)
|
||||
]);
|
||||
},
|
||||
|
||||
createPreviewCell(previewUrl) {
|
||||
if (!previewUrl) {
|
||||
return utils.createElement('td', { textContent: 'N/A' });
|
||||
}
|
||||
|
||||
const img = utils.createElement('img', {
|
||||
src: previewUrl,
|
||||
alt: 'Book Preview',
|
||||
style: 'max-width: 60px;'
|
||||
});
|
||||
|
||||
return utils.createElement('td', {}, [img]);
|
||||
},
|
||||
|
||||
createActionCell(book) {
|
||||
const buttonDetails = utils.createElement('button', {
|
||||
className: 'details-button',
|
||||
onclick: () => bookDetails.show(book.id)
|
||||
}, [utils.createElement('span', { textContent: 'Details' })]);
|
||||
|
||||
const downloadButton = utils.createElement('button', {
|
||||
className: 'download-button',
|
||||
onclick: () => bookDetails.downloadBook(book)
|
||||
}, [utils.createElement('span', { textContent: 'Download' })]);
|
||||
|
||||
return utils.createElement('td', {}, [buttonDetails, downloadButton]);
|
||||
},
|
||||
|
||||
handleSearchError(error) {
|
||||
console.error('Search error:', error);
|
||||
elements.resultsTableBody.innerHTML = '';
|
||||
const errorRow = utils.createElement('tr', {}, [
|
||||
utils.createElement('td', {
|
||||
colSpan: '10',
|
||||
textContent: 'An error occurred while searching. Please try again.'
|
||||
})
|
||||
]);
|
||||
elements.resultsTableBody.appendChild(errorRow);
|
||||
}
|
||||
};
|
||||
|
||||
// Book Details Functions
|
||||
const bookDetails = {
|
||||
async show(bookId) {
|
||||
if (STATE.isLoadingDetails) return;
|
||||
|
||||
try {
|
||||
STATE.isLoadingDetails = true;
|
||||
modal.open();
|
||||
elements.detailsContainer.innerHTML = '<p>Loading details...</p>';
|
||||
|
||||
const book = await utils.fetchJson(
|
||||
`${API_ENDPOINTS.info}?id=${encodeURIComponent(bookId)}`
|
||||
);
|
||||
|
||||
currentBookDetails = book;
|
||||
this.displayDetails(book);
|
||||
} catch (error) {
|
||||
this.handleDetailsError(error);
|
||||
} finally {
|
||||
STATE.isLoadingDetails = false;
|
||||
}
|
||||
},
|
||||
|
||||
displayDetails(book) {
|
||||
elements.detailsContainer.innerHTML = this.generateDetailsHTML(book);
|
||||
|
||||
// Add event listeners
|
||||
document.getElementById('download-button')
|
||||
.addEventListener('click', () => this.downloadBook(book));
|
||||
document.getElementById('close-details')
|
||||
.addEventListener('click', modal.close);
|
||||
},
|
||||
|
||||
generateDetailsHTML(book) {
|
||||
return `
|
||||
<div class="details-header">
|
||||
<img src="${book.preview || ''}" alt="Book Preview">
|
||||
<div class="details-info">
|
||||
<h3>${book.title || 'No title available'}</h3>
|
||||
<p><strong>Author:</strong> ${book.author || 'N/A'}</p>
|
||||
<p><strong>Publisher:</strong> ${book.publisher || 'N/A'}</p>
|
||||
<p><strong>Year:</strong> ${book.year || 'N/A'}</p>
|
||||
<p><strong>Language:</strong> ${book.language || 'N/A'}</p>
|
||||
<p><strong>Format:</strong> ${book.format || 'N/A'}</p>
|
||||
<p><strong>Size:</strong> ${book.size || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
${this.generateInfoList(book.info)}
|
||||
<div class="details-actions">
|
||||
<button id="download-button">Download</button>
|
||||
<button id="close-details">Close</button>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
generateInfoList(info) {
|
||||
if (!info) return '';
|
||||
|
||||
const listItems = Object.entries(info)
|
||||
.map(([key, values]) => `
|
||||
<li><strong>${key}:</strong> ${values.join(', ')}</li>
|
||||
`)
|
||||
.join('');
|
||||
|
||||
return `<ul class="details-info-list">${listItems}</ul>`;
|
||||
},
|
||||
|
||||
async downloadBook(book) {
|
||||
if (!book) return;
|
||||
|
||||
try {
|
||||
utils.showLoading(elements.searchLoading);
|
||||
await utils.fetchJson(
|
||||
`${API_ENDPOINTS.download}?id=${encodeURIComponent(book.id)}`
|
||||
);
|
||||
|
||||
modal.close();
|
||||
elements.resultsSection.classList.add('collapsed');
|
||||
status.fetch();
|
||||
} catch (error) {
|
||||
console.error('Download error:', error);
|
||||
} finally {
|
||||
utils.hideLoading(elements.searchLoading);
|
||||
}
|
||||
},
|
||||
|
||||
handleDetailsError(error) {
|
||||
console.error('Details error:', error);
|
||||
elements.detailsContainer.innerHTML = `
|
||||
<p>Error loading details. Please try again.</p>
|
||||
<div class="details-actions">
|
||||
<button id="close-details" onclick="modal.close()">Close</button>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('close-details')
|
||||
.addEventListener('click', modal.close);
|
||||
}
|
||||
};
|
||||
|
||||
// Status Functions
|
||||
const status = {
|
||||
async fetch() {
|
||||
try {
|
||||
utils.showLoading(elements.statusLoading);
|
||||
const data = await utils.fetchJson(API_ENDPOINTS.status);
|
||||
this.display(data);
|
||||
} catch (error) {
|
||||
this.handleError(error);
|
||||
} finally {
|
||||
utils.hideLoading(elements.statusLoading);
|
||||
}
|
||||
},
|
||||
|
||||
display(data) {
|
||||
elements.statusTableBody.innerHTML = '';
|
||||
|
||||
// Handle each status type
|
||||
Object.entries(data).forEach(([status, booksInStatus]) => {
|
||||
// If the status section has books
|
||||
if (Object.keys(booksInStatus).length > 0) {
|
||||
// For each book in this status
|
||||
Object.entries(booksInStatus).forEach(([bookId, bookData]) => {
|
||||
this.addStatusRow(status, bookData);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
addStatusRow(status, book) {
|
||||
if (!book.id || !book.title) return;
|
||||
|
||||
const statusCell = utils.createElement('td', {
|
||||
className: `status-${status.toLowerCase()}`,
|
||||
textContent: status
|
||||
});
|
||||
|
||||
let titleElement;
|
||||
if (status.toLowerCase().includes('available')) {
|
||||
titleElement = utils.createElement('a', {
|
||||
href: `/request/api/localdownload?id=${book.id}`,
|
||||
target: '_blank',
|
||||
textContent: book.title || 'N/A'
|
||||
});
|
||||
}
|
||||
else {
|
||||
titleElement = utils.createElement('td', { textContent: book.title || 'N/A' })
|
||||
}
|
||||
|
||||
const row = utils.createElement('tr', {}, [
|
||||
statusCell,
|
||||
utils.createElement('td', { textContent: book.id }),
|
||||
titleElement,
|
||||
this.createPreviewCell(book.preview)
|
||||
]);
|
||||
|
||||
elements.statusTableBody.appendChild(row);
|
||||
},
|
||||
|
||||
createPreviewCell(previewUrl) {
|
||||
const cell = utils.createElement('td');
|
||||
|
||||
if (previewUrl) {
|
||||
const img = utils.createElement('img', {
|
||||
src: previewUrl,
|
||||
alt: 'Book Preview',
|
||||
style: 'max-width: 60px; height: auto;'
|
||||
});
|
||||
cell.appendChild(img);
|
||||
} else {
|
||||
cell.textContent = 'N/A';
|
||||
}
|
||||
|
||||
return cell;
|
||||
},
|
||||
|
||||
handleError(error) {
|
||||
console.error('Status error:', error);
|
||||
elements.statusTableBody.innerHTML = '';
|
||||
|
||||
const errorRow = utils.createElement('tr', {}, [
|
||||
utils.createElement('td', {
|
||||
colSpan: '4',
|
||||
className: 'error-message',
|
||||
textContent: 'Error loading status. Will retry automatically.'
|
||||
})
|
||||
]);
|
||||
|
||||
elements.statusTableBody.appendChild(errorRow);
|
||||
}
|
||||
};
|
||||
|
||||
// Modal Functions
|
||||
const modal = {
|
||||
open() {
|
||||
elements.modalOverlay.classList.add('active');
|
||||
},
|
||||
|
||||
close() {
|
||||
elements.modalOverlay.classList.remove('active');
|
||||
currentBookDetails = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Event Listeners
|
||||
function setupEventListeners() {
|
||||
// Search events
|
||||
elements.searchButton.addEventListener('click', () => {
|
||||
const query = elements.searchInput.value.trim();
|
||||
if (query) search.performSearch(query);
|
||||
});
|
||||
|
||||
elements.searchInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const query = elements.searchInput.value.trim();
|
||||
if (query) search.performSearch(query);
|
||||
}
|
||||
});
|
||||
|
||||
// Results section toggle
|
||||
elements.resultsHeading.addEventListener('click', () => {
|
||||
elements.resultsSection.classList.toggle('collapsed');
|
||||
});
|
||||
|
||||
// Modal close on overlay click
|
||||
elements.modalOverlay.addEventListener('click', (e) => {
|
||||
if (e.target === elements.modalOverlay) {
|
||||
modal.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard accessibility
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && elements.modalOverlay.classList.contains('active')) {
|
||||
modal.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize
|
||||
function init() {
|
||||
setupEventListeners();
|
||||
status.fetch();
|
||||
setInterval(() => status.fetch(), REFRESH_INTERVAL);
|
||||
}
|
||||
|
||||
init();
|
||||
});
|
||||
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,108 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<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="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') }}">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Book Search & Download</h1>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- Search Section -->
|
||||
<section class="search-section">
|
||||
<div class="search-container">
|
||||
<input
|
||||
type="text"
|
||||
id="search-input"
|
||||
placeholder="Search by ISBN, title, author..."
|
||||
aria-label="Search books"
|
||||
>
|
||||
<button id="search-button" aria-label="Search">
|
||||
<span>Search</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Results Section -->
|
||||
<section class="results-section collapsed" id="results-section">
|
||||
<h2 id="results-heading">
|
||||
Search Results
|
||||
<span class="toggle-icon">▼</span>
|
||||
</h2>
|
||||
<div class="loading-indicator" id="search-loading" role="status">
|
||||
<span class="spinner"></span>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
<div class="results-content">
|
||||
<div class="table-responsive">
|
||||
<table id="results-table" role="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">#</th>
|
||||
<th scope="col">Preview</th>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Author</th>
|
||||
<th scope="col">Publisher</th>
|
||||
<th scope="col">Year</th>
|
||||
<th scope="col">Language</th>
|
||||
<th scope="col">Format</th>
|
||||
<th scope="col">Size</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Search results will be injected here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Book Details 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 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Section -->
|
||||
<section class="status-section">
|
||||
<h2>Download Status</h2>
|
||||
<div class="loading-indicator" id="status-loading" role="status">
|
||||
<span class="spinner"></span>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table id="status-table" 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>
|
||||
<!-- Status information will be injected here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>Calibre Web Book Downloader.</p>
|
||||
</footer>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}" defer></script>
|
||||
</body>
|
||||
</html>
|
||||