from __future__ import annotations import logging from typing import Any from django.db.models import QuerySet, Q from django.http import FileResponse from django_filters.rest_framework import DjangoFilterBackend from rest_framework import parsers, permissions, status, viewsets from rest_framework.decorators import action, api_view, permission_classes from rest_framework.filters import OrderingFilter, SearchFilter from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response from apps.books.models import Book, BookChapter, DownloadRecord, EBook, ReadingProgress from apps.books.serializers import ( BookChapterSerializer, BookDetailSerializer, BookListSerializer, BookReadingSettingsSerializer, BookSerializer, DownloadRecordSerializer, EBookContentSerializer, EBookDetailSerializer, EBookListSerializer, EBookTocSerializer, EBookUploadSerializer, ReadingProgressSerializer, StorageSummarySerializer, ) from apps.reader.models import ReadingSettings from apps.books.services.ebook_metadata import subjects_from_ebook from apps.books.services.metadata import enrich_ebook_metadata logger = logging.getLogger(__name__) class BookViewSet(viewsets.ModelViewSet): queryset = Book.objects.all() permission_classes = [IsAuthenticated] filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filterset_fields = ["author", "genre", "reading_status"] search_fields = ["title", "author", "genre"] ordering_fields = ["title", "author", "genre", "created_at"] ordering = ["title"] def get_serializer_class(self): if self.action == "retrieve": return BookDetailSerializer if self.action == "list": return BookListSerializer return BookSerializer def get_queryset(self) -> QuerySet[Book]: qs = super().get_queryset() query = self.request.query_params.get("q", "").strip() if query: qs = qs.filter(Q(title__icontains=query) | Q(author__icontains=query) | Q(genre__icontains=query)) return qs @action(detail=False, methods=["get"], permission_classes=[AllowAny]) def genres(self, request: Request) -> Response: genre_list = Book.objects.values_list("genre", flat=True).distinct().order_by("genre") return Response([g for g in genre_list if g]) @action(detail=False, methods=["get"], permission_classes=[AllowAny]) def authors(self, request: Request) -> Response: author_list = Book.objects.values_list("author", flat=True).distinct().order_by("author") return Response([a for a in author_list if a]) @action(detail=False, methods=["get"]) def storage(self, request: Request) -> Response: """Return storage usage summary for the current user.""" download_records = DownloadRecord.objects.filter(user=request.user).select_related("ebook") total_size = sum(r.file_size for r in download_records) ebook_list = [ {"id": r.ebook.id, "title": r.ebook.title, "file_size": r.file_size} for r in download_records ] serializer = StorageSummarySerializer(data={ "total_downloads": download_records.count(), "total_size_bytes": total_size, "ebooks": ebook_list, }) serializer.is_valid(raise_exception=True) return Response(serializer.data) class IsEBookOwner(permissions.BasePermission): def has_object_permission(self, request: Request, view: object, obj: EBook) -> bool: return obj.user == request.user class EBookViewSet(viewsets.ModelViewSet): parser_classes = [parsers.MultiPartParser, parsers.FormParser, parsers.JSONParser] permission_classes = [IsAuthenticated, IsEBookOwner] def get_serializer_class(self): if self.action == "create": return EBookUploadSerializer if self.action in ("list",): return EBookListSerializer if self.action in ("toc",): return BookChapterSerializer return EBookDetailSerializer def get_queryset(self): qs = EBook.objects.filter(user=self.request.user).select_related("reading_progress", "user") query = self.request.query_params.get("q", "").strip() if query and self.action == "list": qs = qs.filter( Q(title__icontains=query) | Q(author__icontains=query), ) return qs @action(detail=False, methods=["get"]) def genres(self, request: Request) -> Response: genre_set: set[str] = set() for ebook in self.get_queryset(): genre_set.update(subjects_from_ebook(ebook)) return Response(sorted(genre_set)) @action(detail=False, methods=["get"]) def authors(self, request: Request) -> Response: author_list = ( self.get_queryset() .values_list("author", flat=True) .distinct() .order_by("author") ) return Response([a for a in author_list if a]) @action(detail=True, methods=["get", "patch"]) def progress(self, request: Request, pk: int | None = None) -> Response: ebook = self.get_object() progress_obj, _created = ReadingProgress.objects.get_or_create(user=request.user, ebook=ebook) if request.method == "GET": serializer = ReadingProgressSerializer(progress_obj) return Response(serializer.data) serializer = ReadingProgressSerializer(progress_obj, data=request.data, partial=True) serializer.is_valid(raise_exception=True) serializer.save() return Response(serializer.data) @action(detail=True, methods=["post"], url_path="enrich-metadata") def enrich_metadata(self, request: Request, pk: int | None = None) -> Response: """Re-fetch Open Library metadata and cover for this ebook.""" ebook = self.get_object() try: enrich_ebook_metadata(ebook) except Exception: logger.exception("Manual metadata enrichment failed for ebook %s", ebook.id) return Response( {"error": "Metadata enrichment failed."}, status=status.HTTP_502_BAD_GATEWAY, ) ebook.refresh_from_db() serializer = EBookDetailSerializer(ebook, context={"request": request}) return Response(serializer.data) @action(detail=True, methods=["post"]) def process(self, request: Request, pk: int | None = None) -> Response: """Trigger e-book processing: metadata extraction, TOC building, page counting.""" ebook = self.get_object() if not ebook.file: return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST) try: from apps.books.services.process_ebook import apply_processing_to_ebook result = apply_processing_to_ebook(ebook) return Response(result) except Exception as exc: logger.exception("Failed to process ebook %s", ebook.id) return Response({"error": f"Processing failed: {exc}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @action(detail=True, methods=["get"]) def file(self, request: Request, pk: int | None = None) -> FileResponse | Response: """Stream the EPUB file for authenticated in-browser reading.""" ebook = self.get_object() if ebook.format != "epub": return Response( {"error": "Reader supports EPUB only."}, status=status.HTTP_400_BAD_REQUEST, ) if not ebook.file: return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST) return FileResponse( ebook.file.open("rb"), content_type="application/epub+zip", filename=ebook.filename(), ) @action(detail=True, methods=["get"]) def toc(self, request: Request, pk: int | None = None) -> Response: """Return hierarchical table of contents.""" ebook = self.get_object() chapters = BookChapter.objects.filter(ebook=ebook).order_by("index").select_related("ebook") serializer = BookChapterSerializer(chapters, many=True) return Response({ "chapters": serializer.data, "format": ebook.format, "page_count": ebook.page_count, }) @action(detail=True, methods=["get"]) def content(self, request: Request, pk: int | None = None) -> Response: """Return paginated content for a given page number. Query params: page (int): page/chapter index to fetch (1-indexed, default: 1) """ ebook = self.get_object() page = max(1, int(request.query_params.get("page", 1))) chapters = list(BookChapter.objects.filter(ebook=ebook).order_by("index").select_related("ebook")) total_pages = len(chapters) or ebook.page_count or 1 chapter: BookChapter | None = None chapter_title = "" content_html = "" if chapters and 0 <= page - 1 < len(chapters): ch = chapters[page - 1] chapter_title = ch.title content_html = _fetch_chapter_content(ebook, ch) serializer = EBookContentSerializer(data={ "page": page, "total_pages": total_pages, "content": content_html, "chapter_title": chapter_title, "format": ebook.format, }) serializer.is_valid(raise_exception=True) return Response(serializer.data) @action(detail=True, methods=["post"]) def download(self, request: Request, pk: int | None = None) -> Response: """Track download of an e-book. Creates a DownloadRecord and returns file info.""" ebook = self.get_object() if not ebook.file: return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST) download, created = DownloadRecord.objects.get_or_create( user=request.user, ebook=ebook, defaults={"file_size": ebook.file.size if ebook.file else 0}, ) if not created: download.file_size = ebook.file.size if ebook.file else 0 download.save(update_fields=["file_size"]) serializer = DownloadRecordSerializer(download, context={"request": request}) return Response(serializer.data, status=status.HTTP_200_OK) @action(detail=False, methods=["get"]) def downloads(self, request: Request) -> Response: """List all e-books the current user has downloaded.""" records = DownloadRecord.objects.filter(user=request.user).select_related( "ebook", "ebook__reading_progress" ).prefetch_related("ebook__chapters") page = self.paginate_queryset(records) if page is not None: serializer = DownloadRecordSerializer(page, many=True, context={"request": request}) return self.get_paginated_response(serializer.data) serializer = DownloadRecordSerializer(records, many=True, context={"request": request}) return Response(serializer.data) @action(detail=False, methods=["delete"], url_path="downloads/(?P[^/.]+)") def delete_download(self, request: Request, download_pk: str | None = None) -> Response: """Delete a download record.""" try: download = DownloadRecord.objects.get(pk=download_pk, user=request.user) except DownloadRecord.DoesNotExist: return Response({"error": "Download record not found."}, status=status.HTTP_404_NOT_FOUND) download.delete() return Response(status=status.HTTP_204_NO_CONTENT) def _fetch_chapter_content(ebook: EBook, chapter: BookChapter) -> str: """Fetch HTML content for a chapter from the e-book file.""" if ebook.format == "epub": return _fetch_epub_chapter_content(ebook.file.path, chapter) return "" def _fetch_epub_chapter_content(file_path: str, chapter: BookChapter) -> str: """Extract HTML content of a specific EPUB chapter by href.""" try: from ebooklib import epub from bs4 import BeautifulSoup except ImportError: return "" try: book = epub.read_epub(file_path) href = chapter.href or "" # Find the item by href for item in book.get_items(): item_name = item.get_name() or "" if href and (item_name.endswith(href) or href.endswith(item_name)): content = item.get_content() soup = BeautifulSoup(content, "html.parser") # Clean up — remove body/html/head wrappers, keep inner content body = soup.find("body") if body: return str(body) return str(soup) return "" except Exception: logger.exception("Failed to fetch EPUB chapter content for %s", chapter.href) return "" @api_view(["GET", "PATCH"]) @permission_classes([IsAuthenticated]) def book_reading_settings_view(request: Request) -> Response: """Get or update reading settings via the books API contract.""" settings, _created = ReadingSettings.objects.get_or_create(user=request.user) if request.method == "GET": serializer = BookReadingSettingsSerializer(settings) return Response(serializer.data) serializer = BookReadingSettingsSerializer(settings, data=request.data, partial=True) serializer.is_valid(raise_exception=True) serializer.save() return Response(serializer.data)