| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312 |
- 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"}
|