Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions api/v1/routes/books.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
from core.config import settings
from models.user import User
from services.book_service import BookService, get_book_service
from services.storage.filesystem_storage import FileSystemStorage
from services.storage.storage_backend import StorageFileType

router = APIRouter()
Expand Down Expand Up @@ -120,7 +119,13 @@ async def list_books(
Lists books with pagination, optional search, filtering and sorting.
"""
books, total = await book_service.get_books(
user.id, skip, limit, search, tags, sort_by, sort_order,
user.id,
skip,
limit,
search,
tags,
sort_by,
sort_order,
)
items = [construct_book_display(book, request) for book in books]
return PaginatedBookResponse(items=items, total=total)
Expand Down Expand Up @@ -229,7 +234,7 @@ async def get_book_cover(
)

if cover_path and cover_path.exists():
if not isinstance(storage_backend, FileSystemStorage):
if not storage_backend.is_local:
background_tasks.add_task(cover_path.unlink)

return FileResponse(cover_path, background=background_tasks)
Expand Down Expand Up @@ -257,11 +262,7 @@ async def download_book_file(
"""
book = await book_service.get_book_by_id(book_id, user.id)

if (
not book.file_path
or not book.file_hash
or not book.stored_filename
):
if not book.file_path or not book.file_hash or not book.stored_filename:
raise HTTPException(status_code=404, detail="File not found.")

storage_backend = await book_service.get_storage_backend(user)
Expand All @@ -276,7 +277,7 @@ async def download_book_file(
if not book_path or not book_path.exists():
raise HTTPException(status_code=404, detail="File not found.")

if not isinstance(storage_backend, FileSystemStorage):
if not storage_backend.is_local:
background_tasks.add_task(book_path.unlink)

return FileResponse(
Expand Down
30 changes: 4 additions & 26 deletions services/book_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,15 @@
from parsers.base_parser import BookParser
from parsers.epub_parser import EpubParser
from parsers.pdf_parser import PdfParser
from services.storage import create_storage_backend
from services.storage.exceptions import StorageBackendError
from services.storage.filesystem_storage import FileSystemStorage
from services.storage.minio_storage import MinIOStorage
from services.storage.storage_backend import StorageBackend, StorageFileType

PARSER_MAPPING = {
"EPUB": EpubParser,
"PDF": PdfParser,
}

STORAGE_BACKENDS = {
"FILE_SYSTEM": FileSystemStorage,
"MINIO": MinIOStorage,
}


class BookService:
def __init__(self, db: AsyncSession):
Expand Down Expand Up @@ -252,7 +246,7 @@ async def _store_book_impl(

try:
storage_backend = await self.get_storage_backend(user)
except (StorageBackendError, KeyError) as error:
except StorageBackendError as error:
logger.exception("Error getting storage backend.")

await self.update_book_status(
Expand Down Expand Up @@ -396,7 +390,7 @@ async def delete_book_by_id(self, user: User, book_id: str) -> int:

try:
storage_backend = await self.get_storage_backend(user)
except (StorageBackendError, KeyError):
except StorageBackendError:
logger.exception("Error getting storage backend.")
return 0

Expand Down Expand Up @@ -439,23 +433,7 @@ async def get_storage_backend(self, user: User | str | None) -> StorageBackend:
user_id = user.id
storage = await get_default_storage(self.db, user_id)

if not storage:
return STORAGE_BACKENDS["FILE_SYSTEM"]()

match storage.storage_type:
case "FILE_SYSTEM":
return STORAGE_BACKENDS["FILE_SYSTEM"]()
case "MINIO":
config = storage.config
return STORAGE_BACKENDS["MINIO"](
access_key=config["access_key"],
secret_key=config["secret_key"],
endpoint=config["endpoint"],
bucket_name=config["bucket_name"],
secure=config.get("secure", False),
)
case _:
raise StorageBackendError(StorageBackendError.NOT_FOUND)
return create_storage_backend(storage)


def get_book_service(
Expand Down
49 changes: 49 additions & 0 deletions services/storage/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Utilities for resolving storage backends."""

from models.storage import Storage
from services.storage.exceptions import StorageBackendError
from services.storage.filesystem_storage import FileSystemStorage
from services.storage.minio_storage import MinIOStorage
from services.storage.storage_backend import StorageBackend

STORAGE_BACKENDS: dict[str, type[StorageBackend]] = {
"FILE_SYSTEM": FileSystemStorage,
"MINIO": MinIOStorage,
}


def create_storage_backend(storage: Storage | None) -> StorageBackend:
"""Create a :class:`StorageBackend` from a storage model instance.

Args:
storage: The storage configuration from the database. ``None``
results in the default local file system storage.

Raises:
StorageBackendError: If the storage type is unknown or misconfigured.
"""

if storage is None:
return FileSystemStorage()

backend_cls = STORAGE_BACKENDS.get(storage.storage_type)

if backend_cls is None:
raise StorageBackendError(StorageBackendError.NOT_FOUND)

if backend_cls is FileSystemStorage:
return backend_cls()

# MINIO or other backends requiring config
config = storage.config or {}

try:
return MinIOStorage(
bucket_name=config["bucket_name"],
endpoint=config["endpoint"],
access_key=config["access_key"],
secret_key=config["secret_key"],
secure=config.get("secure", False),
)
except KeyError as exc: # pragma: no cover - configuration errors
raise StorageBackendError(StorageBackendError.NOT_CONFIGURED) from exc
4 changes: 4 additions & 0 deletions services/storage/filesystem_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@


class FileSystemStorage(StorageBackend):
@property
def is_local(self) -> bool:
return True

def get_prepared_book_dir(self, user: User, book_dir: str) -> Path:
book_path = settings.BOOK_FILES_DIR / str(user.id) / book_dir
book_path.mkdir(parents=True, exist_ok=True)
Expand Down
7 changes: 6 additions & 1 deletion services/storage/minio_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ def __init__(
secure=secure,
)

@property
def is_local(self) -> bool:
return False

def get_prepared_book_dir(self, user: User, book_dir: str) -> Path:
return Path(f"books/{user.id}/{book_dir}")

Expand All @@ -39,7 +43,8 @@ def get_file(
filetype: StorageFileType,
) -> Path | None:
object_name = self._get_object_name(user, book_dir, filename, filetype)
local_path = settings.TEMP_FILES_DIR / filename
local_path = settings.TEMP_FILES_DIR / str(user.id) / book_dir / filename
local_path.parent.mkdir(parents=True, exist_ok=True)

try:
self.client.fget_object(self.bucket_name, object_name, str(local_path))
Expand Down
6 changes: 6 additions & 0 deletions services/storage/storage_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ class StorageFileType(Enum):


class StorageBackend(ABC):
@property
@abstractmethod
def is_local(self) -> bool:
"""Indicates whether files are stored locally on the server."""
pass

@abstractmethod
def get_file(
self,
Expand Down
Loading