|
|
@@ -0,0 +1,297 @@
|
|
|
+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"}
|