diff --git a/docs/cover-images.md b/docs/cover-images.md new file mode 100644 index 0000000..7921721 --- /dev/null +++ b/docs/cover-images.md @@ -0,0 +1,72 @@ +# Book Cover Images Setup + +HXBooks now supports book cover images using static file storage with nginx/caddy for production. + +## Architecture + +**Development**: Flask serves covers via `/media/covers/` route +**Production**: nginx serves covers directly from filesystem for optimal performance + +## File Structure + +``` +/app/ +├── src/hxbooks/static/ # App assets (CSS, JS) -> served at /static/ +├── media/covers/ # Book covers -> served at /media/ +└── instance/ # Database, config +``` + +## Production Deployment + +### Docker Setup +```bash +# Build and run with nginx + gunicorn +docker-compose up --build + +# Or build manually +docker build -f Dockerfile.production -t hxbooks . +docker run -p 8080:80 -v ./media:/app/media -v ./instance:/app/instance hxbooks +``` + +### nginx Configuration +- Static files: nginx serves `/static/` and `/media/` directly +- Dynamic requests: proxied to gunicorn on port 8080 +- Proper caching headers for performance + +## Cover Image Features + +### CLI +```bash +# Import with cover download (default) +hxbooks book import "9780123456789" + +# Skip cover download +hxbooks book import "9780123456789" --no-cover +``` + +### Web Interface +- Book cards display covers when available +- Fallback to book emoji for books without covers +- Google Books API integration provides cover URLs + +## Storage Details + +- **Images downloaded from**: Google Books API (thumbnail/smallThumbnail) +- **Filename format**: `book_{id}.{extension}` +- **Security**: HTTP URLs converted to HTTPS +- **Fallback**: graceful handling if download fails +- **Database**: `cover_image_path` field stores relative filename + +## Performance + +**nginx serves static files directly** (bypassing Python completely): +- ✅ ~1ms response time for images +- ✅ Proper browser caching +- ✅ Gzip compression +- ✅ No Python memory usage for images + +vs. database BLOB storage through Python: +- ❌ ~50-200ms response time +- ❌ Limited caching options +- ❌ High memory usage +- ❌ Database file bloat \ No newline at end of file diff --git a/migrations/versions/0c155d83c55b_add_cover_image_path_to_book_model.py b/migrations/versions/0c155d83c55b_add_cover_image_path_to_book_model.py new file mode 100644 index 0000000..6224834 --- /dev/null +++ b/migrations/versions/0c155d83c55b_add_cover_image_path_to_book_model.py @@ -0,0 +1,32 @@ +"""Add cover_image_path to Book model + +Revision ID: 0c155d83c55b +Revises: 75e81e4ab7b6 +Create Date: 2026-03-24 20:45:34.613875 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0c155d83c55b' +down_revision = '75e81e4ab7b6' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('book', schema=None) as batch_op: + batch_op.add_column(sa.Column('cover_image_path', sa.String(length=200), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('book', schema=None) as batch_op: + batch_op.drop_column('cover_image_path') + + # ### end Alembic commands ### diff --git a/pyproject.toml b/pyproject.toml index 03e4a70..d9b67c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "flask-sqlalchemy>=3.1.1", "gunicorn>=25.1.0", "jinja2-fragments>=1.11.0", + "pillow>=12.1.1", "pydantic>=2.12.5", "pydantic-extra-types>=2.11.1", "pyparsing>=3.3.2", diff --git a/src/hxbooks/app.py b/src/hxbooks/app.py index aec70a1..c08f170 100644 --- a/src/hxbooks/app.py +++ b/src/hxbooks/app.py @@ -1,3 +1,4 @@ +import logging import os from pathlib import Path @@ -21,6 +22,9 @@ def create_app(test_config: dict | None = None) -> Flask: SQLALCHEMY_DATABASE_URI=f"sqlite:///{PROJECT_ROOT / 'hxbooks.sqlite'}", ) + # Setup logging + _setup_logging(app, test_config is not None) + if test_config is None: # load the instance config, if it exists, when not testing app.config.from_pyfile("config.py", silent=True) @@ -31,6 +35,8 @@ def create_app(test_config: dict | None = None) -> Flask: # ensure the instance folder exists try: os.makedirs(app.instance_path) + # Also create media directories + os.makedirs(os.path.join(PROJECT_ROOT, "media", "covers"), exist_ok=True) except OSError: pass @@ -43,3 +49,37 @@ def create_app(test_config: dict | None = None) -> Flask: app.register_blueprint(main_bp) return app + + +def _setup_logging(app: Flask, is_testing: bool = False) -> None: + """Setup application logging with terminal output.""" + if is_testing: + # Disable logging during tests to reduce noise + logging.getLogger("hxbooks").setLevel(logging.CRITICAL) + return + + # Create logger for the app + logger = logging.getLogger("hxbooks") + logger.setLevel(logging.INFO) + + # Avoid duplicate handlers if create_app is called multiple times + if logger.handlers: + return + + # Create console handler + handler = logging.StreamHandler() + handler.setLevel(logging.INFO) + + # Create formatter + formatter = logging.Formatter( + "[%(asctime)s] %(levelname)s in %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + handler.setFormatter(formatter) + + # Add handler to logger + logger.addHandler(handler) + + # Also configure the root Flask logger for this app + app.logger.setLevel(logging.INFO) + app.logger.addHandler(handler) diff --git a/src/hxbooks/cli.py b/src/hxbooks/cli.py index 3b7a45e..a1fc1f7 100644 --- a/src/hxbooks/cli.py +++ b/src/hxbooks/cli.py @@ -6,7 +6,9 @@ while keeping business logic separate from web interface concerns. """ import json +import logging import sys +from datetime import datetime import click from flask import Flask @@ -16,6 +18,8 @@ from .app import create_app from .db import db from .models import Author, Book, Genre, Reading, User, Wishlist +logger = logging.getLogger(__name__) + def get_app() -> Flask: """Create and configure Flask app for CLI operations.""" @@ -84,6 +88,7 @@ def db_group() -> None: @click.option("--shelf", type=int, help="Shelf number") @click.option("--description", help="Book description") @click.option("--notes", help="Personal notes") +@click.option("--cover-url", help="Cover image URL (http/https/file://)") def add_book( title: str, owner: str, @@ -97,6 +102,7 @@ def add_book( shelf: int | None = None, description: str | None = None, notes: str | None = None, + cover_url: str | None = None, ) -> None: """Add a new book to the library.""" app = get_app() @@ -120,9 +126,11 @@ def add_book( location_shelf=shelf, description=description, notes=notes, + cover_image_url=cover_url, ) click.echo(f"Added book: {book.title} (ID: {book.id})") except Exception as e: + logger.error(f"Error adding book '{title}': {e}", exc_info=True) click.echo(f"Error adding book: {e}", err=True) sys.exit(1) @@ -188,6 +196,7 @@ def get_book(book_id: int) -> None: click.echo(json.dumps(book_info, indent=2)) except Exception as e: + logger.error(f"Error getting book {book_id}: {e}", exc_info=True) click.echo(f"Error getting book: {e}", err=True) sys.exit(1) @@ -206,10 +215,114 @@ def delete_book(book_id: int) -> None: click.echo(f"Book with ID {book_id} not found.") sys.exit(1) except Exception as e: + logger.error(f"Error deleting book {book_id}: {e}", exc_info=True) click.echo(f"Error deleting book: {e}", err=True) sys.exit(1) +@book.command("edit") +@click.argument("book_id", type=int) +@click.option("--title", help="Book title") +@click.option("--authors", help="Comma-separated list of authors") +@click.option("--genres", help="Comma-separated list of genres") +@click.option("--isbn", help="ISBN number") +@click.option("--publisher", help="Publisher name") +@click.option("--edition", help="Edition information") +@click.option("--description", help="Book description") +@click.option("--notes", help="Personal notes") +@click.option("--cover-url", help="Cover image URL (http/https/file://)") +@click.option("--owner", help="Username of book owner") +@click.option("--place", help="Location place") +@click.option("--bookshelf", help="Bookshelf name") +@click.option("--shelf", type=int, help="Shelf number") +@click.option("--year", type=int, help="Publication year") +@click.option("--loaned-to", help="Person book is loaned to") +@click.option( + "--bought-date", type=click.DateTime(), help="Date book was bought (YYYY-MM-DD)" +) +@click.option( + "--loaned-date", type=click.DateTime(), help="Date book was loaned (YYYY-MM-DD)" +) +def edit_book( + book_id: int, + title: str | None = None, + authors: str | None = None, + genres: str | None = None, + isbn: str | None = None, + publisher: str | None = None, + edition: str | None = None, + description: str | None = None, + notes: str | None = None, + cover_url: str | None = None, + owner: str | None = None, + place: str | None = None, + bookshelf: str | None = None, + shelf: int | None = None, + year: int | None = None, + loaned_to: str | None = None, + bought_date: datetime | None = None, + loaned_date: datetime | None = None, +) -> None: + """Edit an existing book's details.""" + app = get_app() + + with app.app_context(): + try: + # Check if book exists + book = library.get_book(book_id) + if not book: + click.echo(f"Book with ID {book_id} not found.", err=True) + sys.exit(1) + + # Get owner ID if provided + owner_id = None + if owner: + owner_id = ensure_user_exists(app, owner) + + # Parse authors and genres + authors_list = [a.strip() for a in authors.split(",")] if authors else None + genres_list = [g.strip() for g in genres.split(",")] if genres else None + + # Convert dates + bought_date_obj = bought_date.date() if bought_date else None + loaned_date_obj = loaned_date.date() if loaned_date else None + + # Update book + updated_book = library.update_book( + book_id=book_id, + title=title, + owner_id=owner_id, + authors=authors_list, + genres=genres_list, + isbn=isbn, + publisher=publisher, + edition=edition, + description=description, + notes=notes, + cover_image_url=cover_url, + location_place=place, + location_bookshelf=bookshelf, + location_shelf=shelf, + first_published=year, + loaned_to=loaned_to, + bought_date=bought_date_obj, + loaned_date=loaned_date_obj, + ) + + if updated_book: + click.echo( + f"Updated book: {updated_book.title} (ID: {updated_book.id})" + ) + else: + click.echo(f"Failed to update book with ID {book_id}") + sys.exit(1) + + except Exception as e: + logger.error(f"Error editing book {book_id}: {e}", exc_info=True) + click.echo(f"Error editing book: {e}", err=True) + sys.exit(1) + + @book.command("list") @click.option("--owner", help="Filter by owner username") @click.option("--place", help="Filter by location place") @@ -276,6 +389,7 @@ def list_books( ) except Exception as e: + logger.error(f"Error listing books: {e}", exc_info=True) click.echo(f"Error listing books: {e}", err=True) sys.exit(1) @@ -361,12 +475,14 @@ def search_books( @click.option("--place", help="Location place") @click.option("--bookshelf", help="Bookshelf name") @click.option("--shelf", type=int, help="Shelf number") +@click.option("--no-cover", is_flag=True, help="Skip downloading cover image") def import_book( isbn: str, owner: str | None = None, place: str | None = None, bookshelf: str | None = None, shelf: int | None = None, + no_cover: bool = False, ) -> None: """Import book data from ISBN using Google Books API.""" app = get_app() @@ -383,6 +499,7 @@ def import_book( location_place=place, location_bookshelf=bookshelf, location_shelf=shelf, + fetch_cover=not no_cover, ) click.echo( f"Imported book: {book.title} by {', '.join(a.name for a in book.authors)} (ID: {book.id})" diff --git a/src/hxbooks/gbooks.py b/src/hxbooks/gbooks.py index 20a8c45..fe099c3 100644 --- a/src/hxbooks/gbooks.py +++ b/src/hxbooks/gbooks.py @@ -1,3 +1,4 @@ +import logging from os import environ from datetime import date, datetime from typing import Any @@ -5,6 +6,9 @@ from typing import Any import requests from pydantic import BaseModel, field_validator +# Get logger for this module +logger = logging.getLogger(__name__) + class GoogleBook(BaseModel): title: str @@ -40,12 +44,29 @@ class GoogleBook(BaseModel): def fetch_google_book_data(isbn: str) -> GoogleBook: - api_key = environ.get("GOOGLE_BOOKS_API_KEY") - req = requests.get( - "https://www.googleapis.com/books/v1/volumes", params={"q": f"isbn:{isbn}", "key": api_key} - ) - req.raise_for_status() - data = req.json() - if data["totalItems"] == 0: - raise ValueError(f"Book with ISBN {isbn} not found") - return GoogleBook.model_validate(data["items"][0]["volumeInfo"]) + """Fetch book data from Google Books API by ISBN.""" + try: + api_key = environ.get("GOOGLE_BOOKS_API_KEY") + logger.info(f"Fetching Google Books data for ISBN: {isbn}") + + req = requests.get( + "https://www.googleapis.com/books/v1/volumes", + params={"q": f"isbn:{isbn}", "key": api_key}, + timeout=10 + ) + req.raise_for_status() + data = req.json() + + if data["totalItems"] == 0: + logger.warning(f"No Google Books data found for ISBN: {isbn}") + raise ValueError(f"Book with ISBN {isbn} not found") + + logger.info(f"Successfully fetched Google Books data for ISBN: {isbn}") + return GoogleBook.model_validate(data["items"][0]["volumeInfo"]) + + except requests.RequestException as e: + logger.error(f"HTTP error fetching Google Books data for ISBN {isbn}: {e}", exc_info=True) + raise ValueError(f"Failed to fetch book data from Google Books: {e}") from e + except Exception as e: + logger.error(f"Unexpected error fetching Google Books data for ISBN {isbn}: {e}", exc_info=True) + raise diff --git a/src/hxbooks/library.py b/src/hxbooks/library.py index 30fadcc..70e3cb7 100644 --- a/src/hxbooks/library.py +++ b/src/hxbooks/library.py @@ -5,11 +5,16 @@ Clean service layer for book management, reading tracking, and wishlist operatio Separated from web interface concerns to enable both CLI and web access. """ +import logging from collections import defaultdict from collections.abc import Sequence from datetime import date, datetime +from pathlib import Path from typing import assert_never +import requests +from flask import current_app +from PIL import Image from sqlalchemy import ColumnElement, Select, and_, func, or_, select from sqlalchemy.orm import InstrumentedAttribute, joinedload @@ -20,6 +25,9 @@ from .gbooks import fetch_google_book_data from .models import Author, Book, Genre, Reading, User, Wishlist from .search import ComparisonOperator, Field, FieldFilter +# Get logger for this module +logger = logging.getLogger(__name__) + def create_book( title: str, @@ -38,6 +46,7 @@ def create_book( bought_date: date | None = None, loaned_to: str | None = None, loaned_date: date | None = None, + cover_image_url: str | None = None, ) -> Book: """Create a new book with the given details.""" book = Book( @@ -71,6 +80,11 @@ def create_book( book.genres.append(genre) db.session.commit() + + # Handle cover image if provided + if cover_image_url: + download_book_cover(book, cover_image_url) + return book @@ -111,6 +125,7 @@ def update_book( bought_date: date | None = None, loaned_to: str | None = None, loaned_date: date | None = None, + cover_image_url: str | None = None, ) -> Book | None: """Update a book with new details.""" book = get_book(book_id) @@ -185,6 +200,14 @@ def update_book( if owner_id is not None or set_all_fields: book.owner_id = owner_id + if cover_image_url is not None or set_all_fields: + if cover_image_url is not None and (stripped := cover_image_url.strip()): + # Only download if URL is different from current + if stripped != book.cover_image_path: + download_book_cover(book, cover_image_url) + else: + remove_book_cover(book) + db.session.commit() return book @@ -557,8 +580,9 @@ def import_book_from_isbn( location_place: str | None = None, location_bookshelf: str | None = None, location_shelf: int | None = None, + fetch_cover: bool = True, ) -> Book: - """Import book data from Google Books API using ISBN.""" + """Import book data from Google Books API using ISBN with optional cover download.""" google_book_data = fetch_google_book_data(isbn) if not google_book_data: raise ValueError(f"No book data found for ISBN: {isbn}") @@ -579,7 +603,7 @@ def import_book_from_isbn( elif isinstance(google_book_data.publishedDate, int): first_published = google_book_data.publishedDate - return create_book( + book = create_book( title=google_book_data.title, owner_id=owner_id, authors=authors, @@ -593,6 +617,20 @@ def import_book_from_isbn( location_shelf=location_shelf, ) + # Download cover if available and requested + if fetch_cover and google_book_data.imageLinks: + # Try thumbnail first, fallback to small image + image_url = google_book_data.imageLinks.get( + "thumbnail" + ) or google_book_data.imageLinks.get("smallThumbnail") + if image_url: + # Replace http with https for security + if image_url.startswith("http://"): + image_url = image_url.replace("http://", "https://", 1) + download_book_cover(book, image_url) + + return book + def get_books_by_location( place: str, bookshelf: str | None = None, shelf: int | None = None @@ -910,3 +948,107 @@ def list_locations() -> dict[str, list[str]]: for location_place, location_bookshelf in result: ret[location_place].append(location_bookshelf) return ret + + +def download_book_cover(book: Book, image_url: str) -> bool: + """Download and save a book cover image from URL or file:// path. + + Supports both HTTP(S) URLs and file:// URLs for local files. + Images larger than 200px vertically are scaled down. + + Returns True if successful, False otherwise. + """ + try: + # Create covers directory if it doesn't exist + covers_dir = Path(current_app.instance_path).parent / "media" / "covers" + covers_dir.mkdir(parents=True, exist_ok=True) + + # Handle file:// URLs + if image_url.startswith("file://"): + source_path = Path(image_url.replace("file://", "")) + if not source_path.exists(): + return False + + # Load image directly from file + with Image.open(source_path) as image: + processed_image = _process_cover_image(image) + + # Generate filename + extension = ".jpg" # Always save as JPEG + filename = f"book_{book.id}{extension}" + cover_path = covers_dir / filename + + # Save processed image + processed_image.save(cover_path, "JPEG", quality=85) + else: + # Handle HTTP(S) URLs + response = requests.get(image_url, timeout=10, stream=True) + response.raise_for_status() + + # Load image from response content + with Image.open(response.raw) as image: + processed_image = _process_cover_image(image) + + # Generate filename + extension = ".jpg" # Always save as JPEG + filename = f"book_{book.id}{extension}" + cover_path = covers_dir / filename + + # Save processed image + processed_image.save(cover_path, "JPEG", quality=85) + + # Update book record + book.cover_image_path = filename + db.session.commit() + + return True + + except Exception as e: + # Log error details but don't fail the book creation + logger.error( + f"Failed to download cover image from {image_url} for book {book.id}: {e}", + exc_info=True, + ) + return False + + +def _process_cover_image(image: Image.Image) -> Image.Image: + """Process a cover image: convert to RGB and scale if needed.""" + # Convert to RGB (handles RGBA, grayscale, etc.) + if image.mode != "RGB": + image = image.convert("RGB") + + # Scale down if height > 200px + width, height = image.size + if height > 200: + # Calculate new dimensions maintaining aspect ratio + new_height = 200 + new_width = int(width * (new_height / height)) + image = image.resize((new_width, new_height), Image.Resampling.LANCZOS) + + return image + + +def remove_book_cover(book: Book) -> bool: + """Remove a book's cover image file and database reference.""" + try: + if not book.cover_image_path: + return True + + # Remove file + covers_dir = Path(current_app.instance_path).parent / "media" / "covers" + cover_path = covers_dir / book.cover_image_path + if cover_path.exists(): + cover_path.unlink() + + # Clear database reference + book.cover_image_path = None + db.session.commit() + + return True + except Exception as e: + logger.error( + f"Failed to remove cover image {book.cover_image_path} for book {book.id}: {e}", + exc_info=True, + ) + return False diff --git a/src/hxbooks/main.py b/src/hxbooks/main.py index d138226..dc9f7a4 100644 --- a/src/hxbooks/main.py +++ b/src/hxbooks/main.py @@ -4,23 +4,29 @@ Main application routes for HXBooks frontend. Provides clean URL structure and integrates with library.py business logic. """ -import traceback +import logging +import os +import tempfile from datetime import date +from pathlib import Path from typing import Annotated, Any, Literal from flask import ( Blueprint, Response, + current_app, flash, g, redirect, render_template, request, + send_from_directory, session, url_for, ) from flask.typing import ResponseReturnValue from pydantic import ( + AnyHttpUrl, BaseModel, BeforeValidator, Field, @@ -38,6 +44,9 @@ from .db import db bp = Blueprint("main", __name__) +# Get logger for this module +logger = logging.getLogger(__name__) + # Pydantic validation models StrOrNone = Annotated[str | None, BeforeValidator(lambda v: v.strip() or None)] @@ -55,6 +64,7 @@ TextareaList = Annotated[ ] DateOrNone = Annotated[date | None, BeforeValidator(lambda v: v.strip() or None)] IntOrNone = Annotated[int | None, BeforeValidator(lambda v: v.strip() or None)] +UrlOrNone = Annotated[AnyHttpUrl | None, BeforeValidator(lambda v: v.strip() or None)] class BookFormData(BaseModel): @@ -73,6 +83,8 @@ class BookFormData(BaseModel): location_shelf: IntOrNone = Field(default=None, ge=1) loaned_to: StripStr = Field(default="") loaned_date: DateOrNone = None + cover_image_url: UrlOrNone = None + delete_cover: bool = Field(default=False) class ReadingFormData(BaseModel): @@ -88,6 +100,7 @@ def _flash_validation_errors(e: ValidationError) -> None: error = e.errors()[0] loc = " -> ".join(str(v) for v in error.get("loc", [])) msg = error.get("msg", "Invalid input") + logger.warning(f"Validation error in '{loc}': {msg} | Full errors: {e.errors()}") flash(f"Validation error in '{loc}': {msg}", "error") @@ -173,9 +186,8 @@ def index() -> ResponseReturnValue: query, limit=RESULTS_PER_PAGE, offset=offset, username=viewing_user ) except Exception as e: + logger.error(f"Search error for query '{query}': {e}", exc_info=True) flash(f"Search error: {e}", "error") - # print traceback for debugging - traceback.print_exc() books, total_count = [], 0 # Calculate pagination info @@ -230,6 +242,27 @@ def _get_or_create_user(username: str) -> int: return owner.id +def _handle_cover_image(form_data: BookFormData) -> str | None: + uploaded_file = request.files.get("cover_image_file") + cover_image_url = None + + if uploaded_file and uploaded_file.filename and uploaded_file.filename.strip(): + # User uploaded a file - replace cover + with tempfile.NamedTemporaryFile( + delete=False, suffix=os.path.splitext(uploaded_file.filename)[1] + ) as tmp_file: + uploaded_file.save(tmp_file.name) + cover_image_url = f"file://{tmp_file.name}" + elif form_data.cover_image_url: + # User entered URL - replace cover + cover_image_url = str(form_data.cover_image_url) + elif form_data.delete_cover: + # User wants to delete cover + cover_image_url = "" + + return cover_image_url + + @bp.route("/book/new", methods=["GET", "POST"]) def create_book() -> ResponseReturnValue: """Create a new book.""" @@ -238,6 +271,9 @@ def create_book() -> ResponseReturnValue: # Validate form data with Pydantic form_data = BookFormData.model_validate(dict(request.form)) + # Handle cover image from file upload or URL + cover_image_url = _handle_cover_image(form_data) + # Get owner ID if provided owner_id = _get_or_create_user(form_data.owner) if form_data.owner else None @@ -258,6 +294,7 @@ def create_book() -> ResponseReturnValue: first_published=form_data.first_published, loaned_to=form_data.loaned_to, loaned_date=form_data.loaned_date, + cover_image_url=cover_image_url, ) flash(f"Book '{form_data.title}' created successfully!", "success") @@ -269,6 +306,7 @@ def create_book() -> ResponseReturnValue: return render_template("book/create.html.j2", form_data=request.form) except Exception as e: + logger.error(f"Error creating book '{form_data.title}': {e}", exc_info=True) flash(f"Error creating book: {e}", "error") return render_template("book/create.html.j2", form_data=request.form) @@ -287,6 +325,11 @@ def update_book(book_id: int) -> ResponseReturnValue: # Validate form data with Pydantic form_data = BookFormData.model_validate(dict(request.form)) + # Handle cover image from file upload or URL + cover_image_url = _handle_cover_image(form_data) + if cover_image_url is None: + cover_image_url = book.cover_image_path # Keep existing if no new input + # Get owner ID if provided owner_id = _get_or_create_user(form_data.owner) if form_data.owner else None @@ -309,6 +352,7 @@ def update_book(book_id: int) -> ResponseReturnValue: first_published=form_data.first_published, loaned_to=form_data.loaned_to, loaned_date=form_data.loaned_date, + cover_image_url=cover_image_url, ) flash("Book updated successfully!", "success") @@ -318,6 +362,7 @@ def update_book(book_id: int) -> ResponseReturnValue: _flash_validation_errors(e) except Exception as e: + logger.error(f"Error updating book '{book_id}': {e}", exc_info=True) flash(f"Error updating book: {e}", "error") return redirect(url_for("main.book_detail", book_id=book_id)) @@ -338,6 +383,7 @@ def delete_book(book_id: int) -> ResponseReturnValue: library.delete_book(book_id) flash(f"Book '{title}' deleted successfully!", "success") except Exception as e: + logger.error(f"Error deleting book '{book_id}': {e}", exc_info=True) flash(f"Error deleting book: {e}", "error") return redirect(url_for("main.index")) @@ -367,6 +413,7 @@ def import_book() -> ResponseReturnValue: return redirect(url_for("main.book_detail", book_id=book.id)) except Exception as e: + logger.error(f"Error importing book with ISBN '{isbn}': {e}", exc_info=True) flash(f"Import error: {e}", "error") return redirect(url_for("main.index")) @@ -432,6 +479,10 @@ def save_search_route() -> ResponseReturnValue: else: flash("Error saving search", "error") except Exception as e: + logger.error( + f"Error saving search '{search_name}' for user '{viewing_user.username}': {e}", + exc_info=True, + ) flash(f"Error saving search: {e}", "error") return redirect(url_for("main.index", q=query_params)) @@ -449,6 +500,10 @@ def start_reading_route(book_id: int) -> ResponseReturnValue: library.start_reading(book_id=book_id, user_id=viewing_user.id) flash("Started reading!", "success") except Exception as e: + logger.error( + f"Error starting reading for book '{book_id}' and user '{viewing_user.username}': {e}", + exc_info=True, + ) flash(f"Error starting reading: {e}", "error") return redirect(url_for("main.book_detail", book_id=book_id)) @@ -476,6 +531,10 @@ def finish_reading_route(book_id: int) -> ResponseReturnValue: library.finish_reading(reading_id=current_reading.id) flash("Finished reading!", "success") except Exception as e: + logger.error( + f"Error finishing reading for book '{book_id}' and user '{viewing_user.username}': {e}", + exc_info=True, + ) flash(f"Error finishing reading: {e}", "error") return redirect(url_for("main.book_detail", book_id=book_id)) @@ -503,6 +562,10 @@ def drop_reading_route(book_id: int) -> ResponseReturnValue: library.drop_reading(reading_id=current_reading.id) flash("Dropped reading", "info") except Exception as e: + logger.error( + f"Error dropping reading for book '{book_id}' and user '{viewing_user.username}': {e}", + exc_info=True, + ) flash(f"Error dropping reading: {e}", "error") return redirect(url_for("main.book_detail", book_id=book_id)) @@ -520,6 +583,10 @@ def add_to_wishlist_route(book_id: int) -> ResponseReturnValue: library.add_to_wishlist(book_id=book_id, user_id=viewing_user.id) flash("Added to wishlist!", "success") except Exception as e: + logger.error( + f"Error adding book '{book_id}' to wishlist for user '{viewing_user.username}': {e}", + exc_info=True, + ) flash(f"Error adding to wishlist: {e}", "error") return redirect(url_for("main.book_detail", book_id=book_id)) @@ -540,6 +607,10 @@ def remove_from_wishlist_route(book_id: int) -> ResponseReturnValue: else: flash("Book was not in wishlist", "warning") except Exception as e: + logger.error( + f"Error removing book '{book_id}' from wishlist for user '{viewing_user.username}': {e}", + exc_info=True, + ) flash(f"Error removing from wishlist: {e}", "error") return redirect(url_for("main.book_detail", book_id=book_id)) @@ -579,6 +650,10 @@ def update_reading_route(book_id: int, reading_id: int) -> ResponseReturnValue: except Exception as e: db.session.rollback() # Rollback any partial changes on general error + logger.error( + f"Error updating reading for book '{book_id}' and user '{viewing_user.username}': {e}", + exc_info=True, + ) flash(f"Error updating reading: {e}", "error") return redirect(url_for("main.book_detail", book_id=book_id)) @@ -604,6 +679,10 @@ def delete_reading_route(book_id: int, reading_id: int) -> ResponseReturnValue: else: flash("Reading session not found or not yours to delete", "error") except Exception as e: + logger.error( + f"Error deleting reading for book '{book_id}' and user '{viewing_user.username}': {e}", + exc_info=True, + ) flash(f"Error deleting reading: {e}", "error") return redirect(url_for("main.book_detail", book_id=book_id)) @@ -632,6 +711,10 @@ def delete_saved_search_route(search_name: str) -> ResponseReturnValue: else: flash("Error deleting saved search", "error") except Exception as e: + logger.error( + f"Error deleting saved search '{search_name}' for user '{viewing_user.username}': {e}", + exc_info=True, + ) flash(f"Error deleting saved search: {e}", "error") return redirect(url_for("main.index")) @@ -641,3 +724,10 @@ def delete_saved_search_route(search_name: str) -> ResponseReturnValue: search_name=search_name, search_params=saved_searches[search_name], ) + + +@bp.route("/media/covers/") +def serve_cover(filename: str) -> ResponseReturnValue: + """Serve book cover images from media directory.""" + media_path = Path(current_app.instance_path).parent / "media" / "covers" + return send_from_directory(media_path, filename) diff --git a/src/hxbooks/models.py b/src/hxbooks/models.py index 4ee0f57..940e870 100644 --- a/src/hxbooks/models.py +++ b/src/hxbooks/models.py @@ -60,6 +60,7 @@ class Book(db.Model): # ty:ignore[unsupported-base] notes: Mapped[str] = mapped_column(default="") added_date: Mapped[datetime] = mapped_column(default=datetime.now) bought_date: Mapped[date | None] = mapped_column(default=None) + cover_image_path: Mapped[str | None] = mapped_column(String(200), default=None) # Location hierarchy location_place: Mapped[str] = mapped_column(String(100), default="") diff --git a/src/hxbooks/templates/book/create.html.j2 b/src/hxbooks/templates/book/create.html.j2 index 42a8e5c..281b622 100644 --- a/src/hxbooks/templates/book/create.html.j2 +++ b/src/hxbooks/templates/book/create.html.j2 @@ -16,7 +16,7 @@
-
+ {% include 'components/book_form.html.j2' %}
diff --git a/src/hxbooks/templates/book/detail.html.j2 b/src/hxbooks/templates/book/detail.html.j2 index 064215f..9024223 100644 --- a/src/hxbooks/templates/book/detail.html.j2 +++ b/src/hxbooks/templates/book/detail.html.j2 @@ -57,10 +57,26 @@
{% endif %} + + +
+ {% if book.cover_image_path %} +
+
+
+ {{ book.title }} cover +
+
+
+ {% endif %} +
+
- + {% include 'components/book_form.html.j2' %}
@@ -74,6 +90,21 @@
+ +
+ {% if book.cover_image_path %} +
+
+
Cover Image
+
+
+ {{ book.title }} cover +
+
+ {% endif %} +
+ {% if session.get('viewing_as_user') %}
diff --git a/src/hxbooks/templates/components/book_card.html.j2 b/src/hxbooks/templates/components/book_card.html.j2 index 287c395..14edc22 100644 --- a/src/hxbooks/templates/components/book_card.html.j2 +++ b/src/hxbooks/templates/components/book_card.html.j2 @@ -1,9 +1,17 @@
- -
+ + {% if book.cover_image_path %} +
+ {{ book.title }} cover +
+ {% else %} +
📖
+ {% endif %}
diff --git a/src/hxbooks/templates/components/book_form.html.j2 b/src/hxbooks/templates/components/book_form.html.j2 index 68d07d3..6a3ec6e 100644 --- a/src/hxbooks/templates/components/book_form.html.j2 +++ b/src/hxbooks/templates/components/book_form.html.j2 @@ -55,6 +55,42 @@ rows="3">{{ book.description if book else '' }}
+ +
+
+ + + {% if book and book.cover_image_path %} +
+ + +
+ {% endif %} + +
+
+ + +
+
+ + +
+
+ +
+ {% if book and book.cover_image_path %} + Leave both fields empty to keep current cover, or check delete to remove it. + {% else %} + Enter a URL or upload a file to add a cover image. + {% endif %} +
+
+
+
diff --git a/uv.lock b/uv.lock index 6e3aff7..f1a7b9a 100644 --- a/uv.lock +++ b/uv.lock @@ -220,6 +220,7 @@ dependencies = [ { name = "flask-sqlalchemy" }, { name = "gunicorn" }, { name = "jinja2-fragments" }, + { name = "pillow" }, { name = "pydantic" }, { name = "pydantic-extra-types" }, { name = "pyparsing" }, @@ -246,6 +247,7 @@ requires-dist = [ { name = "flask-sqlalchemy", specifier = ">=3.1.1" }, { name = "gunicorn", specifier = ">=25.1.0" }, { name = "jinja2-fragments", specifier = ">=1.11.0" }, + { name = "pillow", specifier = ">=12.1.1" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic-extra-types", specifier = ">=2.11.1" }, { name = "pyparsing", specifier = ">=3.3.2" }, @@ -394,6 +396,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "pillow" +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, +] + [[package]] name = "platformdirs" version = "4.9.4"