Skip to content
Open
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
30 changes: 30 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
FROM astral/uv:python3.12-bookworm-slim AS base

ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get -y upgrade


FROM base AS build

RUN mkdir -p /app/build
COPY src README.md pyproject.toml /app/build/

WORKDIR /app/build
RUN uv build


FROM base AS runtime

RUN mkdir -p /tmp/wheels
COPY --from=build /app/build/dist/*.whl /tmp/wheels/
RUN uv tool install /tmp/wheels/*.whl
RUN rm -rf /tmp/wheels

RUN mkdir -p /app/data /app/templates
COPY reader3.png ./
WORKDIR /app

EXPOSE 8123

ENTRYPOINT [ "uv", "run", "reader3-server" ]

29 changes: 24 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,39 @@ This project was 90% vibe coded just to illustrate how one can very easily [read

## Usage

The project uses [uv](https://docs.astral.sh/uv/). So for example, download [Dracula EPUB3](https://www.gutenberg.org/ebooks/345) to this directory as `dracula.epub`, then:
The project uses [uv](https://docs.astral.sh/uv/). So for example, download [Dracula EPUB3](https://www.gutenberg.org/ebooks/345) to this directory as `data/dracula.epub`, then:

```bash
uv run reader3.py dracula.epub
uv run reader3ctl add data/dracula.epub
```

This creates the directory `dracula_data`, which registers the book to your local library. We can then run the server:
This creates the directory `data/dracula_data`, which registers the book to your local library. We can then run the server:

```bash
uv run server.py
uv run reader3-server
```

And visit [localhost:8123](http://localhost:8123/) to see your current Library. You can easily add more books, or delete them from your library by deleting the folder. It's not supposed to be complicated or complex.

## Docker and docker-compose

Rather than the uv commands and the reader3-server command above, you can build and run this via docker, as follows:

```bash
docker build -t reader3 .
docker run reader3
``

Alternatively, you can run it with docker compose, as follows:

```bash
docker compose up
```

This will do the building for you, without needing a manual uv installation, and may help you with integrating this into your home network. Don't run this on any commercial (or other) network where security is paramount, of course -- it's just not made for that.

In either docker use case, you can still connect to the service at [localhost:8123](http://localhost:8123/), as above.

## License

MIT
MIT
12 changes: 12 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
services:
reader3:
build: .
environment:
- "READER3_PORT=8123"
- "READER3_HOST=0.0.0.0"
ports:
- "8123:8123"
volumes:
- ./data:/app/data
- ./templates:/app/templates

9 changes: 9 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,12 @@ dependencies = [
"jinja2>=3.1.6",
"uvicorn>=0.38.0",
]

[project.scripts]
reader3-server = "reader3.server:main"
reader3ctl = "reader3.reader3:main"

[build-system]
requires = ["hatchling >= 1.26"]
build-backend = "hatchling.build"

Empty file added src/reader3/__init__.py
Empty file.
23 changes: 15 additions & 8 deletions reader3.py → src/reader3/reader3.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import os
import pickle
import shutil
import sys
from dataclasses import dataclass, field
from typing import List, Dict, Optional, Any
from datetime import datetime
Expand Down Expand Up @@ -290,17 +291,18 @@ def save_to_pickle(book: Book, output_dir: str):
print(f"Saved structured data to {p_path}")


# --- CLI ---

if __name__ == "__main__":

def main():
import sys
if len(sys.argv) < 2:
print("Usage: python reader3.py <file.epub>")
if len(sys.argv) < 3:
print("Usage: reader3 add <file.epub>")
sys.exit(1)

if sys.argv[1].strip().lower() != "add":
print("ERROR: only 'add' mode is supported for now.", file=sys.stderr)
sys.exit(1)

epub_file = sys.argv[1]
assert os.path.exists(epub_file), "File not found."
epub_file = sys.argv[2]
assert os.path.exists(epub_file), "Could not find file {epub_file}."
out_dir = os.path.splitext(epub_file)[0] + "_data"

book_obj = process_epub(epub_file, out_dir)
Expand All @@ -311,3 +313,8 @@ def save_to_pickle(book: Book, output_dir: str):
print(f"Physical Files (Spine): {len(book_obj.spine)}")
print(f"TOC Root Items: {len(book_obj.toc)}")
print(f"Images extracted: {len(book_obj.images)}")


if __name__ == "__main__":
sys.exit(main())

36 changes: 27 additions & 9 deletions server.py → src/reader3/server.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,33 @@
import os
import pickle
import sys
from functools import lru_cache
from typing import Optional
from pathlib import Path

import uvicorn
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import HTMLResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates

from reader3 import Book, BookMetadata, ChapterContent, TOCEntry
from .reader3 import Book, BookMetadata, ChapterContent, TOCEntry

app = FastAPI()
templates = Jinja2Templates(directory="templates")

# Where are the book folders located?
BOOKS_DIR = "."
BOOKS_DIR = "./data"

@lru_cache(maxsize=10)
def load_book_cached(folder_name: str) -> Optional[Book]:
"""
Loads the book from the pickle file.
Cached so we don't re-read the disk on every click.
"""
file_path = os.path.join(BOOKS_DIR, folder_name, "book.pkl")
file_path = os.path.join(folder_name, "book.pkl")
if not os.path.exists(file_path):
print(f"{file_path} for requested book not found", file=sys.stderr)
return None

try:
Expand All @@ -38,20 +42,28 @@ def load_book_cached(folder_name: str) -> Optional[Book]:
async def library_view(request: Request):
"""Lists all available processed books."""
books = []

print(f"Library index page requested. Scanning books.")

# Scan directory for folders ending in '_data' that have a book.pkl
if os.path.exists(BOOKS_DIR):
for item in os.listdir(BOOKS_DIR):
if item.endswith("_data") and os.path.isdir(item):
book_path = Path(BOOKS_DIR) / item
if item.endswith("_data") and book_path.is_dir():
print(f"Examining potentially cached book {item}")
# Try to load it to get the title
book = load_book_cached(item)
print(f"{item} did not load successfully as a cached book", file=sys.stderr)
book = load_book_cached(str(book_path))
if book:
books.append({
"id": item,
"title": book.metadata.title,
"author": ", ".join(book.metadata.authors),
"chapters": len(book.spine)
})
print(f"Added book {item} to the index page list")

print(f"Returning library index page, with {len(books)} books.")

return templates.TemplateResponse("library.html", {"request": request, "books": books})

Expand All @@ -63,7 +75,7 @@ async def redirect_to_first_chapter(book_id: str):
@app.get("/read/{book_id}/{chapter_index}", response_class=HTMLResponse)
async def read_chapter(request: Request, book_id: str, chapter_index: int):
"""The main reader interface."""
book = load_book_cached(book_id)
book = load_book_cached(str(Path(BOOKS_DIR) / os.path.basename(book_id)))
if not book:
raise HTTPException(status_code=404, detail="Book not found")

Expand Down Expand Up @@ -104,7 +116,13 @@ async def serve_image(book_id: str, image_name: str):

return FileResponse(img_path)

def main():
host = os.environ.get("READER3_HOST", "127.0.0.1")
port = int(os.environ.get("READER3_PORT", "8123"))

print(f"Starting server at http://{host}:{port}")
uvicorn.run(app, host=host, port=port)

if __name__ == "__main__":
import uvicorn
print("Starting server at http://127.0.0.1:8123")
uvicorn.run(app, host="127.0.0.1", port=8123)
sys.exit(main())

Loading