Преглед на файлове

Add Audiobookshelf recommendation system

- FastAPI backend with async support
- Audiobookshelf API integration
- AI-powered recommendations using Claude
- SQLite database for tracking books and progress
- Jinja2 templates for web interface
- REST API endpoints for sync and recommendations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Brad Lance преди 3 месеца
родител
ревизия
e1e9d13f7c
променени са 15 файла, в които са добавени 1121 реда и са изтрити 0 реда
  1. 13 0
      .env.example
  2. 46 0
      .gitignore
  3. 41 0
      README.md
  4. 1 0
      app/__init__.py
  5. 68 0
      app/abs_client.py
  6. 30 0
      app/config.py
  7. 33 0
      app/database.py
  8. 297 0
      app/main.py
  9. 63 0
      app/models.py
  10. 125 0
      app/recommender.py
  11. 243 0
      app/static/css/style.css
  12. 45 0
      app/static/js/app.js
  13. 83 0
      app/templates/index.html
  14. 23 0
      main.py
  15. 10 0
      requirements.txt

+ 13 - 0
.env.example

@@ -0,0 +1,13 @@
+# Audiobookshelf Configuration
+ABS_URL=https://your-audiobookshelf-server.com
+ABS_API_TOKEN=your_api_token_here
+
+# AI Configuration (use one)
+ANTHROPIC_API_KEY=your_claude_api_key_here
+# OPENAI_API_KEY=your_openai_api_key_here
+
+# Application Configuration
+DATABASE_URL=sqlite:///./absrecommend.db
+SECRET_KEY=your-secret-key-here
+HOST=0.0.0.0
+PORT=8000

+ 46 - 0
.gitignore

@@ -0,0 +1,46 @@
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+env/
+venv/
+ENV/
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# Environment variables
+.env
+
+# Database
+*.db
+*.sqlite
+*.sqlite3
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Logs
+*.log

+ 41 - 0
README.md

@@ -0,0 +1,41 @@
+# Audiobookshelf Recommendation System
+
+A web application that tracks your Audiobookshelf listening history and provides AI-powered book recommendations.
+
+## Features
+
+- Syncs with Audiobookshelf server to track reading progress
+- AI-powered book recommendations based on your listening history
+- Web interface to view history and recommendations
+- REST API for programmatic access
+
+## Setup
+
+1. Install dependencies:
+```bash
+pip install -r requirements.txt
+```
+
+2. Configure environment variables:
+```bash
+cp .env.example .env
+# Edit .env with your Audiobookshelf URL, API token, and AI API key
+```
+
+3. Run the application:
+```bash
+python main.py
+```
+
+4. Access the web interface at `http://localhost:8000`
+
+## API Endpoints
+
+- `GET /` - Web interface
+- `GET /api/sync` - Sync with Audiobookshelf
+- `GET /api/recommendations` - Get book recommendations
+- `GET /api/history` - View listening history
+
+## Configuration
+
+See `.env.example` for all configuration options.

+ 1 - 0
app/__init__.py

@@ -0,0 +1 @@
+# Audiobookshelf Recommendation System

+ 68 - 0
app/abs_client.py

@@ -0,0 +1,68 @@
+import httpx
+from typing import List, Dict, Any
+from app.config import get_settings
+
+
+class AudiobookshelfClient:
+    """Client for interacting with Audiobookshelf API."""
+
+    def __init__(self):
+        settings = get_settings()
+        self.base_url = settings.abs_url.rstrip("/")
+        self.api_token = settings.abs_api_token
+        self.headers = {"Authorization": f"Bearer {self.api_token}"}
+
+    async def get_libraries(self) -> List[Dict[str, Any]]:
+        """Get all libraries from Audiobookshelf."""
+        async with httpx.AsyncClient() as client:
+            response = await client.get(
+                f"{self.base_url}/api/libraries", headers=self.headers
+            )
+            response.raise_for_status()
+            return response.json().get("libraries", [])
+
+    async def get_library_items(self, library_id: str) -> List[Dict[str, Any]]:
+        """Get all items in a library."""
+        async with httpx.AsyncClient() as client:
+            response = await client.get(
+                f"{self.base_url}/api/libraries/{library_id}/items",
+                headers=self.headers,
+            )
+            response.raise_for_status()
+            return response.json().get("results", [])
+
+    async def get_user_listening_sessions(self) -> List[Dict[str, Any]]:
+        """Get user's listening sessions."""
+        async with httpx.AsyncClient() as client:
+            response = await client.get(
+                f"{self.base_url}/api/me/listening-sessions", headers=self.headers
+            )
+            response.raise_for_status()
+            return response.json().get("sessions", [])
+
+    async def get_user_progress(self) -> List[Dict[str, Any]]:
+        """Get user's media progress for all items."""
+        async with httpx.AsyncClient() as client:
+            response = await client.get(
+                f"{self.base_url}/api/me/progress", headers=self.headers
+            )
+            response.raise_for_status()
+            return response.json().get("libraryItems", [])
+
+    async def get_item_details(self, item_id: str) -> Dict[str, Any]:
+        """Get detailed information about a specific library item."""
+        async with httpx.AsyncClient() as client:
+            response = await client.get(
+                f"{self.base_url}/api/items/{item_id}", headers=self.headers
+            )
+            response.raise_for_status()
+            return response.json()
+
+    async def get_user_info(self) -> Dict[str, Any]:
+        """Get current user information."""
+        async with httpx.AsyncClient() as client:
+            response = await client.get(
+                f"{self.base_url}/api/me", headers=self.headers
+            )
+            response.raise_for_status()
+            return response.json()

+ 30 - 0
app/config.py

@@ -0,0 +1,30 @@
+from pydantic_settings import BaseSettings
+from functools import lru_cache
+
+
+class Settings(BaseSettings):
+    """Application settings loaded from environment variables."""
+
+    # Audiobookshelf Configuration
+    abs_url: str
+    abs_api_token: str
+
+    # AI Configuration
+    anthropic_api_key: str | None = None
+    openai_api_key: str | None = None
+
+    # Application Configuration
+    database_url: str = "sqlite:///./absrecommend.db"
+    secret_key: str = "change-me-in-production"
+    host: str = "0.0.0.0"
+    port: int = 8000
+
+    class Config:
+        env_file = ".env"
+        case_sensitive = False
+
+
+@lru_cache()
+def get_settings() -> Settings:
+    """Get cached settings instance."""
+    return Settings()

+ 33 - 0
app/database.py

@@ -0,0 +1,33 @@
+from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
+from sqlalchemy.orm import sessionmaker
+from app.models import Base
+from app.config import get_settings
+
+
+settings = get_settings()
+
+# Convert sqlite:/// to sqlite+aiosqlite:/// for async support
+db_url = settings.database_url.replace("sqlite://", "sqlite+aiosqlite://")
+
+# Create async engine
+engine = create_async_engine(db_url, echo=True)
+
+# Create async session factory
+async_session = async_sessionmaker(
+    engine, class_=AsyncSession, expire_on_commit=False
+)
+
+
+async def init_db():
+    """Initialize database tables."""
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+
+
+async def get_db():
+    """Dependency for getting database session."""
+    async with async_session() as session:
+        try:
+            yield session
+        finally:
+            await session.close()

+ 297 - 0
app/main.py

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

+ 63 - 0
app/models.py

@@ -0,0 +1,63 @@
+from sqlalchemy import Column, String, Float, DateTime, Integer, Text, Boolean
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.sql import func
+from datetime import datetime
+
+Base = declarative_base()
+
+
+class Book(Base):
+    """Book information from Audiobookshelf."""
+    __tablename__ = "books"
+
+    id = Column(String, primary_key=True)  # Audiobookshelf book ID
+    title = Column(String, nullable=False)
+    author = Column(String)
+    narrator = Column(String)
+    description = Column(Text)
+    genres = Column(String)  # JSON string of genres
+    tags = Column(String)  # JSON string of tags
+    duration = Column(Float)  # Duration in seconds
+    cover_url = Column(String)
+
+    created_at = Column(DateTime, default=func.now())
+    updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
+
+
+class ListeningSession(Base):
+    """User listening sessions and progress."""
+    __tablename__ = "listening_sessions"
+
+    id = Column(Integer, primary_key=True, autoincrement=True)
+    book_id = Column(String, nullable=False)
+
+    # Progress tracking
+    progress = Column(Float, default=0.0)  # 0.0 to 1.0
+    current_time = Column(Float, default=0.0)  # Current position in seconds
+    is_finished = Column(Boolean, default=False)
+
+    # Timestamps
+    started_at = Column(DateTime)
+    finished_at = Column(DateTime, nullable=True)
+    last_update = Column(DateTime, default=func.now(), onupdate=func.now())
+
+    # Ratings and preferences
+    rating = Column(Integer, nullable=True)  # 1-5 stars, optional
+
+
+class Recommendation(Base):
+    """AI-generated book recommendations."""
+    __tablename__ = "recommendations"
+
+    id = Column(Integer, primary_key=True, autoincrement=True)
+
+    # Recommendation details
+    title = Column(String, nullable=False)
+    author = Column(String)
+    description = Column(Text)
+    reason = Column(Text)  # Why this book was recommended
+
+    # Metadata
+    genres = Column(String)  # JSON string
+    created_at = Column(DateTime, default=func.now())
+    dismissed = Column(Boolean, default=False)

+ 125 - 0
app/recommender.py

@@ -0,0 +1,125 @@
+from anthropic import Anthropic
+from typing import List, Dict, Any
+import json
+from app.config import get_settings
+
+
+class BookRecommender:
+    """AI-powered book recommendation engine using Claude."""
+
+    def __init__(self):
+        settings = get_settings()
+        if not settings.anthropic_api_key:
+            raise ValueError("ANTHROPIC_API_KEY not configured")
+        self.client = Anthropic(api_key=settings.anthropic_api_key)
+
+    async def generate_recommendations(
+        self,
+        reading_history: List[Dict[str, Any]],
+        num_recommendations: int = 5
+    ) -> List[Dict[str, Any]]:
+        """
+        Generate book recommendations based on reading history.
+
+        Args:
+            reading_history: List of books the user has read/listened to
+            num_recommendations: Number of recommendations to generate
+
+        Returns:
+            List of recommended books with title, author, description, and reason
+        """
+        # Build context from reading history
+        history_text = self._format_reading_history(reading_history)
+
+        # Create prompt for Claude
+        prompt = f"""Based on this reading history, recommend {num_recommendations} audiobooks that this person would enjoy.
+
+Reading History:
+{history_text}
+
+For each recommendation, provide:
+1. Title
+2. Author
+3. Brief description (2-3 sentences)
+4. Why you're recommending it based on their reading history (1-2 sentences)
+5. Genres (as a list)
+
+Format your response as a JSON array with objects containing: title, author, description, reason, genres.
+Only respond with the JSON array, no additional text."""
+
+        # Call Claude API
+        message = self.client.messages.create(
+            model="claude-3-5-sonnet-20241022",
+            max_tokens=2048,
+            messages=[{"role": "user", "content": prompt}]
+        )
+
+        # Parse response
+        response_text = message.content[0].text
+        try:
+            recommendations = json.loads(response_text)
+            return recommendations
+        except json.JSONDecodeError:
+            # If Claude didn't return valid JSON, try to extract it
+            # Look for JSON array in the response
+            start = response_text.find("[")
+            end = response_text.rfind("]") + 1
+            if start >= 0 and end > start:
+                recommendations = json.loads(response_text[start:end])
+                return recommendations
+            else:
+                raise ValueError("Failed to parse recommendations from AI response")
+
+    def _format_reading_history(self, history: List[Dict[str, Any]]) -> str:
+        """Format reading history for the AI prompt."""
+        formatted = []
+        for i, book in enumerate(history, 1):
+            title = book.get("title", "Unknown")
+            author = book.get("author", "Unknown")
+            genres = book.get("genres", [])
+            progress = book.get("progress", 0)
+            is_finished = book.get("is_finished", False)
+
+            status = "Finished" if is_finished else f"In Progress ({int(progress * 100)}%)"
+            genre_str = ", ".join(genres) if genres else "Unknown"
+
+            formatted.append(
+                f"{i}. {title} by {author}\n"
+                f"   Status: {status}\n"
+                f"   Genres: {genre_str}"
+            )
+
+        return "\n\n".join(formatted)
+
+    async def explain_recommendation(
+        self,
+        book_title: str,
+        book_author: str,
+        reading_history: List[Dict[str, Any]]
+    ) -> str:
+        """
+        Get a detailed explanation for why a specific book is recommended.
+
+        Args:
+            book_title: Title of the recommended book
+            book_author: Author of the recommended book
+            reading_history: User's reading history
+
+        Returns:
+            Detailed explanation text
+        """
+        history_text = self._format_reading_history(reading_history)
+
+        prompt = f"""Explain in detail why "{book_title}" by {book_author} would be a good recommendation for someone with this reading history:
+
+{history_text}
+
+Provide a thoughtful 2-3 paragraph explanation connecting specific aspects of the recommended book to their reading preferences."""
+
+        message = self.client.messages.create(
+            model="claude-3-5-sonnet-20241022",
+            max_tokens=512,
+            messages=[{"role": "user", "content": prompt}]
+        )
+
+        return message.content[0].text

+ 243 - 0
app/static/css/style.css

@@ -0,0 +1,243 @@
+* {
+    margin: 0;
+    padding: 0;
+    box-sizing: border-box;
+}
+
+body {
+    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+    line-height: 1.6;
+    color: #333;
+    background-color: #f5f5f5;
+}
+
+.container {
+    max-width: 1200px;
+    margin: 0 auto;
+    padding: 20px;
+}
+
+header {
+    text-align: center;
+    margin-bottom: 40px;
+    padding: 30px 0;
+    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+    color: white;
+    border-radius: 10px;
+}
+
+header h1 {
+    font-size: 2.5rem;
+    margin-bottom: 10px;
+}
+
+.subtitle {
+    font-size: 1.1rem;
+    opacity: 0.9;
+}
+
+.actions {
+    display: flex;
+    gap: 15px;
+    justify-content: center;
+    margin-bottom: 30px;
+}
+
+.btn {
+    padding: 12px 24px;
+    font-size: 1rem;
+    border: none;
+    border-radius: 6px;
+    cursor: pointer;
+    transition: all 0.3s ease;
+    font-weight: 500;
+}
+
+.btn-primary {
+    background-color: #667eea;
+    color: white;
+}
+
+.btn-primary:hover {
+    background-color: #5568d3;
+    transform: translateY(-2px);
+}
+
+.btn-secondary {
+    background-color: #764ba2;
+    color: white;
+}
+
+.btn-secondary:hover {
+    background-color: #643a8a;
+    transform: translateY(-2px);
+}
+
+.message {
+    padding: 15px 20px;
+    border-radius: 6px;
+    margin-bottom: 20px;
+    text-align: center;
+}
+
+.message.success {
+    background-color: #d4edda;
+    color: #155724;
+    border: 1px solid #c3e6cb;
+}
+
+.message.error {
+    background-color: #f8d7da;
+    color: #721c24;
+    border: 1px solid #f5c6cb;
+}
+
+.message.hidden {
+    display: none;
+}
+
+.section {
+    background: white;
+    padding: 30px;
+    border-radius: 10px;
+    margin-bottom: 30px;
+    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+}
+
+.section h2 {
+    font-size: 1.8rem;
+    margin-bottom: 20px;
+    color: #667eea;
+}
+
+.recommendations-grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+    gap: 20px;
+}
+
+.recommendation-card {
+    background: #f9f9f9;
+    padding: 20px;
+    border-radius: 8px;
+    border: 1px solid #e0e0e0;
+    transition: transform 0.2s ease, box-shadow 0.2s ease;
+}
+
+.recommendation-card:hover {
+    transform: translateY(-4px);
+    box-shadow: 0 4px 12px rgba(0,0,0,0.15);
+}
+
+.recommendation-card h3 {
+    font-size: 1.3rem;
+    margin-bottom: 5px;
+    color: #333;
+}
+
+.author {
+    color: #666;
+    font-style: italic;
+    margin-bottom: 10px;
+}
+
+.description {
+    margin-bottom: 15px;
+    line-height: 1.5;
+}
+
+.reason {
+    background: #fff;
+    padding: 10px;
+    border-left: 3px solid #667eea;
+    margin-bottom: 10px;
+}
+
+.reason strong {
+    color: #667eea;
+}
+
+.reason p {
+    margin-top: 5px;
+    font-size: 0.95rem;
+}
+
+.genres {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px;
+    margin-top: 10px;
+}
+
+.genre-tag {
+    background: #667eea;
+    color: white;
+    padding: 4px 12px;
+    border-radius: 20px;
+    font-size: 0.85rem;
+}
+
+.history-list {
+    display: flex;
+    flex-direction: column;
+    gap: 15px;
+}
+
+.history-item {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 15px;
+    background: #f9f9f9;
+    border-radius: 8px;
+    border: 1px solid #e0e0e0;
+}
+
+.book-info h3 {
+    font-size: 1.1rem;
+    margin-bottom: 5px;
+}
+
+.status {
+    padding: 6px 12px;
+    border-radius: 20px;
+    font-size: 0.9rem;
+    font-weight: 500;
+}
+
+.status.finished {
+    background-color: #d4edda;
+    color: #155724;
+}
+
+.status.in-progress {
+    background-color: #fff3cd;
+    color: #856404;
+}
+
+.empty-state {
+    text-align: center;
+    color: #999;
+    padding: 40px;
+    font-size: 1.1rem;
+}
+
+@media (max-width: 768px) {
+    header h1 {
+        font-size: 2rem;
+    }
+
+    .actions {
+        flex-direction: column;
+    }
+
+    .recommendations-grid {
+        grid-template-columns: 1fr;
+    }
+
+    .history-item {
+        flex-direction: column;
+        align-items: flex-start;
+        gap: 10px;
+    }
+}

+ 45 - 0
app/static/js/app.js

@@ -0,0 +1,45 @@
+function showMessage(text, type = 'success') {
+    const messageEl = document.getElementById('message');
+    messageEl.textContent = text;
+    messageEl.className = `message ${type}`;
+
+    setTimeout(() => {
+        messageEl.className = 'message hidden';
+    }, 5000);
+}
+
+async function syncLibrary() {
+    showMessage('Syncing with Audiobookshelf...', 'success');
+
+    try {
+        const response = await fetch('/api/sync');
+        const data = await response.json();
+
+        if (data.status === 'success') {
+            showMessage(data.message, 'success');
+            setTimeout(() => window.location.reload(), 1500);
+        } else {
+            showMessage(data.message || 'Sync failed', 'error');
+        }
+    } catch (error) {
+        showMessage('Error syncing with Audiobookshelf: ' + error.message, 'error');
+    }
+}
+
+async function generateRecommendations() {
+    showMessage('Generating AI recommendations...', 'success');
+
+    try {
+        const response = await fetch('/api/recommendations/generate');
+        const data = await response.json();
+
+        if (data.status === 'success') {
+            showMessage(`Generated ${data.count} new recommendations!`, 'success');
+            setTimeout(() => window.location.reload(), 1500);
+        } else {
+            showMessage(data.message || 'Failed to generate recommendations', 'error');
+        }
+    } catch (error) {
+        showMessage('Error generating recommendations: ' + error.message, 'error');
+    }
+}

+ 83 - 0
app/templates/index.html

@@ -0,0 +1,83 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Audiobookshelf Recommendations</title>
+    <link rel="stylesheet" href="/static/css/style.css">
+</head>
+<body>
+    <div class="container">
+        <header>
+            <h1>Audiobookshelf Recommendations</h1>
+            <p class="subtitle">AI-powered book recommendations based on your listening history</p>
+        </header>
+
+        <div class="actions">
+            <button onclick="syncLibrary()" class="btn btn-primary">Sync with Audiobookshelf</button>
+            <button onclick="generateRecommendations()" class="btn btn-secondary">Generate New Recommendations</button>
+        </div>
+
+        <div id="message" class="message hidden"></div>
+
+        <section class="section">
+            <h2>Your Recommendations</h2>
+            <div id="recommendations-container">
+                {% if recommendations %}
+                    <div class="recommendations-grid">
+                        {% for rec in recommendations %}
+                        <div class="recommendation-card">
+                            <h3>{{ rec.title }}</h3>
+                            <p class="author">by {{ rec.author }}</p>
+                            <p class="description">{{ rec.description }}</p>
+                            <div class="reason">
+                                <strong>Why this book:</strong>
+                                <p>{{ rec.reason }}</p>
+                            </div>
+                            {% if rec.genres %}
+                                <div class="genres">
+                                    {% for genre in rec.genres.split(',') if rec.genres else [] %}
+                                    <span class="genre-tag">{{ genre.strip() }}</span>
+                                    {% endfor %}
+                                </div>
+                            {% endif %}
+                        </div>
+                        {% endfor %}
+                    </div>
+                {% else %}
+                    <p class="empty-state">No recommendations yet. Sync your library and generate recommendations!</p>
+                {% endif %}
+            </div>
+        </section>
+
+        <section class="section">
+            <h2>Recent Listening History</h2>
+            <div id="history-container">
+                {% if books %}
+                    <div class="history-list">
+                        {% for item in books %}
+                        <div class="history-item">
+                            <div class="book-info">
+                                <h3>{{ item.book.title }}</h3>
+                                <p class="author">by {{ item.book.author }}</p>
+                            </div>
+                            <div class="progress-info">
+                                {% if item.session.is_finished %}
+                                    <span class="status finished">Finished</span>
+                                {% else %}
+                                    <span class="status in-progress">In Progress ({{ (item.session.progress * 100) | round | int }}%)</span>
+                                {% endif %}
+                            </div>
+                        </div>
+                        {% endfor %}
+                    </div>
+                {% else %}
+                    <p class="empty-state">No listening history found. Click "Sync with Audiobookshelf" to load your data.</p>
+                {% endif %}
+            </div>
+        </section>
+    </div>
+
+    <script src="/static/js/app.js"></script>
+</body>
+</html>

+ 23 - 0
main.py

@@ -0,0 +1,23 @@
+#!/usr/bin/env python3
+"""
+Audiobookshelf Recommendation System
+Main entry point for the application
+"""
+import uvicorn
+from app.config import get_settings
+
+
+def main():
+    """Run the FastAPI application."""
+    settings = get_settings()
+
+    uvicorn.run(
+        "app.main:app",
+        host=settings.host,
+        port=settings.port,
+        reload=True  # Enable auto-reload during development
+    )
+
+
+if __name__ == "__main__":
+    main()

+ 10 - 0
requirements.txt

@@ -0,0 +1,10 @@
+fastapi==0.109.0
+uvicorn[standard]==0.27.0
+jinja2==3.1.3
+python-dotenv==1.0.0
+httpx==0.26.0
+sqlalchemy==2.0.25
+aiosqlite==0.19.0
+anthropic==0.18.1
+pydantic==2.5.3
+pydantic-settings==2.1.0