Innitial Release

This commit is contained in:
CaliBrain
2024-12-16 22:48:26 +00:00
commit 18b146af64
24 changed files with 2263 additions and 0 deletions
@@ -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
+48
View File
@@ -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"]
+21
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 874 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

+233
View File
@@ -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
)
+182
View File
@@ -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()
+269
View File
@@ -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
+87
View File
@@ -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
+39
View File
@@ -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))
+26
View File
@@ -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
+51
View File
@@ -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
+110
View File
@@ -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()
+111
View File
@@ -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
+124
View File
@@ -0,0 +1,124 @@
# 📚 Calibre-Web-Automated-Book-Downloader
![Calibre-Web Automated Book Downloader](static/media/logo.png "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
![Main search interface Screenshot](README_images/search.png "Main search interface")
![Details modal Screenshot placeholder](README_images/details.png "Details modal")
![Download queue Screenshot placeholder](README_images/downloading.png "Download queue")
## 🚀 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.
+4
View File
@@ -0,0 +1,4 @@
flask
requests
beautifulsoup4
tqdm
+361
View File
@@ -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;
}
+436
View File
@@ -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();
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+108
View File
@@ -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>