diff --git a/library_service/models/dto/book.py b/library_service/models/dto/book.py index c8e014a..495944a 100644 --- a/library_service/models/dto/book.py +++ b/library_service/models/dto/book.py @@ -46,7 +46,7 @@ class BookRead(BookBase): id: int = Field(description="Идентификатор") status: BookStatus = Field(description="Статус") - preview_url: str | None = Field(None, description="URL изображения") + preview_urls: dict[str, str] = Field(default_factory=dict, description="URL изображений") class BookList(SQLModel): diff --git a/library_service/models/dto/misc.py b/library_service/models/dto/misc.py index ad89b65..a02d4dc 100644 --- a/library_service/models/dto/misc.py +++ b/library_service/models/dto/misc.py @@ -39,7 +39,7 @@ class BookWithAuthors(SQLModel): description: str = Field(description="Описание") page_count: int = Field(description="Количество страниц") status: BookStatus | None = Field(None, description="Статус") - preview_url: str | None = Field(default=None, description="URL изображения") + preview_urls: dict[str, str] = Field(default_factory=dict, description="URL изображений") authors: List[AuthorRead] = Field( default_factory=list, description="Список авторов" ) @@ -53,7 +53,7 @@ class BookWithGenres(SQLModel): description: str = Field(description="Описание") page_count: int = Field(description="Количество страниц") status: BookStatus | None = Field(None, description="Статус") - preview_url: str | None = Field(default=None, description="URL изображения") + preview_urls: dict[str, str] | None = Field(default=None, description="URL изображений") genres: List[GenreRead] = Field(default_factory=list, description="Список жанров") @@ -65,7 +65,7 @@ class BookWithAuthorsAndGenres(SQLModel): description: str = Field(description="Описание") page_count: int = Field(description="Количество страниц") status: BookStatus | None = Field(None, description="Статус") - preview_url: str | None = Field(default=None, description="URL изображения") + preview_urls: dict[str, str] | None = Field(default=None, description="URL изображений") authors: List[AuthorRead] = Field( default_factory=list, description="Список авторов" ) diff --git a/library_service/routers/books.py b/library_service/routers/books.py index 8b70f3d..bc271d3 100644 --- a/library_service/routers/books.py +++ b/library_service/routers/books.py @@ -1,4 +1,5 @@ """Модуль работы с книгами""" +from library_service.services import transcode_image import shutil from uuid import uuid4 @@ -139,9 +140,11 @@ def create_book( session.refresh(db_book) book_data = db_book.model_dump(exclude={"embedding", "preview_id"}) - if db_book.preview_id: - book_data["preview_url"] = f"/static/books/{db_book.preview_id}.png" - + book_data["preview_urls"] = { + "png": f"/static/books/{db_book.preview_id}.png", + "jpeg": f"/static/books/{db_book.preview_id}.jpg", + "webp": f"/static/books/{db_book.preview_id}.webp", + } if db_book.preview_id else {} return BookRead(**book_data) @@ -158,8 +161,11 @@ def read_books(session: Session = Depends(get_session)): books_data = [] for book in books: book_data = book.model_dump(exclude={"embedding", "preview_id"}) - if book.preview_id: - book_data["preview_url"] = f"/static/books/{book.preview_id}.png" + book_data["preview_urls"] = { + "png": f"/static/books/{book.preview_id}.png", + "jpeg": f"/static/books/{book.preview_id}.jpg", + "webp": f"/static/books/{book.preview_id}.webp", + } if book.preview_id else {} books_data.append(book_data) return BookList( @@ -198,8 +204,11 @@ def get_book( genre_reads = [GenreRead(**genre.model_dump()) for genre in genres] book_data = book.model_dump(exclude={"embedding", "preview_id"}) - if book.preview_id: - book_data["preview_url"] = f"/static/books/{book.preview_id}.png" + book_data["preview_urls"] = { + "png": f"/static/books/{book.preview_id}.png", + "jpeg": f"/static/books/{book.preview_id}.jpg", + "webp": f"/static/books/{book.preview_id}.webp", + } if book.preview_id else {} book_data["authors"] = author_reads book_data["genres"] = genre_reads @@ -259,8 +268,11 @@ def update_book( session.refresh(db_book) book_data = db_book.model_dump(exclude={"embedding", "preview_id"}) - if db_book.preview_id: - book_data["preview_url"] = f"/static/books/{db_book.preview_id}.png" + book_data["preview_urls"] = { + "png": f"/static/books/{db_book.preview_id}.png", + "jpeg": f"/static/books/{db_book.preview_id}.jpg", + "webp": f"/static/books/{db_book.preview_id}.webp", + } if db_book.preview_id else {} return BookRead(**book_data) @@ -300,34 +312,41 @@ async def upload_book_preview( book_id: int = Path(..., gt=0), session: Session = Depends(get_session) ): - if not file.content_type == "image/png": - raise HTTPException(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "PNG required") + if not (file.content_type or "").startswith("image/"): + raise HTTPException(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "Image required") - if (file.size or 0) > 10 * 1024 * 1024: + if (file.size or 0) > 32 * 1024 * 1024: raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "File larger than 10 MB") file_uuid= uuid4() - filename = f"{file_uuid}.png" - file_path = BOOKS_PREVIEW_DIR / filename + tmp_path = BOOKS_PREVIEW_DIR / f"{file_uuid}.upload" - with open(file_path, "wb") as f: + with open(tmp_path, "wb") as f: shutil.copyfileobj(file.file, f) + transcode_image(tmp_path) + book = session.get(Book, book_id) if not book: - file_path.unlink() + tmp_path.unlink() raise HTTPException(status.HTTP_404_NOT_FOUND, "Book not found") if book.preview_id: - old_path = BOOKS_PREVIEW_DIR / f"{book.preview_id}.png" - if old_path.exists(): - old_path.unlink() + for path in BOOKS_PREVIEW_DIR.glob(f"{book.preview_id}.*"): + if path.exists(): + path.unlink(missing_ok=True) book.preview_id = file_uuid session.add(book) session.commit() - return {"preview_url": f"/static/books/{filename}"} + return { + "preview": { + "png": f"/static/books/{file_uuid}.png", + "jpeg": f"/static/books/{file_uuid}.jpg", + "webp": f"/static/books/{file_uuid}.webp", + } + } @router.delete("/{book_id}/preview") @@ -341,12 +360,12 @@ async def remove_book_preview( raise HTTPException(status.HTTP_404_NOT_FOUND, "Book not found") if book.preview_id: - old_path = BOOKS_PREVIEW_DIR / f"{book.preview_id}.png" - if old_path.exists(): - old_path.unlink() + for path in BOOKS_PREVIEW_DIR.glob(f"{book.preview_id}.*"): + if path.exists(): + path.unlink(missing_ok=True) book.preview_id = None session.add(book) session.commit() - return {"preview_url": None} + return {"preview_urls": []} diff --git a/library_service/services/__init__.py b/library_service/services/__init__.py index 934a395..0208e9b 100644 --- a/library_service/services/__init__.py +++ b/library_service/services/__init__.py @@ -13,6 +13,7 @@ from .captcha import ( prng, ) from .describe_er import SchemaGenerator +from .image_processing import transcode_image __all__ = [ "limiter", @@ -28,4 +29,5 @@ __all__ = [ "REDEEM_TTL", "prng", "SchemaGenerator", + "transcode_image", ] diff --git a/library_service/services/image_processing.py b/library_service/services/image_processing.py new file mode 100644 index 0000000..ba94431 --- /dev/null +++ b/library_service/services/image_processing.py @@ -0,0 +1,81 @@ +from pathlib import Path +from PIL import Image + + +TARGET_RATIO = 5 / 7 + + +def crop_image(img: Image.Image, target_ratio: float = TARGET_RATIO) -> Image.Image: + w, h = img.size + current_ratio = w / h + + if current_ratio > target_ratio: + new_w = int(h * target_ratio) + left = (w - new_w) // 2 + right = left + new_w + top = 0 + bottom = h + else: + new_h = int(w / target_ratio) + top = (h - new_h) // 2 + bottom = top + new_h + left = 0 + right = w + + return img.crop((left, top, right, bottom)) + + +def transcode_image( + src_path: str | Path, + *, + jpeg_quality: int = 85, + webp_quality: int = 80, + webp_lossless: bool = False, + resize_to: tuple[int, int] | None = None, +): + src_path = Path(src_path) + + if not src_path.exists(): + raise FileNotFoundError(src_path) + + stem = src_path.stem + folder = src_path.parent + + img = Image.open(src_path).convert("RGBA") + img = crop_image(img) + + if resize_to: + img = img.resize(resize_to, Image.LANCZOS) + + png_path = folder / f"{stem}.png" + img.save( + png_path, + format="PNG", + optimize=True, + interlace=1, + ) + + jpg_path = folder / f"{stem}.jpg" + img.convert("RGB").save( + jpg_path, + format="JPEG", + quality=jpeg_quality, + progressive=True, + optimize=True, + subsampling="4:2:0", + ) + + webp_path = folder / f"{stem}.webp" + img.save( + webp_path, + format="WEBP", + quality=webp_quality, + lossless=webp_lossless, + method=6, + ) + + return { + "png": png_path, + "jpeg": jpg_path, + "webp": webp_path, + } diff --git a/library_service/static/page/book.js b/library_service/static/page/book.js index 926dda5..8536564 100644 --- a/library_service/static/page/book.js +++ b/library_service/static/page/book.js @@ -34,6 +34,7 @@ $(document).ready(() => { const pathParts = window.location.pathname.split("/"); const bookId = parseInt(pathParts[pathParts.length - 1]); + let isDraggingOver = false; let currentBook = null; let cachedUsers = null; let selectedLoanUserId = null; @@ -48,6 +49,28 @@ $(document).ready(() => { } loadBookData(); setupEventHandlers(); + setupCoverUpload(); + } + + function getPreviewUrl(book) { + if (!book.preview_urls) { + return null; + } + + const priorities = ["webp", "jpeg", "jpg", "png"]; + + for (const format of priorities) { + if (book.preview_urls[format]) { + return book.preview_urls[format]; + } + } + + const availableFormats = Object.keys(book.preview_urls); + if (availableFormats.length > 0) { + return book.preview_urls[availableFormats[0]]; + } + + return null; } function setupEventHandlers() { @@ -75,6 +98,270 @@ $(document).ready(() => { $("#loan-due-date").val(future.toISOString().split("T")[0]); } + function setupCoverUpload() { + const $container = $("#book-cover-container"); + const $fileInput = $("#cover-file-input"); + + $fileInput.on("change", function (e) { + const file = e.target.files[0]; + if (file) { + uploadCover(file); + } + $(this).val(""); + }); + + $container.on("dragenter", function (e) { + e.preventDefault(); + e.stopPropagation(); + if (!window.canManage()) return; + isDraggingOver = true; + showDropOverlay(); + }); + + $container.on("dragover", function (e) { + e.preventDefault(); + e.stopPropagation(); + if (!window.canManage()) return; + isDraggingOver = true; + }); + + $container.on("dragleave", function (e) { + e.preventDefault(); + e.stopPropagation(); + if (!window.canManage()) return; + + const rect = this.getBoundingClientRect(); + const x = e.clientX; + const y = e.clientY; + + if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) { + isDraggingOver = false; + hideDropOverlay(); + } + }); + + $container.on("drop", function (e) { + e.preventDefault(); + e.stopPropagation(); + if (!window.canManage()) return; + + isDraggingOver = false; + hideDropOverlay(); + + const files = e.dataTransfer?.files || []; + if (files.length > 0) { + const file = files[0]; + + if (!file.type.startsWith("image/")) { + Utils.showToast("Пожалуйста, загрузите изображение", "error"); + return; + } + + uploadCover(file); + } + }); + } + + function showDropOverlay() { + const $container = $("#book-cover-container"); + $container.find(".drop-overlay").remove(); + + const $overlay = $(` +
+ `); + + $container.append($overlay); + } + + function hideDropOverlay() { + $("#book-cover-container .drop-overlay").remove(); + } + + async function uploadCover(file) { + const $container = $("#book-cover-container"); + + const maxSize = 32 * 1024 * 1024; + if (file.size > maxSize) { + Utils.showToast("Файл слишком большой. Максимум 32 MB", "error"); + return; + } + + if (!file.type.startsWith("image/")) { + Utils.showToast("Пожалуйста, загрузите изображение", "error"); + return; + } + + const $loader = $(` +