ソースを参照

Add multi-user authentication and reading log features

- Implement user registration and session-based authentication
- Add per-user Audiobookshelf API token encryption with Fernet
- Create reading statistics service with charts and analytics
- Add reading log page with book ratings and listening duration tracking
- Update all API routes to support multi-user data isolation
- Add migration script for existing single-user databases
- Create landing page for logged-out users
- Update UI with navigation bar and authentication flows
- Add CLAUDE.md documentation for project guidance

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Brad Lance 3 ヶ月 前
コミット
52245e6f69

+ 215 - 0
CLAUDE.md

@@ -0,0 +1,215 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+Audiobookshelf Recommendation System - A FastAPI web application that syncs with Audiobookshelf to track listening history and provide AI-powered book recommendations using Google Gemini.
+
+## Development Commands
+
+### Running the Application
+
+```bash
+# Start the development server (with auto-reload)
+python main.py
+
+# Or using uvicorn directly
+./venv/bin/python main.py
+```
+
+The application runs on `http://0.0.0.0:8000` by default (configurable via `.env`).
+
+### Dependency Management
+
+```bash
+# Install/update dependencies
+./venv/bin/pip install -r requirements.txt
+
+# Upgrade specific packages
+./venv/bin/pip install --upgrade google-generativeai
+```
+
+### Database
+
+The application uses SQLite with async support via `aiosqlite`. Database is auto-initialized on startup.
+
+```bash
+# Database file location
+./absrecommend.db
+
+# To reset database, simply delete the file
+rm absrecommend.db
+```
+
+## Architecture
+
+### Application Structure
+
+```
+app/
+├── main.py           # FastAPI routes and application setup
+├── models.py         # SQLAlchemy ORM models (Book, ListeningSession, Recommendation)
+├── database.py       # Database initialization and session management
+├── config.py         # Pydantic settings (loads from .env)
+├── abs_client.py     # Audiobookshelf API client
+├── recommender.py    # AI recommendation engine (Gemini integration)
+├── templates/        # Jinja2 HTML templates
+└── static/           # CSS and JavaScript files
+```
+
+### Key Architectural Patterns
+
+**Async-First Design**: The entire application uses async/await with:
+- `AsyncSession` for database operations
+- `httpx.AsyncClient` for HTTP requests to Audiobookshelf
+- FastAPI's async route handlers
+
+**Dependency Injection**: FastAPI dependencies are used for:
+- `get_db()` - Provides database sessions to routes
+- Configuration loaded via `get_settings()` with caching
+
+**Single-User Architecture (Current State)**:
+- Application uses a single global Audiobookshelf API token from environment
+- All data is shared (no user isolation)
+- `AudiobookshelfClient` and `BookRecommender` are initialized once at startup
+
+**Data Flow for Sync Operation**:
+1. User triggers `/api/sync` endpoint
+2. Fetches user's `mediaProgress` from Audiobookshelf `/api/me` endpoint
+3. For each book with progress, fetches full item details
+4. Creates/updates `Book` records (shared across all data)
+5. Creates/updates `ListeningSession` records with timestamps and progress
+6. Stores `started_at` and `finished_at` from Audiobookshelf (millisecond timestamps converted to datetime)
+
+**AI Recommendation Flow**:
+1. Query finished books from database (top 20 by finish date)
+2. Format reading history with title, author, genres, finish status
+3. Send to Gemini (`models/gemini-2.5-flash`) with structured prompt
+4. Parse JSON response into `Recommendation` records
+5. Store recommendations in database
+
+### Database Models
+
+**Book** - Shared book metadata from Audiobookshelf
+- Primary key: `id` (Audiobookshelf book ID)
+- Stores metadata: title, author, narrator, description, genres (JSON), tags (JSON), duration, cover URL
+- No user association (shared across all users)
+
+**ListeningSession** - User progress tracking
+- Links to Book via `book_id`
+- Progress tracking: `progress` (0.0-1.0), `current_time` (seconds), `is_finished`
+- Timestamps: `started_at`, `finished_at`, `last_update`
+- `rating` field exists but not currently populated
+
+**Recommendation** - AI-generated suggestions
+- Fields: title, author, description, reason (explanation), genres (JSON)
+- `dismissed` flag to hide recommendations
+- Currently no user association
+
+### External API Integration
+
+**Audiobookshelf API** (`app/abs_client.py`):
+- Authentication: Bearer token in Authorization header
+- Key endpoints used:
+  - `GET /api/me` - User info with `mediaProgress` array (all listening history)
+  - `GET /api/items/{id}` - Full book details
+  - `GET /api/libraries` - Available libraries
+- All timestamps from API are in milliseconds since epoch
+
+**Gemini API** (`app/recommender.py`):
+- Model: `models/gemini-2.5-flash`
+- SDK: `google.generativeai` (version >=0.8.0)
+- Generates structured JSON responses for book recommendations
+- Note: Deprecated `google.generativeai` package - migration to `google.genai` may be needed in future
+
+### Configuration
+
+Environment variables loaded via Pydantic settings (`app/config.py`):
+
+**Required**:
+- `ABS_URL` - Audiobookshelf server URL
+- `ABS_API_TOKEN` - Audiobookshelf API token
+- `GEMINI_API_KEY` - Google Gemini API key
+
+**Optional** (with defaults):
+- `DATABASE_URL` - SQLite database path (default: `sqlite:///./absrecommend.db`)
+- `SECRET_KEY` - Application secret key (default provided but should be changed)
+- `HOST` - Server bind address (default: `0.0.0.0`)
+- `PORT` - Server port (default: `8000`)
+
+### Important Implementation Details
+
+**Timestamp Handling**: Audiobookshelf returns timestamps in milliseconds. The sync operation converts these:
+```python
+datetime.fromtimestamp(timestamp_ms / 1000)
+```
+
+**JSON Fields**: `genres` and `tags` are stored as JSON strings in the database and must be serialized/deserialized:
+```python
+genres=json.dumps(metadata.get("genres", []))
+# Later: json.loads(book.genres)
+```
+
+**Book Filtering**: Sync only processes items where `mediaItemType == "book"` (excludes podcast episodes).
+
+**Session Management**: Database uses async context managers. Always use:
+```python
+async with async_session() as session:
+    # operations
+```
+
+## Common Development Tasks
+
+### Adding New API Endpoints
+
+1. Add route handler in `app/main.py`
+2. Use `Depends(get_db)` for database access
+3. Use async/await throughout
+4. Return `JSONResponse` for API endpoints or `HTMLResponse` with templates
+
+### Modifying Database Schema
+
+1. Update models in `app/models.py`
+2. Delete existing database file: `rm absrecommend.db`
+3. Restart application (database auto-initializes)
+4. Note: No migration system currently in place
+
+### Updating AI Prompts
+
+Edit `app/recommender.py`:
+- Modify prompt in `generate_recommendations()` method
+- Ensure prompt requests JSON format for parsing
+- Handle JSON parse errors with fallback extraction
+
+### Frontend Changes
+
+Templates use Jinja2 with server-side rendering:
+- HTML: `app/templates/`
+- CSS: `app/static/css/`
+- JavaScript: `app/static/js/`
+- Static files served at `/static/` path
+
+## Git Integration
+
+Repository is configured with Gogs at `https://git.mrbamm.xyz/blance/absRecommend`
+
+Authentication uses personal access token in remote URL.
+
+## Known Limitations
+
+- **Single-user only**: No authentication or multi-user support
+- **No migration system**: Schema changes require manual database reset
+- **Manual sync**: No automatic background syncing from Audiobookshelf
+- **Basic error handling**: API errors may not be gracefully handled in all cases
+- **Deprecated AI SDK**: Using `google.generativeai` which is deprecated in favor of `google.genai`
+
+## Future Enhancement Plan
+
+A comprehensive plan exists at `/home/blance/.claude/plans/golden-snuggling-ullman.md` for adding:
+- Multi-user authentication (per-user API tokens)
+- Reading log with statistics (finish dates, listening duration, ratings)
+- User management (login, registration, settings)
+- Enhanced UI with separate reading log page
+
+When implementing these features, follow the phased approach documented in the plan file.

+ 31 - 4
app/abs_client.py

@@ -6,10 +6,16 @@ from app.config import get_settings
 class AudiobookshelfClient:
 class AudiobookshelfClient:
     """Client for interacting with Audiobookshelf API."""
     """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
+    def __init__(self, abs_url: str, api_token: str):
+        """
+        Initialize Audiobookshelf client with credentials.
+
+        Args:
+            abs_url: Base URL of Audiobookshelf server
+            api_token: API token for authentication (unencrypted)
+        """
+        self.base_url = abs_url.rstrip("/")
+        self.api_token = api_token
         self.headers = {"Authorization": f"Bearer {self.api_token}"}
         self.headers = {"Authorization": f"Bearer {self.api_token}"}
 
 
     async def get_libraries(self) -> List[Dict[str, Any]]:
     async def get_libraries(self) -> List[Dict[str, Any]]:
@@ -68,3 +74,24 @@ class AudiobookshelfClient:
             )
             )
             response.raise_for_status()
             response.raise_for_status()
             return response.json()
             return response.json()
+
+
+def get_abs_client(user) -> AudiobookshelfClient:
+    """
+    Create an Audiobookshelf client for a specific user.
+
+    Args:
+        user: User model instance with abs_url and abs_api_token
+
+    Returns:
+        Configured AudiobookshelfClient instance
+    """
+    from app.auth import decrypt_token
+
+    # Decrypt the user's API token
+    decrypted_token = decrypt_token(user.abs_api_token)
+
+    return AudiobookshelfClient(
+        abs_url=user.abs_url,
+        api_token=decrypted_token
+    )

+ 248 - 0
app/auth.py

@@ -0,0 +1,248 @@
+"""
+Authentication and security utilities.
+
+Provides password hashing, token encryption, and session management.
+"""
+
+from datetime import datetime, timedelta
+from typing import Optional
+from fastapi import Depends, HTTPException, status, Request, Response
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+from passlib.hash import bcrypt
+from cryptography.fernet import Fernet
+from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
+import base64
+import os
+
+from app.models import User
+from app.database import get_db
+from app.config import get_settings
+
+
+# Session configuration
+SESSION_COOKIE_NAME = "session"
+SESSION_MAX_AGE = 60 * 60 * 24 * 30  # 30 days
+
+
+def get_password_hash(password: str) -> str:
+    """Hash a password using bcrypt."""
+    return bcrypt.hash(password)
+
+
+def verify_password(plain_password: str, hashed_password: str) -> bool:
+    """Verify a password against its hash."""
+    return bcrypt.verify(plain_password, hashed_password)
+
+
+def get_fernet_key() -> bytes:
+    """
+    Get or generate Fernet encryption key for API tokens.
+
+    Uses SECRET_KEY from settings to derive a consistent encryption key.
+    """
+    settings = get_settings()
+    # Derive a 32-byte key from SECRET_KEY
+    key = base64.urlsafe_b64encode(settings.secret_key.encode().ljust(32)[:32])
+    return key
+
+
+def encrypt_token(token: str) -> str:
+    """Encrypt an API token using Fernet."""
+    fernet = Fernet(get_fernet_key())
+    return fernet.encrypt(token.encode()).decode()
+
+
+def decrypt_token(encrypted_token: str) -> str:
+    """Decrypt an API token using Fernet."""
+    fernet = Fernet(get_fernet_key())
+    return fernet.decrypt(encrypted_token.encode()).decode()
+
+
+def get_serializer() -> URLSafeTimedSerializer:
+    """Get session serializer."""
+    settings = get_settings()
+    return URLSafeTimedSerializer(settings.secret_key)
+
+
+def create_session_token(user_id: int) -> str:
+    """Create a signed session token for a user."""
+    serializer = get_serializer()
+    return serializer.dumps({"user_id": user_id})
+
+
+def verify_session_token(token: str, max_age: int = SESSION_MAX_AGE) -> Optional[int]:
+    """
+    Verify a session token and return the user_id.
+
+    Returns None if token is invalid or expired.
+    """
+    serializer = get_serializer()
+    try:
+        data = serializer.loads(token, max_age=max_age)
+        return data.get("user_id")
+    except (BadSignature, SignatureExpired):
+        return None
+
+
+def set_session_cookie(response: Response, user_id: int):
+    """Set session cookie on response."""
+    token = create_session_token(user_id)
+    response.set_cookie(
+        key=SESSION_COOKIE_NAME,
+        value=token,
+        max_age=SESSION_MAX_AGE,
+        httponly=True,
+        samesite="lax",
+        # Set secure=True in production with HTTPS
+        secure=False
+    )
+
+
+def clear_session_cookie(response: Response):
+    """Clear session cookie."""
+    response.delete_cookie(key=SESSION_COOKIE_NAME)
+
+
+async def get_current_user(
+    request: Request,
+    db: AsyncSession = Depends(get_db)
+) -> User:
+    """
+    Get the current authenticated user from session cookie.
+
+    Raises 401 Unauthorized if not authenticated.
+    """
+    # Get session token from cookie
+    token = request.cookies.get(SESSION_COOKIE_NAME)
+
+    if not token:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Not authenticated"
+        )
+
+    # Verify token and get user_id
+    user_id = verify_session_token(token)
+
+    if user_id is None:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Invalid or expired session"
+        )
+
+    # Get user from database
+    result = await db.execute(
+        select(User).where(User.id == user_id, User.is_active == True)
+    )
+    user = result.scalar_one_or_none()
+
+    if not user:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="User not found or inactive"
+        )
+
+    return user
+
+
+async def get_current_user_optional(
+    request: Request,
+    db: AsyncSession = Depends(get_db)
+) -> Optional[User]:
+    """
+    Get the current authenticated user from session cookie.
+
+    Returns None if not authenticated (does not raise exception).
+    """
+    try:
+        return await get_current_user(request, db)
+    except HTTPException:
+        return None
+
+
+async def authenticate_user(
+    db: AsyncSession,
+    username: str,
+    password: str
+) -> Optional[User]:
+    """
+    Authenticate a user by username and password.
+
+    Returns User if authentication succeeds, None otherwise.
+    """
+    # Find user by username
+    result = await db.execute(
+        select(User).where(User.username == username)
+    )
+    user = result.scalar_one_or_none()
+
+    if not user:
+        return None
+
+    # Verify password
+    if not verify_password(password, user.hashed_password):
+        return None
+
+    # Check if user is active
+    if not user.is_active:
+        return None
+
+    # Update last login
+    user.last_login = datetime.now()
+    await db.commit()
+
+    return user
+
+
+async def create_user(
+    db: AsyncSession,
+    username: str,
+    email: str,
+    password: str,
+    abs_url: str,
+    abs_api_token: str,
+    display_name: Optional[str] = None
+) -> User:
+    """
+    Create a new user account.
+
+    Raises HTTPException if username or email already exists.
+    """
+    # Check if username already exists
+    result = await db.execute(
+        select(User).where(User.username == username)
+    )
+    if result.scalar_one_or_none():
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Username already registered"
+        )
+
+    # Check if email already exists
+    result = await db.execute(
+        select(User).where(User.email == email)
+    )
+    if result.scalar_one_or_none():
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Email already registered"
+        )
+
+    # Create new user
+    user = User(
+        username=username,
+        email=email,
+        hashed_password=get_password_hash(password),
+        abs_url=abs_url,
+        abs_api_token=encrypt_token(abs_api_token),
+        display_name=display_name or username,
+        created_at=datetime.now(),
+        is_active=True
+    )
+
+    db.add(user)
+    await db.commit()
+    await db.refresh(user)
+
+    return user

+ 5 - 4
app/config.py

@@ -5,9 +5,10 @@ from functools import lru_cache
 class Settings(BaseSettings):
 class Settings(BaseSettings):
     """Application settings loaded from environment variables."""
     """Application settings loaded from environment variables."""
 
 
-    # Audiobookshelf Configuration
-    abs_url: str
-    abs_api_token: str
+    # Audiobookshelf Configuration (Optional - for backward compatibility)
+    # In multi-user mode, each user provides their own credentials
+    abs_url: str | None = None
+    abs_api_token: str | None = None
 
 
     # AI Configuration
     # AI Configuration
     gemini_api_key: str | None = None
     gemini_api_key: str | None = None
@@ -16,7 +17,7 @@ class Settings(BaseSettings):
 
 
     # Application Configuration
     # Application Configuration
     database_url: str = "sqlite:///./absrecommend.db"
     database_url: str = "sqlite:///./absrecommend.db"
-    secret_key: str = "change-me-in-production"
+    secret_key: str = "change-me-in-production-please-use-a-strong-random-key"
     host: str = "0.0.0.0"
     host: str = "0.0.0.0"
     port: int = 8000
     port: int = 8000
 
 

+ 21 - 0
app/database.py

@@ -1,5 +1,6 @@
 from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
 from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
 from sqlalchemy.orm import sessionmaker
 from sqlalchemy.orm import sessionmaker
+from sqlalchemy import text
 from app.models import Base
 from app.models import Base
 from app.config import get_settings
 from app.config import get_settings
 
 
@@ -21,6 +22,26 @@ async_session = async_sessionmaker(
 async def init_db():
 async def init_db():
     """Initialize database tables."""
     """Initialize database tables."""
     async with engine.begin() as conn:
     async with engine.begin() as conn:
+        # Check if migration is needed (users table doesn't exist)
+        result = await conn.execute(text(
+            "SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
+        ))
+        needs_migration = result.fetchone() is None
+
+        # Check if we have old schema (listening_sessions exists but no users table)
+        result = await conn.execute(text(
+            "SELECT name FROM sqlite_master WHERE type='table' AND name='listening_sessions'"
+        ))
+        has_old_schema = result.fetchone() is not None
+
+        if needs_migration and has_old_schema:
+            # Need to run migration
+            print("Existing database detected without users table - migration required")
+            print("Please run: python -m app.migrations.add_multi_user")
+            print("Or delete absrecommend.db to start fresh")
+            raise RuntimeError("Database migration required")
+
+        # Create all tables (will skip existing ones)
         await conn.run_sync(Base.metadata.create_all)
         await conn.run_sync(Base.metadata.create_all)
 
 
 
 

+ 272 - 18
app/main.py

@@ -1,18 +1,28 @@
-from fastapi import FastAPI, Request, Depends
+from fastapi import FastAPI, Request, Depends, Form, Response, HTTPException, status
 from fastapi.templating import Jinja2Templates
 from fastapi.templating import Jinja2Templates
 from fastapi.staticfiles import StaticFiles
 from fastapi.staticfiles import StaticFiles
-from fastapi.responses import HTMLResponse, JSONResponse
+from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy import select
 from sqlalchemy import select
 from contextlib import asynccontextmanager
 from contextlib import asynccontextmanager
 import json
 import json
 from datetime import datetime
 from datetime import datetime
+from typing import Optional
 
 
 from app.database import init_db, get_db
 from app.database import init_db, get_db
-from app.models import Book, ListeningSession, Recommendation
-from app.abs_client import AudiobookshelfClient
+from app.models import Book, ListeningSession, Recommendation, User
+from app.abs_client import get_abs_client
 from app.recommender import BookRecommender
 from app.recommender import BookRecommender
 from app.config import get_settings
 from app.config import get_settings
+from app.auth import (
+    get_current_user,
+    get_current_user_optional,
+    authenticate_user,
+    create_user,
+    set_session_cookie,
+    clear_session_cookie
+)
+from app.services.stats import ReadingStatsService
 
 
 
 
 @asynccontextmanager
 @asynccontextmanager
@@ -33,17 +43,33 @@ app = FastAPI(
 templates = Jinja2Templates(directory="app/templates")
 templates = Jinja2Templates(directory="app/templates")
 app.mount("/static", StaticFiles(directory="app/static"), name="static")
 app.mount("/static", StaticFiles(directory="app/static"), name="static")
 
 
-# Initialize clients
-abs_client = AudiobookshelfClient()
+# Initialize recommender (shared across users)
 recommender = BookRecommender()
 recommender = BookRecommender()
 
 
 
 
 @app.get("/", response_class=HTMLResponse)
 @app.get("/", response_class=HTMLResponse)
-async def home(request: Request, db: AsyncSession = Depends(get_db)):
-    """Home page showing dashboard."""
-    # Get recent books and recommendations
+async def home(
+    request: Request,
+    db: AsyncSession = Depends(get_db),
+    user: Optional[User] = Depends(get_current_user_optional)
+):
+    """Home page showing dashboard or landing page."""
+    # If user not logged in, show landing page
+    if not user:
+        return templates.TemplateResponse(
+            "index.html",
+            {
+                "request": request,
+                "user": None,
+                "books": [],
+                "recommendations": []
+            }
+        )
+
+    # Get user's recent books and recommendations
     recent_sessions = await db.execute(
     recent_sessions = await db.execute(
         select(ListeningSession)
         select(ListeningSession)
+        .where(ListeningSession.user_id == user.id)
         .order_by(ListeningSession.last_update.desc())
         .order_by(ListeningSession.last_update.desc())
         .limit(10)
         .limit(10)
     )
     )
@@ -62,10 +88,13 @@ async def home(request: Request, db: AsyncSession = Depends(get_db)):
                 "session": session
                 "session": session
             })
             })
 
 
-    # Get recent recommendations
+    # Get user's recent recommendations
     recs_result = await db.execute(
     recs_result = await db.execute(
         select(Recommendation)
         select(Recommendation)
-        .where(Recommendation.dismissed == False)
+        .where(
+            Recommendation.user_id == user.id,
+            Recommendation.dismissed == False
+        )
         .order_by(Recommendation.created_at.desc())
         .order_by(Recommendation.created_at.desc())
         .limit(5)
         .limit(5)
     )
     )
@@ -75,16 +104,126 @@ async def home(request: Request, db: AsyncSession = Depends(get_db)):
         "index.html",
         "index.html",
         {
         {
             "request": request,
             "request": request,
+            "user": user,
             "books": books,
             "books": books,
             "recommendations": recommendations
             "recommendations": recommendations
         }
         }
     )
     )
 
 
 
 
+# ==================== Authentication Routes ====================
+
+
+@app.get("/login", response_class=HTMLResponse)
+async def login_page(request: Request):
+    """Login page."""
+    return templates.TemplateResponse("login.html", {"request": request})
+
+
+@app.post("/api/auth/login")
+async def login(
+    response: Response,
+    username: str = Form(...),
+    password: str = Form(...),
+    db: AsyncSession = Depends(get_db)
+):
+    """Authenticate user and create session."""
+    user = await authenticate_user(db, username, password)
+
+    if not user:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Incorrect username or password"
+        )
+
+    # Set session cookie
+    set_session_cookie(response, user.id)
+
+    return JSONResponse({
+        "status": "success",
+        "message": "Logged in successfully",
+        "user": {
+            "username": user.username,
+            "email": user.email,
+            "display_name": user.display_name
+        }
+    })
+
+
+@app.get("/register", response_class=HTMLResponse)
+async def register_page(request: Request):
+    """Registration page."""
+    return templates.TemplateResponse("register.html", {"request": request})
+
+
+@app.post("/api/auth/register")
+async def register(
+    response: Response,
+    username: str = Form(...),
+    email: str = Form(...),
+    password: str = Form(...),
+    abs_url: str = Form(...),
+    abs_api_token: str = Form(...),
+    display_name: Optional[str] = Form(None),
+    db: AsyncSession = Depends(get_db)
+):
+    """Register a new user."""
+    try:
+        user = await create_user(
+            db=db,
+            username=username,
+            email=email,
+            password=password,
+            abs_url=abs_url,
+            abs_api_token=abs_api_token,
+            display_name=display_name
+        )
+
+        # Set session cookie
+        set_session_cookie(response, user.id)
+
+        return JSONResponse({
+            "status": "success",
+            "message": "Account created successfully",
+            "user": {
+                "username": user.username,
+                "email": user.email,
+                "display_name": user.display_name
+            }
+        })
+
+    except HTTPException as e:
+        raise e
+    except Exception as e:
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            detail=str(e)
+        )
+
+
+@app.post("/api/auth/logout")
+async def logout(response: Response):
+    """Logout user and clear session."""
+    clear_session_cookie(response)
+    return JSONResponse({
+        "status": "success",
+        "message": "Logged out successfully"
+    })
+
+
+# ==================== API Routes ====================
+
+
 @app.get("/api/sync")
 @app.get("/api/sync")
-async def sync_with_audiobookshelf(db: AsyncSession = Depends(get_db)):
+async def sync_with_audiobookshelf(
+    db: AsyncSession = Depends(get_db),
+    user: User = Depends(get_current_user)
+):
     """Sync library and progress from Audiobookshelf."""
     """Sync library and progress from Audiobookshelf."""
     try:
     try:
+        # Create user-specific ABS client
+        abs_client = get_abs_client(user)
+
         # Get user info which includes all media progress
         # Get user info which includes all media progress
         user_info = await abs_client.get_user_info()
         user_info = await abs_client.get_user_info()
         media_progress = user_info.get("mediaProgress", [])
         media_progress = user_info.get("mediaProgress", [])
@@ -146,7 +285,10 @@ async def sync_with_audiobookshelf(db: AsyncSession = Depends(get_db)):
 
 
             session_result = await db.execute(
             session_result = await db.execute(
                 select(ListeningSession)
                 select(ListeningSession)
-                .where(ListeningSession.book_id == book_id)
+                .where(
+                    ListeningSession.user_id == user.id,
+                    ListeningSession.book_id == book_id
+                )
                 .order_by(ListeningSession.last_update.desc())
                 .order_by(ListeningSession.last_update.desc())
                 .limit(1)
                 .limit(1)
             )
             )
@@ -154,6 +296,7 @@ async def sync_with_audiobookshelf(db: AsyncSession = Depends(get_db)):
 
 
             if not session:
             if not session:
                 session = ListeningSession(
                 session = ListeningSession(
+                    user_id=user.id,
                     book_id=book_id,
                     book_id=book_id,
                     progress=progress_data,
                     progress=progress_data,
                     current_time=current_time,
                     current_time=current_time,
@@ -188,14 +331,20 @@ async def sync_with_audiobookshelf(db: AsyncSession = Depends(get_db)):
 
 
 
 
 @app.get("/api/recommendations/generate")
 @app.get("/api/recommendations/generate")
-async def generate_recommendations(db: AsyncSession = Depends(get_db)):
+async def generate_recommendations(
+    db: AsyncSession = Depends(get_db),
+    user: User = Depends(get_current_user)
+):
     """Generate new AI recommendations based on reading history."""
     """Generate new AI recommendations based on reading history."""
     try:
     try:
         # Get finished books for context
         # Get finished books for context
         finished_result = await db.execute(
         finished_result = await db.execute(
             select(ListeningSession, Book)
             select(ListeningSession, Book)
             .join(Book, ListeningSession.book_id == Book.id)
             .join(Book, ListeningSession.book_id == Book.id)
-            .where(ListeningSession.is_finished == True)
+            .where(
+                ListeningSession.user_id == user.id,
+                ListeningSession.is_finished == True
+            )
             .order_by(ListeningSession.finished_at.desc())
             .order_by(ListeningSession.finished_at.desc())
             .limit(20)
             .limit(20)
         )
         )
@@ -226,6 +375,7 @@ async def generate_recommendations(db: AsyncSession = Depends(get_db)):
         # Save to database
         # Save to database
         for rec in new_recs:
         for rec in new_recs:
             recommendation = Recommendation(
             recommendation = Recommendation(
+                user_id=user.id,
                 title=rec.get("title"),
                 title=rec.get("title"),
                 author=rec.get("author"),
                 author=rec.get("author"),
                 description=rec.get("description"),
                 description=rec.get("description"),
@@ -250,11 +400,17 @@ async def generate_recommendations(db: AsyncSession = Depends(get_db)):
 
 
 
 
 @app.get("/api/recommendations")
 @app.get("/api/recommendations")
-async def get_recommendations(db: AsyncSession = Depends(get_db)):
+async def get_recommendations(
+    db: AsyncSession = Depends(get_db),
+    user: User = Depends(get_current_user)
+):
     """Get saved recommendations."""
     """Get saved recommendations."""
     result = await db.execute(
     result = await db.execute(
         select(Recommendation)
         select(Recommendation)
-        .where(Recommendation.dismissed == False)
+        .where(
+            Recommendation.user_id == user.id,
+            Recommendation.dismissed == False
+        )
         .order_by(Recommendation.created_at.desc())
         .order_by(Recommendation.created_at.desc())
     )
     )
     recommendations = result.scalars().all()
     recommendations = result.scalars().all()
@@ -276,11 +432,15 @@ async def get_recommendations(db: AsyncSession = Depends(get_db)):
 
 
 
 
 @app.get("/api/history")
 @app.get("/api/history")
-async def get_listening_history(db: AsyncSession = Depends(get_db)):
+async def get_listening_history(
+    db: AsyncSession = Depends(get_db),
+    user: User = Depends(get_current_user)
+):
     """Get listening history."""
     """Get listening history."""
     result = await db.execute(
     result = await db.execute(
         select(ListeningSession, Book)
         select(ListeningSession, Book)
         .join(Book, ListeningSession.book_id == Book.id)
         .join(Book, ListeningSession.book_id == Book.id)
+        .where(ListeningSession.user_id == user.id)
         .order_by(ListeningSession.last_update.desc())
         .order_by(ListeningSession.last_update.desc())
     )
     )
     items = result.all()
     items = result.all()
@@ -306,6 +466,100 @@ async def get_listening_history(db: AsyncSession = Depends(get_db)):
     })
     })
 
 
 
 
+# ==================== Reading Log Routes ====================
+
+
+@app.get("/reading-log", response_class=HTMLResponse)
+async def reading_log_page(
+    request: Request,
+    user: User = Depends(get_current_user)
+):
+    """Reading log page with stats and filters."""
+    return templates.TemplateResponse(
+        "reading_log.html",
+        {
+            "request": request,
+            "user": user
+        }
+    )
+
+
+@app.get("/api/reading-log/stats")
+async def get_reading_stats(
+    db: AsyncSession = Depends(get_db),
+    user: User = Depends(get_current_user),
+    start_date: Optional[str] = None,
+    end_date: Optional[str] = None
+):
+    """Get reading statistics for the user."""
+    try:
+        # Parse dates if provided
+        start_dt = datetime.fromisoformat(start_date) if start_date else None
+        end_dt = datetime.fromisoformat(end_date) if end_date else None
+
+        # Calculate stats
+        stats_service = ReadingStatsService(db, user.id)
+        stats = await stats_service.calculate_stats(start_dt, end_dt)
+
+        return JSONResponse(stats)
+
+    except Exception as e:
+        return JSONResponse(
+            {"status": "error", "message": str(e)},
+            status_code=500
+        )
+
+
+@app.put("/api/sessions/{session_id}/rating")
+async def update_session_rating(
+    session_id: int,
+    rating: int = Form(...),
+    db: AsyncSession = Depends(get_db),
+    user: User = Depends(get_current_user)
+):
+    """Update the rating for a listening session."""
+    try:
+        # Validate rating
+        if rating < 1 or rating > 5:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="Rating must be between 1 and 5"
+            )
+
+        # Get session and verify ownership
+        result = await db.execute(
+            select(ListeningSession).where(
+                ListeningSession.id == session_id,
+                ListeningSession.user_id == user.id
+            )
+        )
+        session = result.scalar_one_or_none()
+
+        if not session:
+            raise HTTPException(
+                status_code=status.HTTP_404_NOT_FOUND,
+                detail="Session not found"
+            )
+
+        # Update rating
+        session.rating = rating
+        await db.commit()
+
+        return JSONResponse({
+            "status": "success",
+            "message": "Rating updated successfully",
+            "rating": rating
+        })
+
+    except HTTPException as e:
+        raise e
+    except Exception as e:
+        return JSONResponse(
+            {"status": "error", "message": str(e)},
+            status_code=500
+        )
+
+
 @app.get("/health")
 @app.get("/health")
 async def health_check():
 async def health_check():
     """Health check endpoint."""
     """Health check endpoint."""

+ 1 - 0
app/migrations/__init__.py

@@ -0,0 +1 @@
+"""Database migration scripts."""

+ 225 - 0
app/migrations/add_multi_user.py

@@ -0,0 +1,225 @@
+"""
+Migration script to add multi-user support.
+
+This script:
+1. Creates the User table
+2. Creates a default admin user from environment variables
+3. Migrates existing ListeningSession and Recommendation data to the default user
+"""
+
+import os
+import sys
+import asyncio
+from datetime import datetime
+from sqlalchemy import text, inspect
+from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
+from sqlalchemy.orm import sessionmaker
+
+# Add parent directory to path for imports
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from models import Base, User
+from config import get_settings
+
+
+async def run_migration():
+    """Run the multi-user migration."""
+    settings = get_settings()
+
+    # Convert sqlite:/// to sqlite+aiosqlite:///
+    db_url = settings.database_url.replace("sqlite:///", "sqlite+aiosqlite:///")
+
+    engine = create_async_engine(db_url, echo=True)
+    async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+
+    async with engine.begin() as conn:
+        # Check if migration has already been run
+        result = await conn.execute(text(
+            "SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
+        ))
+        if result.fetchone():
+            print("Migration already run - users table exists")
+            return
+
+        print("Starting multi-user migration...")
+
+        # Step 1: Backup existing data
+        print("Backing up existing listening_sessions...")
+        sessions_backup = await conn.execute(text(
+            "SELECT * FROM listening_sessions"
+        ))
+        sessions_data = sessions_backup.fetchall()
+        sessions_columns = sessions_backup.keys()
+
+        print("Backing up existing recommendations...")
+        recs_backup = await conn.execute(text(
+            "SELECT * FROM recommendations"
+        ))
+        recs_data = recs_backup.fetchall()
+        recs_columns = recs_backup.keys()
+
+        # Step 2: Create User table
+        print("Creating users table...")
+        await conn.execute(text("""
+            CREATE TABLE users (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                username VARCHAR NOT NULL UNIQUE,
+                email VARCHAR NOT NULL UNIQUE,
+                hashed_password VARCHAR NOT NULL,
+                abs_url VARCHAR NOT NULL,
+                abs_api_token VARCHAR NOT NULL,
+                display_name VARCHAR,
+                created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+                last_login DATETIME,
+                is_active BOOLEAN DEFAULT 1
+            )
+        """))
+
+        await conn.execute(text(
+            "CREATE INDEX ix_users_username ON users (username)"
+        ))
+        await conn.execute(text(
+            "CREATE INDEX ix_users_email ON users (email)"
+        ))
+
+        # Step 3: Create default admin user
+        print("Creating default admin user...")
+
+        # Get credentials from environment
+        abs_url = settings.abs_url if hasattr(settings, 'abs_url') else os.getenv('ABS_URL', '')
+        abs_token = settings.abs_api_token if hasattr(settings, 'abs_api_token') else os.getenv('ABS_API_TOKEN', '')
+
+        if not abs_url or not abs_token:
+            print("WARNING: No ABS_URL or ABS_API_TOKEN found in environment")
+            print("Creating placeholder admin user - update credentials in settings")
+            abs_url = "http://localhost:13378"
+            abs_token = "PLACEHOLDER_TOKEN"
+
+        # For now, use a simple hash - will be replaced with proper bcrypt later
+        # Password is "admin123" - user should change this immediately
+        from passlib.hash import bcrypt
+        default_password = bcrypt.hash("admin123")
+
+        await conn.execute(text("""
+            INSERT INTO users
+            (username, email, hashed_password, abs_url, abs_api_token, display_name, created_at, is_active)
+            VALUES
+            (:username, :email, :password, :abs_url, :abs_token, :display_name, :created_at, :is_active)
+        """), {
+            "username": "admin",
+            "email": "admin@localhost",
+            "password": default_password,
+            "abs_url": abs_url,
+            "abs_token": abs_token,
+            "display_name": "Admin User",
+            "created_at": datetime.now(),
+            "is_active": True
+        })
+
+        admin_id = (await conn.execute(text("SELECT last_insert_rowid()"))).scalar()
+        print(f"Created admin user with ID: {admin_id}")
+
+        # Step 4: Drop and recreate listening_sessions table
+        print("Recreating listening_sessions table with user_id...")
+        await conn.execute(text("DROP TABLE listening_sessions"))
+
+        await conn.execute(text("""
+            CREATE TABLE listening_sessions (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                user_id INTEGER NOT NULL,
+                book_id VARCHAR NOT NULL,
+                progress FLOAT DEFAULT 0.0,
+                current_time FLOAT DEFAULT 0.0,
+                is_finished BOOLEAN DEFAULT 0,
+                started_at DATETIME,
+                finished_at DATETIME,
+                last_update DATETIME DEFAULT CURRENT_TIMESTAMP,
+                rating INTEGER,
+                FOREIGN KEY (user_id) REFERENCES users (id)
+            )
+        """))
+
+        await conn.execute(text(
+            "CREATE INDEX ix_listening_sessions_user_id ON listening_sessions (user_id)"
+        ))
+
+        # Step 5: Migrate listening_sessions data
+        if sessions_data:
+            print(f"Migrating {len(sessions_data)} listening sessions to admin user...")
+            for row in sessions_data:
+                row_dict = dict(zip(sessions_columns, row))
+                await conn.execute(text("""
+                    INSERT INTO listening_sessions
+                    (user_id, book_id, progress, current_time, is_finished,
+                     started_at, finished_at, last_update, rating)
+                    VALUES
+                    (:user_id, :book_id, :progress, :current_time, :is_finished,
+                     :started_at, :finished_at, :last_update, :rating)
+                """), {
+                    "user_id": admin_id,
+                    "book_id": row_dict.get("book_id"),
+                    "progress": row_dict.get("progress", 0.0),
+                    "current_time": row_dict.get("current_time", 0.0),
+                    "is_finished": row_dict.get("is_finished", False),
+                    "started_at": row_dict.get("started_at"),
+                    "finished_at": row_dict.get("finished_at"),
+                    "last_update": row_dict.get("last_update"),
+                    "rating": row_dict.get("rating")
+                })
+
+        # Step 6: Drop and recreate recommendations table
+        print("Recreating recommendations table with user_id...")
+        await conn.execute(text("DROP TABLE recommendations"))
+
+        await conn.execute(text("""
+            CREATE TABLE recommendations (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                user_id INTEGER NOT NULL,
+                title VARCHAR NOT NULL,
+                author VARCHAR,
+                description TEXT,
+                reason TEXT,
+                genres VARCHAR,
+                created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+                dismissed BOOLEAN DEFAULT 0,
+                FOREIGN KEY (user_id) REFERENCES users (id)
+            )
+        """))
+
+        await conn.execute(text(
+            "CREATE INDEX ix_recommendations_user_id ON recommendations (user_id)"
+        ))
+
+        # Step 7: Migrate recommendations data
+        if recs_data:
+            print(f"Migrating {len(recs_data)} recommendations to admin user...")
+            for row in recs_data:
+                row_dict = dict(zip(recs_columns, row))
+                await conn.execute(text("""
+                    INSERT INTO recommendations
+                    (user_id, title, author, description, reason, genres, created_at, dismissed)
+                    VALUES
+                    (:user_id, :title, :author, :description, :reason, :genres, :created_at, :dismissed)
+                """), {
+                    "user_id": admin_id,
+                    "title": row_dict.get("title"),
+                    "author": row_dict.get("author"),
+                    "description": row_dict.get("description"),
+                    "reason": row_dict.get("reason"),
+                    "genres": row_dict.get("genres"),
+                    "created_at": row_dict.get("created_at"),
+                    "dismissed": row_dict.get("dismissed", False)
+                })
+
+        print("Migration completed successfully!")
+        print("\nDEFAULT ADMIN CREDENTIALS:")
+        print("  Username: admin")
+        print("  Password: admin123")
+        print("  Email: admin@localhost")
+        print("\nIMPORTANT: Change the admin password after first login!")
+
+    await engine.dispose()
+
+
+if __name__ == "__main__":
+    asyncio.run(run_migration())

+ 34 - 1
app/models.py

@@ -1,11 +1,36 @@
-from sqlalchemy import Column, String, Float, DateTime, Integer, Text, Boolean
+from sqlalchemy import Column, String, Float, DateTime, Integer, Text, Boolean, ForeignKey
 from sqlalchemy.ext.declarative import declarative_base
 from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.orm import relationship
 from sqlalchemy.sql import func
 from sqlalchemy.sql import func
 from datetime import datetime
 from datetime import datetime
 
 
 Base = declarative_base()
 Base = declarative_base()
 
 
 
 
+class User(Base):
+    """User account with Audiobookshelf credentials."""
+    __tablename__ = "users"
+
+    id = Column(Integer, primary_key=True, autoincrement=True)
+    username = Column(String, unique=True, nullable=False, index=True)
+    email = Column(String, unique=True, nullable=False, index=True)
+    hashed_password = Column(String, nullable=False)
+
+    # Per-user Audiobookshelf credentials
+    abs_url = Column(String, nullable=False)
+    abs_api_token = Column(String, nullable=False)  # Encrypted with Fernet
+
+    # Profile information
+    display_name = Column(String)
+    created_at = Column(DateTime, default=func.now())
+    last_login = Column(DateTime)
+    is_active = Column(Boolean, default=True)
+
+    # Relationships
+    listening_sessions = relationship("ListeningSession", back_populates="user", cascade="all, delete-orphan")
+    recommendations = relationship("Recommendation", back_populates="user", cascade="all, delete-orphan")
+
+
 class Book(Base):
 class Book(Base):
     """Book information from Audiobookshelf."""
     """Book information from Audiobookshelf."""
     __tablename__ = "books"
     __tablename__ = "books"
@@ -29,6 +54,7 @@ class ListeningSession(Base):
     __tablename__ = "listening_sessions"
     __tablename__ = "listening_sessions"
 
 
     id = Column(Integer, primary_key=True, autoincrement=True)
     id = Column(Integer, primary_key=True, autoincrement=True)
+    user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
     book_id = Column(String, nullable=False)
     book_id = Column(String, nullable=False)
 
 
     # Progress tracking
     # Progress tracking
@@ -44,12 +70,16 @@ class ListeningSession(Base):
     # Ratings and preferences
     # Ratings and preferences
     rating = Column(Integer, nullable=True)  # 1-5 stars, optional
     rating = Column(Integer, nullable=True)  # 1-5 stars, optional
 
 
+    # Relationships
+    user = relationship("User", back_populates="listening_sessions")
+
 
 
 class Recommendation(Base):
 class Recommendation(Base):
     """AI-generated book recommendations."""
     """AI-generated book recommendations."""
     __tablename__ = "recommendations"
     __tablename__ = "recommendations"
 
 
     id = Column(Integer, primary_key=True, autoincrement=True)
     id = Column(Integer, primary_key=True, autoincrement=True)
+    user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
 
 
     # Recommendation details
     # Recommendation details
     title = Column(String, nullable=False)
     title = Column(String, nullable=False)
@@ -61,3 +91,6 @@ class Recommendation(Base):
     genres = Column(String)  # JSON string
     genres = Column(String)  # JSON string
     created_at = Column(DateTime, default=func.now())
     created_at = Column(DateTime, default=func.now())
     dismissed = Column(Boolean, default=False)
     dismissed = Column(Boolean, default=False)
+
+    # Relationships
+    user = relationship("User", back_populates="recommendations")

+ 1 - 0
app/services/__init__.py

@@ -0,0 +1 @@
+"""Services package for business logic."""

+ 258 - 0
app/services/stats.py

@@ -0,0 +1,258 @@
+"""
+Reading statistics service.
+
+Calculates various statistics about user's reading habits.
+"""
+
+from datetime import datetime, timedelta
+from typing import Dict, Any, List, Optional
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, func, and_
+import json
+
+from app.models import ListeningSession, Book
+
+
+class ReadingStatsService:
+    """Service for calculating reading statistics."""
+
+    def __init__(self, db: AsyncSession, user_id: int):
+        """
+        Initialize statistics service for a user.
+
+        Args:
+            db: Database session
+            user_id: User ID to calculate stats for
+        """
+        self.db = db
+        self.user_id = user_id
+
+    async def calculate_stats(
+        self,
+        start_date: Optional[datetime] = None,
+        end_date: Optional[datetime] = None
+    ) -> Dict[str, Any]:
+        """
+        Calculate comprehensive reading statistics.
+
+        Args:
+            start_date: Optional start date filter
+            end_date: Optional end date filter
+
+        Returns:
+            Dictionary with various statistics
+        """
+        # Build base query
+        query = select(ListeningSession).where(
+            ListeningSession.user_id == self.user_id
+        )
+
+        if start_date:
+            query = query.where(ListeningSession.started_at >= start_date)
+        if end_date:
+            query = query.where(ListeningSession.started_at <= end_date)
+
+        result = await self.db.execute(query)
+        sessions = result.scalars().all()
+
+        # Calculate finished books
+        finished_sessions = [s for s in sessions if s.is_finished and s.finished_at]
+
+        # Calculate total listening time
+        total_hours = 0.0
+        for session in finished_sessions:
+            if session.started_at and session.finished_at:
+                duration = (session.finished_at - session.started_at).total_seconds() / 3600
+                total_hours += duration
+
+        # Get book details for finished books
+        finished_book_ids = [s.book_id for s in finished_sessions]
+        books_dict = {}
+        if finished_book_ids:
+            books_result = await self.db.execute(
+                select(Book).where(Book.id.in_(finished_book_ids))
+            )
+            books_dict = {book.id: book for book in books_result.scalars().all()}
+
+        # Calculate average rating
+        rated_sessions = [s for s in finished_sessions if s.rating]
+        avg_rating = (
+            sum(s.rating for s in rated_sessions) / len(rated_sessions)
+            if rated_sessions else None
+        )
+
+        # Calculate books per month
+        books_by_month = await self._calculate_books_by_month(finished_sessions)
+
+        # Calculate books by genre
+        books_by_genre = await self._calculate_books_by_genre(finished_sessions, books_dict)
+
+        # Calculate current streak
+        streak = await self._calculate_streak(finished_sessions)
+
+        # Calculate recent books
+        recent_books = await self._get_recent_books(finished_sessions, books_dict, limit=10)
+
+        return {
+            "total_books": len(finished_sessions),
+            "total_hours": round(total_hours, 1),
+            "average_rating": round(avg_rating, 1) if avg_rating else None,
+            "books_in_progress": len([s for s in sessions if not s.is_finished]),
+            "books_by_month": books_by_month,
+            "books_by_genre": books_by_genre,
+            "current_streak": streak,
+            "recent_books": recent_books,
+            "total_sessions": len(sessions)
+        }
+
+    async def _calculate_books_by_month(
+        self,
+        finished_sessions: List[ListeningSession]
+    ) -> List[Dict[str, Any]]:
+        """
+        Calculate books finished per month.
+
+        Returns:
+            List of {month, year, count} dictionaries
+        """
+        books_by_month = {}
+
+        for session in finished_sessions:
+            if not session.finished_at:
+                continue
+
+            month_key = (session.finished_at.year, session.finished_at.month)
+            books_by_month[month_key] = books_by_month.get(month_key, 0) + 1
+
+        # Convert to list and sort
+        result = [
+            {
+                "year": year,
+                "month": month,
+                "count": count,
+                "month_name": datetime(year, month, 1).strftime("%B")
+            }
+            for (year, month), count in sorted(books_by_month.items())
+        ]
+
+        return result
+
+    async def _calculate_books_by_genre(
+        self,
+        finished_sessions: List[ListeningSession],
+        books_dict: Dict[str, Book]
+    ) -> List[Dict[str, Any]]:
+        """
+        Calculate books finished by genre.
+
+        Returns:
+            List of {genre, count} dictionaries sorted by count
+        """
+        genre_counts = {}
+
+        for session in finished_sessions:
+            book = books_dict.get(session.book_id)
+            if not book or not book.genres:
+                continue
+
+            try:
+                genres = json.loads(book.genres) if isinstance(book.genres, str) else book.genres
+                for genre in genres:
+                    genre_counts[genre] = genre_counts.get(genre, 0) + 1
+            except (json.JSONDecodeError, TypeError):
+                continue
+
+        # Sort by count descending
+        result = [
+            {"genre": genre, "count": count}
+            for genre, count in sorted(
+                genre_counts.items(),
+                key=lambda x: x[1],
+                reverse=True
+            )
+        ]
+
+        return result
+
+    async def _calculate_streak(
+        self,
+        finished_sessions: List[ListeningSession]
+    ) -> int:
+        """
+        Calculate current reading streak (consecutive days with finished books).
+
+        Returns:
+            Number of consecutive days
+        """
+        if not finished_sessions:
+            return 0
+
+        # Get unique finish dates, sorted descending
+        finish_dates = sorted(
+            {s.finished_at.date() for s in finished_sessions if s.finished_at},
+            reverse=True
+        )
+
+        if not finish_dates:
+            return 0
+
+        # Check if most recent is today or yesterday
+        today = datetime.now().date()
+        if finish_dates[0] not in [today, today - timedelta(days=1)]:
+            return 0
+
+        # Count consecutive days
+        streak = 1
+        for i in range(len(finish_dates) - 1):
+            diff = (finish_dates[i] - finish_dates[i + 1]).days
+            if diff == 1:
+                streak += 1
+            elif diff == 0:
+                # Same day, continue
+                continue
+            else:
+                break
+
+        return streak
+
+    async def _get_recent_books(
+        self,
+        finished_sessions: List[ListeningSession],
+        books_dict: Dict[str, Book],
+        limit: int = 10
+    ) -> List[Dict[str, Any]]:
+        """
+        Get recently finished books with details.
+
+        Returns:
+            List of book details with finish date and rating
+        """
+        # Sort by finish date descending
+        sorted_sessions = sorted(
+            [s for s in finished_sessions if s.finished_at],
+            key=lambda x: x.finished_at,
+            reverse=True
+        )[:limit]
+
+        recent = []
+        for session in sorted_sessions:
+            book = books_dict.get(session.book_id)
+            if not book:
+                continue
+
+            listening_duration = None
+            if session.started_at and session.finished_at:
+                duration_hours = (session.finished_at - session.started_at).total_seconds() / 3600
+                listening_duration = round(duration_hours, 1)
+
+            recent.append({
+                "book_id": book.id,
+                "title": book.title,
+                "author": book.author,
+                "finished_at": session.finished_at.isoformat(),
+                "rating": session.rating,
+                "listening_duration": listening_duration,
+                "cover_url": book.cover_url
+            })
+
+        return recent

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

@@ -222,6 +222,376 @@ header h1 {
     font-size: 1.1rem;
     font-size: 1.1rem;
 }
 }
 
 
+/* ==================== Navigation ====================*/
+
+.navbar {
+    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+    color: white;
+    padding: 0;
+    margin-bottom: 20px;
+    box-shadow: 0 2px 8px rgba(0,0,0,0.1);
+}
+
+.nav-container {
+    max-width: 1200px;
+    margin: 0 auto;
+    padding: 15px 20px;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+}
+
+.nav-brand a {
+    color: white;
+    text-decoration: none;
+    font-size: 1.3rem;
+    font-weight: 600;
+}
+
+.nav-links {
+    display: flex;
+    list-style: none;
+    gap: 30px;
+}
+
+.nav-links a {
+    color: white;
+    text-decoration: none;
+    transition: opacity 0.3s ease;
+}
+
+.nav-links a:hover {
+    opacity: 0.8;
+}
+
+.nav-user {
+    display: flex;
+    align-items: center;
+    gap: 15px;
+}
+
+.user-name {
+    font-weight: 500;
+}
+
+.btn-text {
+    background: transparent;
+    color: white;
+    border: 1px solid rgba(255,255,255,0.3);
+    padding: 8px 16px;
+}
+
+.btn-text:hover {
+    background: rgba(255,255,255,0.1);
+}
+
+
+/* ==================== Authentication Pages ====================*/
+
+.auth-container {
+    min-height: 100vh;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 20px;
+    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+}
+
+.auth-card {
+    background: white;
+    padding: 40px;
+    border-radius: 12px;
+    box-shadow: 0 8px 24px rgba(0,0,0,0.15);
+    max-width: 480px;
+    width: 100%;
+}
+
+.auth-card h1 {
+    text-align: center;
+    color: #333;
+    margin-bottom: 10px;
+    font-size: 2rem;
+}
+
+.auth-subtitle {
+    text-align: center;
+    color: #666;
+    margin-bottom: 30px;
+}
+
+.form-group {
+    margin-bottom: 20px;
+}
+
+.form-group label {
+    display: block;
+    margin-bottom: 8px;
+    color: #333;
+    font-weight: 500;
+}
+
+.form-group input {
+    width: 100%;
+    padding: 12px;
+    border: 1px solid #ddd;
+    border-radius: 6px;
+    font-size: 1rem;
+    transition: border-color 0.3s ease;
+}
+
+.form-group input:focus {
+    outline: none;
+    border-color: #667eea;
+    box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+}
+
+.form-group small {
+    display: block;
+    margin-top: 5px;
+    color: #666;
+    font-size: 0.85rem;
+}
+
+.form-section {
+    margin-top: 30px;
+    padding-top: 30px;
+    border-top: 1px solid #e0e0e0;
+}
+
+.form-section h3 {
+    color: #667eea;
+    margin-bottom: 10px;
+}
+
+.form-help {
+    color: #666;
+    font-size: 0.9rem;
+    margin-bottom: 20px;
+}
+
+.btn-full {
+    width: 100%;
+    padding: 14px;
+    font-size: 1.1rem;
+}
+
+.auth-footer {
+    text-align: center;
+    margin-top: 20px;
+    padding-top: 20px;
+    border-top: 1px solid #e0e0e0;
+}
+
+.auth-footer a {
+    color: #667eea;
+    text-decoration: none;
+    font-weight: 500;
+}
+
+.auth-footer a:hover {
+    text-decoration: underline;
+}
+
+
+/* ==================== Landing Page ====================*/
+
+.landing-container {
+    min-height: 100vh;
+    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+    color: white;
+    padding: 40px 20px;
+}
+
+.landing-hero {
+    text-align: center;
+    max-width: 800px;
+    margin: 0 auto 80px;
+    padding-top: 80px;
+}
+
+.landing-hero h1 {
+    font-size: 3.5rem;
+    margin-bottom: 20px;
+}
+
+.landing-subtitle {
+    font-size: 1.3rem;
+    opacity: 0.95;
+    margin-bottom: 40px;
+}
+
+.landing-actions {
+    display: flex;
+    gap: 20px;
+    justify-content: center;
+}
+
+.btn-large {
+    padding: 16px 40px;
+    font-size: 1.2rem;
+    text-decoration: none;
+    display: inline-block;
+}
+
+.landing-features {
+    max-width: 1000px;
+    margin: 0 auto;
+}
+
+.landing-features h2 {
+    text-align: center;
+    font-size: 2.5rem;
+    margin-bottom: 40px;
+}
+
+.features-grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+    gap: 30px;
+}
+
+.feature-card {
+    background: rgba(255,255,255,0.1);
+    padding: 30px;
+    border-radius: 12px;
+    backdrop-filter: blur(10px);
+    border: 1px solid rgba(255,255,255,0.2);
+}
+
+.feature-card h3 {
+    font-size: 1.5rem;
+    margin-bottom: 15px;
+}
+
+.feature-card p {
+    opacity: 0.9;
+    line-height: 1.6;
+}
+
+
+/* ==================== Reading Log ====================*/
+
+.stats-grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+    gap: 20px;
+    margin-bottom: 30px;
+}
+
+.stat-card {
+    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+    color: white;
+    padding: 25px;
+    border-radius: 10px;
+    text-align: center;
+    box-shadow: 0 4px 12px rgba(0,0,0,0.1);
+}
+
+.stat-value {
+    font-size: 2.5rem;
+    font-weight: bold;
+    margin-bottom: 5px;
+}
+
+.stat-label {
+    font-size: 0.95rem;
+    opacity: 0.9;
+}
+
+.charts-container {
+    display: grid;
+    grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
+    gap: 30px;
+}
+
+.chart-card {
+    background: white;
+    padding: 20px;
+    border-radius: 10px;
+    box-shadow: 0 2px 8px rgba(0,0,0,0.1);
+}
+
+.chart-card h3 {
+    color: #667eea;
+    margin-bottom: 20px;
+    text-align: center;
+}
+
+.books-grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+    gap: 20px;
+}
+
+.book-card {
+    background: #f9f9f9;
+    padding: 20px;
+    border-radius: 8px;
+    border: 1px solid #e0e0e0;
+    transition: transform 0.2s ease, box-shadow 0.2s ease;
+}
+
+.book-card:hover {
+    transform: translateY(-4px);
+    box-shadow: 0 4px 12px rgba(0,0,0,0.15);
+}
+
+.book-cover {
+    width: 100%;
+    height: 200px;
+    object-fit: cover;
+    border-radius: 6px;
+    margin-bottom: 15px;
+}
+
+.book-title {
+    font-size: 1.2rem;
+    margin-bottom: 5px;
+    color: #333;
+}
+
+.book-author {
+    color: #666;
+    font-style: italic;
+    margin-bottom: 10px;
+}
+
+.book-meta {
+    display: flex;
+    justify-content: space-between;
+    font-size: 0.85rem;
+    color: #666;
+    margin-bottom: 10px;
+}
+
+.book-rating {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-top: 15px;
+    padding-top: 15px;
+    border-top: 1px solid #e0e0e0;
+}
+
+.rating-stars {
+    color: #f59e0b;
+    font-size: 1.2rem;
+}
+
+.btn-small {
+    padding: 6px 14px;
+    font-size: 0.85rem;
+}
+
+.loading {
+    text-align: center;
+    padding: 40px;
+    color: #999;
+}
+
+
+/* ==================== Responsive Design ====================*/
+
 @media (max-width: 768px) {
 @media (max-width: 768px) {
     header h1 {
     header h1 {
         font-size: 2rem;
         font-size: 2rem;
@@ -240,4 +610,30 @@ header h1 {
         align-items: flex-start;
         align-items: flex-start;
         gap: 10px;
         gap: 10px;
     }
     }
+
+    .nav-container {
+        flex-direction: column;
+        gap: 15px;
+    }
+
+    .nav-links {
+        gap: 15px;
+    }
+
+    .landing-hero h1 {
+        font-size: 2.5rem;
+    }
+
+    .landing-actions {
+        flex-direction: column;
+        align-items: center;
+    }
+
+    .charts-container {
+        grid-template-columns: 1fr;
+    }
+
+    .stats-grid {
+        grid-template-columns: repeat(2, 1fr);
+    }
 }
 }

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

@@ -1,5 +1,9 @@
+// ==================== Utility Functions ====================
+
 function showMessage(text, type = 'success') {
 function showMessage(text, type = 'success') {
     const messageEl = document.getElementById('message');
     const messageEl = document.getElementById('message');
+    if (!messageEl) return;
+
     messageEl.textContent = text;
     messageEl.textContent = text;
     messageEl.className = `message ${type}`;
     messageEl.className = `message ${type}`;
 
 
@@ -8,11 +12,100 @@ function showMessage(text, type = 'success') {
     }, 5000);
     }, 5000);
 }
 }
 
 
+async function handleApiError(response) {
+    if (response.status === 401) {
+        // Unauthorized - redirect to login
+        window.location.href = '/login';
+        return true;
+    }
+    return false;
+}
+
+
+// ==================== Authentication Functions ====================
+
+async function handleLogin(event) {
+    event.preventDefault();
+
+    const form = event.target;
+    const formData = new FormData(form);
+
+    try {
+        const response = await fetch('/api/auth/login', {
+            method: 'POST',
+            body: formData
+        });
+
+        const data = await response.json();
+
+        if (response.ok && data.status === 'success') {
+            showMessage('Login successful! Redirecting...', 'success');
+            setTimeout(() => {
+                window.location.href = '/';
+            }, 1000);
+        } else {
+            showMessage(data.detail || 'Login failed', 'error');
+        }
+    } catch (error) {
+        showMessage('Error logging in: ' + error.message, 'error');
+    }
+}
+
+async function handleRegister(event) {
+    event.preventDefault();
+
+    const form = event.target;
+    const formData = new FormData(form);
+
+    try {
+        const response = await fetch('/api/auth/register', {
+            method: 'POST',
+            body: formData
+        });
+
+        const data = await response.json();
+
+        if (response.ok && data.status === 'success') {
+            showMessage('Account created successfully! Redirecting...', 'success');
+            setTimeout(() => {
+                window.location.href = '/';
+            }, 1000);
+        } else {
+            showMessage(data.detail || 'Registration failed', 'error');
+        }
+    } catch (error) {
+        showMessage('Error creating account: ' + error.message, 'error');
+    }
+}
+
+async function logout() {
+    try {
+        const response = await fetch('/api/auth/logout', {
+            method: 'POST'
+        });
+
+        const data = await response.json();
+
+        if (data.status === 'success') {
+            window.location.href = '/';
+        }
+    } catch (error) {
+        console.error('Logout error:', error);
+        window.location.href = '/';
+    }
+}
+
+
+// ==================== Dashboard Functions ====================
+
 async function syncLibrary() {
 async function syncLibrary() {
     showMessage('Syncing with Audiobookshelf...', 'success');
     showMessage('Syncing with Audiobookshelf...', 'success');
 
 
     try {
     try {
         const response = await fetch('/api/sync');
         const response = await fetch('/api/sync');
+
+        if (await handleApiError(response)) return;
+
         const data = await response.json();
         const data = await response.json();
 
 
         if (data.status === 'success') {
         if (data.status === 'success') {
@@ -31,6 +124,9 @@ async function generateRecommendations() {
 
 
     try {
     try {
         const response = await fetch('/api/recommendations/generate');
         const response = await fetch('/api/recommendations/generate');
+
+        if (await handleApiError(response)) return;
+
         const data = await response.json();
         const data = await response.json();
 
 
         if (data.status === 'success') {
         if (data.status === 'success') {

+ 211 - 0
app/static/js/reading-log.js

@@ -0,0 +1,211 @@
+// ==================== Reading Log Functions ====================
+
+let booksPerMonthChart = null;
+let genresChart = null;
+
+async function loadReadingStats() {
+    try {
+        const response = await fetch('/api/reading-log/stats');
+
+        if (response.status === 401) {
+            window.location.href = '/login';
+            return;
+        }
+
+        const stats = await response.json();
+
+        // Hide loading, show stats
+        document.getElementById('stats-loading').classList.add('hidden');
+        document.getElementById('stats-container').classList.remove('hidden');
+
+        // Update stat cards
+        document.getElementById('stat-total-books').textContent = stats.total_books || 0;
+        document.getElementById('stat-total-hours').textContent = stats.total_hours || 0;
+        document.getElementById('stat-avg-rating').textContent =
+            stats.average_rating ? `${stats.average_rating}/5` : 'N/A';
+        document.getElementById('stat-streak').textContent = stats.current_streak || 0;
+
+        // Render charts
+        renderBooksPerMonthChart(stats.books_by_month || []);
+        renderGenresChart(stats.books_by_genre || []);
+
+        // Render recent books
+        renderRecentBooks(stats.recent_books || []);
+
+    } catch (error) {
+        console.error('Error loading stats:', error);
+        showMessage('Error loading statistics: ' + error.message, 'error');
+    }
+}
+
+function renderBooksPerMonthChart(booksPerMonth) {
+    const ctx = document.getElementById('books-per-month-chart');
+    if (!ctx) return;
+
+    // Destroy existing chart
+    if (booksPerMonthChart) {
+        booksPerMonthChart.destroy();
+    }
+
+    // Prepare data - show last 12 months
+    const labels = booksPerMonth.slice(-12).map(item => `${item.month_name} ${item.year}`);
+    const data = booksPerMonth.slice(-12).map(item => item.count);
+
+    booksPerMonthChart = new Chart(ctx, {
+        type: 'bar',
+        data: {
+            labels: labels,
+            datasets: [{
+                label: 'Books Finished',
+                data: data,
+                backgroundColor: 'rgba(99, 102, 241, 0.7)',
+                borderColor: 'rgba(99, 102, 241, 1)',
+                borderWidth: 1
+            }]
+        },
+        options: {
+            responsive: true,
+            maintainAspectRatio: true,
+            scales: {
+                y: {
+                    beginAtZero: true,
+                    ticks: {
+                        stepSize: 1
+                    }
+                }
+            },
+            plugins: {
+                legend: {
+                    display: false
+                }
+            }
+        }
+    });
+}
+
+function renderGenresChart(booksByGenre) {
+    const ctx = document.getElementById('genres-chart');
+    if (!ctx) return;
+
+    // Destroy existing chart
+    if (genresChart) {
+        genresChart.destroy();
+    }
+
+    // Show top 8 genres
+    const topGenres = booksByGenre.slice(0, 8);
+    const labels = topGenres.map(item => item.genre);
+    const data = topGenres.map(item => item.count);
+
+    // Generate colors
+    const colors = [
+        'rgba(239, 68, 68, 0.7)',
+        'rgba(249, 115, 22, 0.7)',
+        'rgba(234, 179, 8, 0.7)',
+        'rgba(34, 197, 94, 0.7)',
+        'rgba(20, 184, 166, 0.7)',
+        'rgba(59, 130, 246, 0.7)',
+        'rgba(99, 102, 241, 0.7)',
+        'rgba(168, 85, 247, 0.7)'
+    ];
+
+    genresChart = new Chart(ctx, {
+        type: 'doughnut',
+        data: {
+            labels: labels,
+            datasets: [{
+                data: data,
+                backgroundColor: colors,
+                borderWidth: 2,
+                borderColor: '#ffffff'
+            }]
+        },
+        options: {
+            responsive: true,
+            maintainAspectRatio: true,
+            plugins: {
+                legend: {
+                    position: 'right'
+                }
+            }
+        }
+    });
+}
+
+function renderRecentBooks(recentBooks) {
+    const listEl = document.getElementById('books-list');
+    const loadingEl = document.getElementById('books-loading');
+    const emptyEl = document.getElementById('books-empty');
+
+    loadingEl.classList.add('hidden');
+
+    if (recentBooks.length === 0) {
+        emptyEl.classList.remove('hidden');
+        return;
+    }
+
+    listEl.classList.remove('hidden');
+
+    const html = recentBooks.map(book => {
+        const finishedDate = new Date(book.finished_at).toLocaleDateString();
+        const ratingStars = book.rating ? '★'.repeat(book.rating) + '☆'.repeat(5 - book.rating) : 'Not rated';
+
+        return `
+            <div class="book-card">
+                ${book.cover_url ? `<img src="${book.cover_url}" alt="${book.title}" class="book-cover">` : ''}
+                <div class="book-details">
+                    <h3 class="book-title">${book.title}</h3>
+                    <p class="book-author">by ${book.author}</p>
+                    <div class="book-meta">
+                        <span class="book-date">Finished: ${finishedDate}</span>
+                        ${book.listening_duration ? `<span class="book-duration">${book.listening_duration}h</span>` : ''}
+                    </div>
+                    <div class="book-rating">
+                        <span class="rating-stars">${ratingStars}</span>
+                        <button class="btn btn-small" onclick="promptRating(${book.book_id})">Rate</button>
+                    </div>
+                </div>
+            </div>
+        `;
+    }).join('');
+
+    listEl.innerHTML = html;
+}
+
+async function promptRating(sessionId) {
+    const rating = prompt('Rate this book (1-5 stars):');
+
+    if (!rating) return;
+
+    const ratingNum = parseInt(rating);
+    if (isNaN(ratingNum) || ratingNum < 1 || ratingNum > 5) {
+        showMessage('Please enter a rating between 1 and 5', 'error');
+        return;
+    }
+
+    try {
+        const formData = new FormData();
+        formData.append('rating', ratingNum);
+
+        const response = await fetch(`/api/sessions/${sessionId}/rating`, {
+            method: 'PUT',
+            body: formData
+        });
+
+        if (response.status === 401) {
+            window.location.href = '/login';
+            return;
+        }
+
+        const data = await response.json();
+
+        if (data.status === 'success') {
+            showMessage('Rating updated successfully!', 'success');
+            loadReadingStats(); // Reload stats
+        } else {
+            showMessage(data.message || 'Failed to update rating', 'error');
+        }
+    } catch (error) {
+        showMessage('Error updating rating: ' + error.message, 'error');
+    }
+}

+ 36 - 0
app/templates/base.html

@@ -0,0 +1,36 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>{% block title %}Audiobookshelf Recommendations{% endblock %}</title>
+    <link rel="stylesheet" href="/static/css/style.css">
+    {% block extra_head %}{% endblock %}
+</head>
+<body>
+    {% if user %}
+    <nav class="navbar">
+        <div class="nav-container">
+            <div class="nav-brand">
+                <a href="/">Audiobookshelf Recommendations</a>
+            </div>
+            <ul class="nav-links">
+                <li><a href="/">Dashboard</a></li>
+                <li><a href="/reading-log">Reading Log</a></li>
+            </ul>
+            <div class="nav-user">
+                <span class="user-name">{{ user.display_name }}</span>
+                <button onclick="logout()" class="btn btn-text">Logout</button>
+            </div>
+        </div>
+    </nav>
+    {% endif %}
+
+    <div class="container">
+        {% block content %}{% endblock %}
+    </div>
+
+    <script src="/static/js/app.js"></script>
+    {% block extra_scripts %}{% endblock %}
+</body>
+</html>

+ 106 - 65
app/templates/index.html

@@ -1,3 +1,83 @@
+{% if user %}
+{% extends "base.html" %}
+
+{% block title %}Dashboard - Audiobookshelf Recommendations{% endblock %}
+
+{% block content %}
+<header>
+    <h1>Your Dashboard</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">
+                            {% set genres = rec.genres if rec.genres is string else rec.genres|tojson %}
+                            {% set genres_list = genres|from_json if genres is string else genres %}
+                            {% for genre in genres_list %}
+                            <span class="genre-tag">{{ genre }}</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>
+{% endblock %}
+
+{% else %}
+{# Landing page for logged-out users #}
 <!DOCTYPE html>
 <!DOCTYPE html>
 <html lang="en">
 <html lang="en">
 <head>
 <head>
@@ -7,77 +87,38 @@
     <link rel="stylesheet" href="/static/css/style.css">
     <link rel="stylesheet" href="/static/css/style.css">
 </head>
 </head>
 <body>
 <body>
-    <div class="container">
-        <header>
+    <div class="landing-container">
+        <div class="landing-hero">
             <h1>Audiobookshelf Recommendations</h1>
             <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(',') %}
-                                    <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 %}
+            <p class="landing-subtitle">
+                AI-powered book recommendations based on your Audiobookshelf listening history
+            </p>
+            <div class="landing-actions">
+                <a href="/register" class="btn btn-primary btn-large">Get Started</a>
+                <a href="/login" class="btn btn-secondary btn-large">Sign In</a>
             </div>
             </div>
-        </section>
+        </div>
 
 
-        <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 class="landing-features">
+            <h2>Features</h2>
+            <div class="features-grid">
+                <div class="feature-card">
+                    <h3>Smart Recommendations</h3>
+                    <p>Get personalized book recommendations powered by AI based on your listening history</p>
+                </div>
+                <div class="feature-card">
+                    <h3>Reading Statistics</h3>
+                    <p>Track your listening progress with detailed stats and insights</p>
+                </div>
+                <div class="feature-card">
+                    <h3>Audiobookshelf Integration</h3>
+                    <p>Seamlessly sync with your Audiobookshelf server</p>
+                </div>
             </div>
             </div>
-        </section>
+        </div>
     </div>
     </div>
 
 
     <script src="/static/js/app.js"></script>
     <script src="/static/js/app.js"></script>
 </body>
 </body>
 </html>
 </html>
+{% endif %}

+ 52 - 0
app/templates/login.html

@@ -0,0 +1,52 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Login - Audiobookshelf Recommendations</title>
+    <link rel="stylesheet" href="/static/css/style.css">
+</head>
+<body>
+    <div class="auth-container">
+        <div class="auth-card">
+            <h1>Welcome Back</h1>
+            <p class="auth-subtitle">Sign in to access your recommendations</p>
+
+            <div id="message" class="message hidden"></div>
+
+            <form id="login-form" onsubmit="handleLogin(event)">
+                <div class="form-group">
+                    <label for="username">Username</label>
+                    <input
+                        type="text"
+                        id="username"
+                        name="username"
+                        required
+                        autofocus
+                        placeholder="Enter your username"
+                    >
+                </div>
+
+                <div class="form-group">
+                    <label for="password">Password</label>
+                    <input
+                        type="password"
+                        id="password"
+                        name="password"
+                        required
+                        placeholder="Enter your password"
+                    >
+                </div>
+
+                <button type="submit" class="btn btn-primary btn-full">Sign In</button>
+            </form>
+
+            <div class="auth-footer">
+                <p>Don't have an account? <a href="/register">Register here</a></p>
+            </div>
+        </div>
+    </div>
+
+    <script src="/static/js/app.js"></script>
+</body>
+</html>

+ 76 - 0
app/templates/reading_log.html

@@ -0,0 +1,76 @@
+{% extends "base.html" %}
+
+{% block title %}Reading Log - Audiobookshelf Recommendations{% endblock %}
+
+{% block extra_head %}
+<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
+{% endblock %}
+
+{% block content %}
+<header>
+    <h1>Reading Log</h1>
+    <p class="subtitle">Track your listening progress and statistics</p>
+</header>
+
+<div id="message" class="message hidden"></div>
+
+<!-- Statistics Summary -->
+<section class="section">
+    <h2>Reading Statistics</h2>
+    <div id="stats-loading" class="loading">Loading statistics...</div>
+    <div id="stats-container" class="stats-grid hidden">
+        <div class="stat-card">
+            <div class="stat-value" id="stat-total-books">-</div>
+            <div class="stat-label">Books Finished</div>
+        </div>
+        <div class="stat-card">
+            <div class="stat-value" id="stat-total-hours">-</div>
+            <div class="stat-label">Hours Listened</div>
+        </div>
+        <div class="stat-card">
+            <div class="stat-value" id="stat-avg-rating">-</div>
+            <div class="stat-label">Average Rating</div>
+        </div>
+        <div class="stat-card">
+            <div class="stat-value" id="stat-streak">-</div>
+            <div class="stat-label">Day Streak</div>
+        </div>
+    </div>
+</section>
+
+<!-- Charts -->
+<section class="section">
+    <div class="charts-container">
+        <div class="chart-card">
+            <h3>Books Per Month</h3>
+            <canvas id="books-per-month-chart"></canvas>
+        </div>
+        <div class="chart-card">
+            <h3>Top Genres</h3>
+            <canvas id="genres-chart"></canvas>
+        </div>
+    </div>
+</section>
+
+<!-- Recent Books -->
+<section class="section">
+    <h2>Recently Finished Books</h2>
+    <div id="recent-books-container">
+        <div id="books-loading" class="loading">Loading books...</div>
+        <div id="books-list" class="books-grid hidden"></div>
+        <div id="books-empty" class="empty-state hidden">
+            No finished books yet. Start listening and they'll appear here!
+        </div>
+    </div>
+</section>
+{% endblock %}
+
+{% block extra_scripts %}
+<script src="/static/js/reading-log.js"></script>
+<script>
+    // Initialize reading log on page load
+    document.addEventListener('DOMContentLoaded', () => {
+        loadReadingStats();
+    });
+</script>
+{% endblock %}

+ 102 - 0
app/templates/register.html

@@ -0,0 +1,102 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Register - Audiobookshelf Recommendations</title>
+    <link rel="stylesheet" href="/static/css/style.css">
+</head>
+<body>
+    <div class="auth-container">
+        <div class="auth-card">
+            <h1>Create Account</h1>
+            <p class="auth-subtitle">Get started with AI-powered book recommendations</p>
+
+            <div id="message" class="message hidden"></div>
+
+            <form id="register-form" onsubmit="handleRegister(event)">
+                <div class="form-group">
+                    <label for="username">Username</label>
+                    <input
+                        type="text"
+                        id="username"
+                        name="username"
+                        required
+                        autofocus
+                        placeholder="Choose a username"
+                    >
+                </div>
+
+                <div class="form-group">
+                    <label for="email">Email</label>
+                    <input
+                        type="email"
+                        id="email"
+                        name="email"
+                        required
+                        placeholder="your@email.com"
+                    >
+                </div>
+
+                <div class="form-group">
+                    <label for="display_name">Display Name (optional)</label>
+                    <input
+                        type="text"
+                        id="display_name"
+                        name="display_name"
+                        placeholder="Your display name"
+                    >
+                </div>
+
+                <div class="form-group">
+                    <label for="password">Password</label>
+                    <input
+                        type="password"
+                        id="password"
+                        name="password"
+                        required
+                        placeholder="Choose a strong password"
+                    >
+                </div>
+
+                <div class="form-section">
+                    <h3>Audiobookshelf Connection</h3>
+                    <p class="form-help">Connect your Audiobookshelf account to sync your listening history.</p>
+
+                    <div class="form-group">
+                        <label for="abs_url">Audiobookshelf URL</label>
+                        <input
+                            type="url"
+                            id="abs_url"
+                            name="abs_url"
+                            required
+                            placeholder="https://your-audiobookshelf-server.com"
+                        >
+                        <small>The URL of your Audiobookshelf server</small>
+                    </div>
+
+                    <div class="form-group">
+                        <label for="abs_api_token">API Token</label>
+                        <input
+                            type="password"
+                            id="abs_api_token"
+                            name="abs_api_token"
+                            required
+                            placeholder="Your Audiobookshelf API token"
+                        >
+                        <small>Get your token from Audiobookshelf Settings > Users > [Your User] > Generate API Token</small>
+                    </div>
+                </div>
+
+                <button type="submit" class="btn btn-primary btn-full">Create Account</button>
+            </form>
+
+            <div class="auth-footer">
+                <p>Already have an account? <a href="/login">Sign in here</a></p>
+            </div>
+        </div>
+    </div>
+
+    <script src="/static/js/app.js"></script>
+</body>
+</html>

+ 6 - 0
requirements.txt

@@ -8,3 +8,9 @@ aiosqlite==0.19.0
 google-generativeai>=0.8.0
 google-generativeai>=0.8.0
 pydantic==2.5.3
 pydantic==2.5.3
 pydantic-settings==2.1.0
 pydantic-settings==2.1.0
+
+# Authentication and security
+passlib[bcrypt]==1.7.4
+python-multipart==0.0.6
+itsdangerous==2.1.2
+cryptography==42.0.0