commit 18b146af645f2dc005e6824b576896ad6b55dba8 Author: CaliBrain Date: Mon Dec 16 22:48:26 2024 +0000 Innitial Release diff --git a/.github/workflows/build-and-publish-docker-image.yml b/.github/workflows/build-and-publish-docker-image.yml new file mode 100644 index 00000000..4df0c1ef --- /dev/null +++ b/.github/workflows/build-and-publish-docker-image.yml @@ -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 + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..8f8d4e0f --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..98430c38 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README_images/details.png b/README_images/details.png new file mode 100644 index 00000000..11749130 Binary files /dev/null and b/README_images/details.png differ diff --git a/README_images/downloading.png b/README_images/downloading.png new file mode 100644 index 00000000..ca1ac327 Binary files /dev/null and b/README_images/downloading.png differ diff --git a/README_images/main_page.png b/README_images/main_page.png new file mode 100644 index 00000000..4b95dfc3 Binary files /dev/null and b/README_images/main_page.png differ diff --git a/README_images/search.png b/README_images/search.png new file mode 100644 index 00000000..c0857dd0 Binary files /dev/null and b/README_images/search.png differ diff --git a/README_images/status.png b/README_images/status.png new file mode 100644 index 00000000..b30ac918 Binary files /dev/null and b/README_images/status.png differ diff --git a/app.py b/app.py new file mode 100644 index 00000000..0ec5c365 --- /dev/null +++ b/app.py @@ -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') +@app.route('/request/favico') +@app.route('/request/static/favico') +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 + ) \ No newline at end of file diff --git a/backend.py b/backend.py new file mode 100644 index 00000000..7e804023 --- /dev/null +++ b/backend.py @@ -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() diff --git a/book_manager.py b/book_manager.py new file mode 100644 index 00000000..40666d28 --- /dev/null +++ b/book_manager.py @@ -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 \ No newline at end of file diff --git a/check_health.sh b/check_health.sh new file mode 100644 index 00000000..9ca7c92a --- /dev/null +++ b/check_health.sh @@ -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 diff --git a/config.py b/config.py new file mode 100644 index 00000000..b82fd0aa --- /dev/null +++ b/config.py @@ -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)) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..db69a279 --- /dev/null +++ b/docker-compose.yml @@ -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 + \ No newline at end of file diff --git a/logger.py b/logger.py new file mode 100644 index 00000000..432d6d8a --- /dev/null +++ b/logger.py @@ -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 \ No newline at end of file diff --git a/models.py b/models.py new file mode 100644 index 00000000..4613c433 --- /dev/null +++ b/models.py @@ -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() \ No newline at end of file diff --git a/network.py b/network.py new file mode 100644 index 00000000..9f3da35b --- /dev/null +++ b/network.py @@ -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 \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 00000000..ed26f7f5 --- /dev/null +++ b/readme.md @@ -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. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..29c3caa6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +flask +requests +beautifulsoup4 +tqdm \ No newline at end of file diff --git a/static/css/styles.css b/static/css/styles.css new file mode 100644 index 00000000..e197d996 --- /dev/null +++ b/static/css/styles.css @@ -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; +} diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 00000000..b7e3b686 --- /dev/null +++ b/static/js/main.js @@ -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 = '

Loading details...

'; + + 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 ` +
+ Book Preview +
+

${book.title || 'No title available'}

+

Author: ${book.author || 'N/A'}

+

Publisher: ${book.publisher || 'N/A'}

+

Year: ${book.year || 'N/A'}

+

Language: ${book.language || 'N/A'}

+

Format: ${book.format || 'N/A'}

+

Size: ${book.size || 'N/A'}

+
+
+ ${this.generateInfoList(book.info)} +
+ + +
+ `; + }, + + generateInfoList(info) { + if (!info) return ''; + + const listItems = Object.entries(info) + .map(([key, values]) => ` +
  • ${key}: ${values.join(', ')}
  • + `) + .join(''); + + return `
      ${listItems}
    `; + }, + + 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 = ` +

    Error loading details. Please try again.

    +
    + +
    + `; + 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(); +}); \ No newline at end of file diff --git a/static/media/favicon.ico b/static/media/favicon.ico new file mode 100644 index 00000000..43dadd3f Binary files /dev/null and b/static/media/favicon.ico differ diff --git a/static/media/logo.png b/static/media/logo.png new file mode 100644 index 00000000..d18ea2fa Binary files /dev/null and b/static/media/logo.png differ diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 00000000..4f9e470e --- /dev/null +++ b/templates/index.html @@ -0,0 +1,108 @@ + + + + + + + + Calibre Web Book Downloader + + + + + +
    +

    Book Search & Download

    +
    + +
    + +
    +
    + + +
    +
    + + + + + + + + +
    +

    Download Status

    +
    + + Loading... +
    +
    + + + + + + + + + + + + +
    StatusBook IDTitlePreview
    +
    +
    +
    + +
    +

    Calibre Web Book Downloader.

    +
    + + + + + \ No newline at end of file