LNA Book Proc API
This project is a FastAPI service that lets you:
- Authenticate users with JWT
- Check service health
- Upload and process EPUB files into:
- an HTML “viewer” page (
viewer.html) with a navigation sidebar - a RAG-ready folder containing extracted text and images
- Convert extracted text chunks (
.txt) into audio (.mp3) with Kokoro and concatenate them
The code is intentionally split into routers (HTTP/API layer) and utilities (file processing / templating / audio generation).
1. High-level architecture
Entry point (FastAPI)
main.py creates the FastAPI app, configures CORS, and mounts routers under their prefixes:
app.routers.auth→/authapp.routers.health→/healthapp.routers.epub→/epubapp.routers.audio→/audio
See:
# Include routers
app.include_router(auth.router)
app.include_router(health.router)
app.include_router(epub.router)
app.include_router(audio.router)
...
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=9000)
CORS
main.py configures permissive CORS:
allow_origins=["*"]allow_credentials=Trueallow_methods=["*"],allow_headers=["*"]
For production, restrict allow_origins to your real frontend URLs.
Authentication / middleware
All “processing” endpoints depend on get_current_active_user (JWT bearer token).
JWT logic is implemented in app/auth.py using a small in-memory “fake” user database (useful for development).
SECRET_KEY = "your-secret-key-here-change-in-production"
...
fake_users_db = {
"testuser": {
"username": "testuser",
...
"hashed_password": bcrypt.hashpw("testpassword".encode('utf-8'), bcrypt.gensalt()),
"disabled": False,
}
}
...
async def get_current_active_user(current_user = Depends(get_current_user)):
"""Get current active user"""
if current_user.get("disabled"):
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
EPUB processing pipeline
The EPUB endpoint (/epub/proc) calls process_epub(...) in app/utils/utils.py.
In practice this pipeline is:
- Save upload to a temp directory
- Unzip EPUB
- Locate the OPS/OEBPS/EPUB folder
- Parse
content.opf/contents.opfto extract: - the book title + navigation items
- structured RAG text chunks (
.txt) and a copy of images - Copy certain extracted folders to make assets available to the HTML viewer
- Generate/overwrite
viewer.htmlby injecting nav + audio filename intohtml/viewer_template1.html
See the endpoint and the call into the processing function:
@router.post("/proc", response_model=EpubProcResponse)
async def epub_proc(
file: UploadFile = File(...),
book_name: str = Form(...),
book_id: str = Form(...),
current_user = Depends(get_current_active_user)
):
...
# Save uploaded file to temp directory
temp_dir = tempfile.mkdtemp()
...
buffer.write(content)
...
# Process the EPUB file
result_path, rag_dir_text, rag_dir_images, error_message, success = process_epub(temp_file_path, book_id)
...
if success:
folder_name = os.path.dirname(result_path)
data = {
"book_name": book_name,
"book_id": book_id,
"result_path": result_path,
"folder_name": folder_name,
"rag_dir_text": rag_dir_text,
"rag_dir_images": rag_dir_images
}
The core EPUB processing logic is in process_epub:
def process_epub(epub_file_path, book_id) -> Tuple[str, str, str,str, bool]:
...
root_dir = os.getenv('ROOT_DIR')
rag_dir = os.getenv('RAG_DIR')
template_path = os.getenv('TEMPLATE_PATH')
...
temp_folder = os.path.join(root_dir, book_id)
...
# Unzip the epub
files = unzip_epub(epub_file_path, temp_folder)
...
# Choose ops_dir (OPS / OEBPS / EPUB)
if len(matches) > 0:
ops_dir = os.path.join(temp_folder, 'OPS')
...
# Parse OPF
if os.path.exists(os.path.join(ops_dir, 'content.opf')):
with open(os.path.join(ops_dir, 'content.opf'), 'r', encoding='utf-8') as f:
opf_content = f.read()
title, nav_items, rag_dir_text, rag_dir_images = parse_opf_xml(opf_content, ops_dir, rag_dir)
...
# Inject nav into viewer template
viewer_html_path = os.path.join(ops_dir, 'viewer.html')
success = add_nav_section(template_path, viewer_html_path, title, nav_items, f"{book_id}.mp3", book_id)
...
return viewer_html_path, rag_dir_text, rag_dir_images, error_message, True
Audio pipeline
The audio endpoint (/audio/convert) converts .txt files from a directory into audio segments and concatenates them into a single {book_id}.mp3.
router = APIRouter(
prefix="/audio",
tags=["audio"],
responses={404: {"description": "Not found"}},
)
@router.post("/convert", response_model=EpubProcResponse)
async def convert_audio(
ops_dir: str = Form(...),
destination_dir: str = Form(...),
book_id: str = Form(...),
current_user = Depends(get_current_active_user)
):
...
txt_files = [f for f in os.listdir(source_dir) if f.endswith('.txt')]
txt_files.sort(key=lambda x: int(os.path.splitext(os.path.basename(x))[0]) if os.path.splitext(os.path.basename(x))[0].isdigit() else float('inf'))
...
book_audio_files, page = generate_audio(text, output_dir=destination_dir, page_number=page_number)
...
allGood = combine_mp3_files(destination_dir, destination_dir + f'/{name}.mp3')
...
return EpubProcResponse(success=True, message=f"Successfully converted {len(txt_files)} text files to audio")
Audio segment generation and concatenation are implemented here:
def generate_audio(..., output_dir='output', page_number=0):
pipeline = KPipeline(lang_code)
generator = pipeline(text, voice=voice, speed=speed, split_pattern=split_pattern)
...
sf.write(f'{output_dir}/{page_number}.mp3', audio, 24000)
fileList.append(f'{output_dir}/{page_number}.mp3')
return fileList, page
def combine_mp3_files(input_dir: str, output_file: str, ...):
mp3_files = glob.glob(os.path.join(input_dir, file_pattern))
...
mp3_files.sort(key=lambda x: int(os.path.splitext(os.path.basename(x))[0]) if os.path.splitext(os.path.basename(x))[0].isdigit() else float('inf'))
...
final_audio = np.concatenate(combined_audio)
sf.write(output_file, final_audio, sample_rate)
return True
2. Start the service (port, run commands)
Default port
- When running directly via
python main.py, the server uses port 9000:
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=9000)
Development scripts
start_server.shstarts the service on port 9001:
source venv/bin/activate
uvicorn main:app --host 0.0.0.0 --reload --port 9001
Run steps
- Create and activate a virtual environment
- Install dependencies
-
Provide environment variables (see next section)
-
Start the server (pick one):
API docs
FastAPI exposes:
- Swagger UI:
http://localhost:<port>/docs - ReDoc:
http://localhost:<port>/redoc
3. Configuration (.env)
The service expects a root .env at the project root.
Required variables used by EPUB processing and templating:
ROOT_DIR: where EPUB extraction temp folders are created (ROOT_DIR/<book_id>/...)RAG_DIR: where extracted RAG text/images are storedTEMPLATE_PATH: path to the HTML template used to generateviewer.htmlAPI_URL: injected into the viewer templateBOOK_SOURCE_DIR: used byBookProcessor.py(optional background indexing)
The project also uses Supabase + Ollama for indexing/embeddings (used by app/utils/BookProcessor.py and app/utils/BookProcessor.py helpers).
You can also find a second .env under app/utils/.env (useful for running indexing scripts locally). Keep in mind that modules call load_dotenv() at import time, so the working directory matters.
4. Authentication (JWT)
Endpoints
POST /auth/login: returns a JWT access tokenGET /auth/me: returns current user info- Protected endpoints require
Authorization: Bearer <token>
Login endpoint is implemented as:
@router.post("/login", response_model=Token)
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
user = authenticate_user(form_data.username, form_data.password)
...
access_token = create_access_token(
data={"sub": user["username"]}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}
Default development user
The in-memory user database includes:
- Username:
testuser - Password:
testpassword
5. HTTP API summary (what to call)
All non-auth endpoints require a valid JWT bearer token.
Root
GET /→{ "message": "LNA BOOK Proc API is running" }
Health
GET /health
EPUB processing
POST /epub/proc- Content type:
multipart/form-data - Form fields:
file: EPUB filebook_name: stringbook_id: string
Validations:
- File extension must end with
.epub - File size must be
<= 500MB - Rejects files with suspiciously small payload (
< 100 bytes)
See:
if not file.filename.lower().endswith('.epub'):
return EpubProcResponse(
success=False,
message=f"Invalid file type. Only EPUB files are allowed. {file.filename}"
)
...
if file.size and file.size > MAX_FILE_SIZE:
return EpubProcResponse(
success=False,
message=f"File too large. Maximum file size is {MAX_FILE_SIZE // (1024*1024)}MB. Your file is {file.size // (1024*1024)}MB."
)
...
if len(content) < 100:
return EpubProcResponse(
success=False,
message=f"File appears to be incomplete. Received {len(content)} bytes, which is too small for a valid EPUB file. Please check your upload."
)
Response fields on success include:
result_path: generatedviewer.htmlpathfolder_name: directory that containsviewer.htmlrag_dir_text: directory containing extracted numbered.txtchunksrag_dir_images: directory containing images used by the viewer
Audio conversion
POST /audio/convert- Content type:
multipart/form-data - Form fields:
ops_dir: directory containing numbered.txtfilesdestination_dir: directory to write{book_id}.mp3into (usually alongsideviewer.html)book_id: controls output name{book_id}.mp3
6. EPUB → Viewer + RAG: end-to-end flow
Here is the intended flow based on the endpoint responses:
Client
|
| 1) POST /epub/proc (file, book_name, book_id) [JWT]
v
API
|
| 2) unzip EPUB into ROOT_DIR/<book_id>/
| 3) parse OPF (content.opf/contents.opf)
| 4) write RAG text/images into RAG_DIR/<identifier>/{text,images}/
| 5) generate ROOT_DIR/<book_id>/<OPS-or-OEBPS>/viewer.html
|
v
Client receives:
- viewer.html path (result_path)
- ops folder for assets (folder_name)
- rag text folder (rag_dir_text)
- rag images folder (rag_dir_images)
|
| (optional) 6) POST /audio/convert using rag_dir_text + folder_name [JWT]
v
Audio output:
- {folder_name}/{book_id}.mp3
Key implementation details to know when updating code:
- OPS folder detection:
process_epubtriesOPS, thenOEBPS, thenEPUB. - OPF parsing:
parse_opf_xml(...)scans the OPF manifest/spine and: - creates
RAG_DIR/<identifier>/text/ - creates
RAG_DIR/<identifier>/images/ - writes numbered text chunks into
.../text/<file_num>.txt - Viewer templating:
add_nav_section(...)modifies the HTML template: - injects title +
nav_items - sets API URL and AI source book id
- attempts to set the audio file name in the viewer
The OPF parsing behavior (directory creation + text writing) lives here:
def parse_opf_xml(html_data: str, ops_dir: str, rag_dir: str):
...
identifier = soup.find('identifier')
...
if len(spine_data) > 0:
# create the folder for the RAG
if not os.path.exists(os.path.join(rag_dir, identifier )):
os.makedirs(os.path.join(rag_dir, identifier ))
else:
shutil.rmtree(os.path.join(rag_dir, identifier ))
os.makedirs(os.path.join(rag_dir, identifier ))
os.makedirs(os.path.join(rag_dir, identifier, 'text')) ...
os.makedirs(os.path.join(rag_dir, identifier, 'images')) ...
rag_dir_text = os.path.join(rag_dir, identifier, 'text')
rag_dir_images = os.path.join(rag_dir, identifier, 'images')
Text chunk writing:
if item['media-type'] == 'application/xhtml+xml':
...
title, heading, data = get_text_from_xhtml(os.path.join(ops_dir,item['href'] ))
if data is not None:
try:
file_name = f"{rag_dir}/{identifier}/text/{file_num}.txt"
if "cover" in f"{item['id']}":
file_name = f"{rag_dir}/{identifier}/text/0.txt"
with open(file_name, 'w', encoding='utf-8') as f:
f.write(data)
file_num += 1
...
nav_items.append({
'id': item['id'],
'index': index,
'title': title,
'subtitle': heading,
'url': item['href']
})
Viewer injection is done in html_util.py:
def add_nav_section(template_path: str, html_path: str, section_title: str, nav_items: List[Dict[str, str]], audio_file_path: str, book_id: str) -> bool:
...
api_url = soup.find('label', **{'id':'API_URL'})
api_url.attrs['value'] = os.getenv('API_URL')
ai_source = soup.find('label', **{'id':'AI_SOURCE'})
ai_source.attrs['value'] = book_id
...
aside = soup.find('aside', **{'class':'sidebar'})
...
# Add the new nav_section to the sidebar
aside.append(new_nav_section)
...
with open(html_path, 'w', encoding='utf-8') as file:
file.write(str(soup.prettify()))
return True
7. Audio conversion: expected inputs
The audio endpoint sorts .txt files by their filename stem (numeric stems come first).
Implementation details:
txt_files = [f for f in os.listdir(source_dir) if f.endswith('.txt')]
txt_files.sort(key=lambda x: int(os.path.splitext(os.path.basename(x))[0]) if os.path.splitext(os.path.basename(x))[0].isdigit() else float('inf'))
...
with open(os.path.join(source_dir, file), 'r') as f:
text = f.read()
book_audio_files, page = generate_audio(text, output_dir=destination_dir, page_number=page_number)
...
allGood = combine_mp3_files(destination_dir, destination_dir + f'/{name}.mp3')
Practical usage pattern:
- Call
POST /epub/proc - Use the response values:
rag_dir_text→ pass asops_dirfolder_name→ pass asdestination_dirbook_id→ pass asbook_id
This causes the service to write:
{folder_name}/{book_id}.mp3
so the viewer can reference the correct audio file.
8. Optional: background indexing into Supabase (BookProcessor)
app/utils/BookProcessor.py is a standalone script that:
- reads directories from
BOOK_SOURCE_DIR - uses Ollama to:
- generate titles/summaries (
get_title_and_summary) - generate embeddings (
get_embedding) - inserts processed chunks into Supabase table
book_vector_pages
Key configuration:
load_dotenv()
llm_host = os.getenv('OLLAMA_HOST')
ollama_client = AsyncClient(host=llm_host)
...
def get_supabase_client() -> Client:
supabase_url = os.getenv("SUPABASE_URL")
supabase_key = os.getenv("SUPABASE_KEY")
...
return create_client(supabase_url, supabase_key)
Embeddings + title/summary helpers:
async def get_title_and_summary(chunk: str, url: str) -> Dict[str, str]:
...
async def get_embedding(text: str) -> List[float]:
response = await ollama_client.embeddings(
model="nomic-embed-text:latest",
prompt=text
)
return response['embedding']
If you update RAG/chunking behavior, start here first.
9. Notes when updating code
- Keep endpoint contracts stable:
/epub/procrequest fields and response field names are relied upon by downstream consumers (audio conversion)./audio/convertexpects numbered.txtfiles for ordering.- Treat
.envvalues as secrets: - Supabase keys and service keys must not be committed.
- Be mindful of “working directory” effects:
- several modules call
load_dotenv()at import time.