from fastapi import FastAPI, Request, Depends from fastapi.templating import Jinja2Templates from fastapi.staticfiles import StaticFiles from fastapi.responses import HTMLResponse, JSONResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from contextlib import asynccontextmanager import json from datetime import datetime from app.database import init_db, get_db from app.models import Book, ListeningSession, Recommendation from app.abs_client import AudiobookshelfClient from app.recommender import BookRecommender from app.config import get_settings @asynccontextmanager async def lifespan(app: FastAPI): """Initialize database on startup.""" await init_db() yield # Initialize FastAPI app app = FastAPI( title="Audiobookshelf Recommendations", description="AI-powered book recommendations based on your listening history", lifespan=lifespan ) # Setup templates and static files templates = Jinja2Templates(directory="app/templates") app.mount("/static", StaticFiles(directory="app/static"), name="static") # Initialize clients abs_client = AudiobookshelfClient() recommender = BookRecommender() @app.get("/", response_class=HTMLResponse) async def home(request: Request, db: AsyncSession = Depends(get_db)): """Home page showing dashboard.""" # Get recent books and recommendations recent_sessions = await db.execute( select(ListeningSession) .order_by(ListeningSession.last_update.desc()) .limit(10) ) sessions = recent_sessions.scalars().all() # Get book details for sessions books = [] for session in sessions: book_result = await db.execute( select(Book).where(Book.id == session.book_id) ) book = book_result.scalar_one_or_none() if book: books.append({ "book": book, "session": session }) # Get recent recommendations recs_result = await db.execute( select(Recommendation) .where(Recommendation.dismissed == False) .order_by(Recommendation.created_at.desc()) .limit(5) ) recommendations = recs_result.scalars().all() return templates.TemplateResponse( "index.html", { "request": request, "books": books, "recommendations": recommendations } ) @app.get("/api/sync") async def sync_with_audiobookshelf(db: AsyncSession = Depends(get_db)): """Sync library and progress from Audiobookshelf.""" try: # Get user's progress progress_items = await abs_client.get_user_progress() synced_count = 0 for item in progress_items: # Extract book info library_item = item.get("libraryItem", {}) media = library_item.get("media", {}) metadata = media.get("metadata", {}) book_id = library_item.get("id") if not book_id: continue # Check if book exists in DB result = await db.execute(select(Book).where(Book.id == book_id)) book = result.scalar_one_or_none() # Create or update book if not book: book = Book( id=book_id, title=metadata.get("title", "Unknown"), author=metadata.get("authorName", "Unknown"), narrator=metadata.get("narratorName"), description=metadata.get("description"), genres=json.dumps(metadata.get("genres", [])), tags=json.dumps(media.get("tags", [])), duration=media.get("duration", 0), cover_url=media.get("coverPath") ) db.add(book) else: # Update existing book book.title = metadata.get("title", book.title) book.author = metadata.get("authorName", book.author) book.updated_at = datetime.now() # Update or create listening session progress_data = item.get("progress", 0) current_time = item.get("currentTime", 0) is_finished = item.get("isFinished", False) session_result = await db.execute( select(ListeningSession) .where(ListeningSession.book_id == book_id) .order_by(ListeningSession.last_update.desc()) .limit(1) ) session = session_result.scalar_one_or_none() if not session: session = ListeningSession( book_id=book_id, progress=progress_data, current_time=current_time, is_finished=is_finished, started_at=datetime.now() ) if is_finished: session.finished_at = datetime.now() db.add(session) else: # Update existing session session.progress = progress_data session.current_time = current_time session.is_finished = is_finished if is_finished and not session.finished_at: session.finished_at = datetime.now() synced_count += 1 await db.commit() return JSONResponse({ "status": "success", "synced": synced_count, "message": f"Synced {synced_count} books from Audiobookshelf" }) except Exception as e: return JSONResponse( {"status": "error", "message": str(e)}, status_code=500 ) @app.get("/api/recommendations/generate") async def generate_recommendations(db: AsyncSession = Depends(get_db)): """Generate new AI recommendations based on reading history.""" try: # Get finished books for context finished_result = await db.execute( select(ListeningSession, Book) .join(Book, ListeningSession.book_id == Book.id) .where(ListeningSession.is_finished == True) .order_by(ListeningSession.finished_at.desc()) .limit(20) ) finished_items = finished_result.all() if not finished_items: return JSONResponse({ "status": "error", "message": "No reading history found. Please sync with Audiobookshelf first." }) # Format reading history reading_history = [] for session, book in finished_items: reading_history.append({ "title": book.title, "author": book.author, "genres": json.loads(book.genres) if book.genres else [], "progress": session.progress, "is_finished": session.is_finished }) # Generate recommendations new_recs = await recommender.generate_recommendations( reading_history, num_recommendations=5 ) # Save to database for rec in new_recs: recommendation = Recommendation( title=rec.get("title"), author=rec.get("author"), description=rec.get("description"), reason=rec.get("reason"), genres=json.dumps(rec.get("genres", [])) ) db.add(recommendation) await db.commit() return JSONResponse({ "status": "success", "recommendations": new_recs, "count": len(new_recs) }) except Exception as e: return JSONResponse( {"status": "error", "message": str(e)}, status_code=500 ) @app.get("/api/recommendations") async def get_recommendations(db: AsyncSession = Depends(get_db)): """Get saved recommendations.""" result = await db.execute( select(Recommendation) .where(Recommendation.dismissed == False) .order_by(Recommendation.created_at.desc()) ) recommendations = result.scalars().all() return JSONResponse({ "recommendations": [ { "id": rec.id, "title": rec.title, "author": rec.author, "description": rec.description, "reason": rec.reason, "genres": json.loads(rec.genres) if rec.genres else [], "created_at": rec.created_at.isoformat() } for rec in recommendations ] }) @app.get("/api/history") async def get_listening_history(db: AsyncSession = Depends(get_db)): """Get listening history.""" result = await db.execute( select(ListeningSession, Book) .join(Book, ListeningSession.book_id == Book.id) .order_by(ListeningSession.last_update.desc()) ) items = result.all() return JSONResponse({ "history": [ { "book": { "id": book.id, "title": book.title, "author": book.author, "cover_url": book.cover_url, }, "session": { "progress": session.progress, "is_finished": session.is_finished, "started_at": session.started_at.isoformat() if session.started_at else None, "finished_at": session.finished_at.isoformat() if session.finished_at else None, } } for session, book in items ] }) @app.get("/health") async def health_check(): """Health check endpoint.""" return {"status": "healthy"}