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 info which includes all media progress user_info = await abs_client.get_user_info() media_progress = user_info.get("mediaProgress", []) synced_count = 0 for progress_item in media_progress: # Skip podcast episodes, only process books if progress_item.get("mediaItemType") != "book": continue library_item_id = progress_item.get("libraryItemId") if not library_item_id: continue # Fetch full library item details try: item = await abs_client.get_item_details(library_item_id) except: # Skip if item not found continue # Extract book info media = item.get("media", {}) metadata = media.get("metadata", {}) book_id = 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 = progress_item.get("progress", 0) current_time = progress_item.get("currentTime", 0) is_finished = progress_item.get("isFinished", False) started_at_ts = progress_item.get("startedAt") finished_at_ts = progress_item.get("finishedAt") 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.fromtimestamp(started_at_ts / 1000) if started_at_ts else datetime.now(), finished_at=datetime.fromtimestamp(finished_at_ts / 1000) if finished_at_ts else None ) db.add(session) else: # Update existing session session.progress = progress_data session.current_time = current_time session.is_finished = is_finished if finished_at_ts and not session.finished_at: session.finished_at = datetime.fromtimestamp(finished_at_ts / 1000) 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"}