Browse Source

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 months ago
parent
commit
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:
     """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}"}
 
     async def get_libraries(self) -> List[Dict[str, Any]]:
@@ -68,3 +74,24 @@ class AudiobookshelfClient:
             )
             response.raise_for_status()
             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):
     """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
     gemini_api_key: str | None = None
@@ -16,7 +17,7 @@ class Settings(BaseSettings):
 
     # Application Configuration
     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"
     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.orm import sessionmaker
+from sqlalchemy import text
 from app.models import Base
 from app.config import get_settings
 
@@ -21,6 +22,26 @@ async_session = async_sessionmaker(
 async def init_db():
     """Initialize database tables."""
     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)
 
 

+ 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.staticfiles import StaticFiles
-from fastapi.responses import HTMLResponse, JSONResponse
+from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy import select
 from contextlib import asynccontextmanager
 import json
 from datetime import datetime
+from typing import Optional
 
 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.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
@@ -33,17 +43,33 @@ app = FastAPI(
 templates = Jinja2Templates(directory="app/templates")
 app.mount("/static", StaticFiles(directory="app/static"), name="static")
 
-# Initialize clients
-abs_client = AudiobookshelfClient()
+# Initialize recommender (shared across users)
 recommender = BookRecommender()
 
 
 @app.get("/", response_class=HTMLResponse)
-async def home(request: Request, db: AsyncSession = Depends(get_db)):
-    """Home page showing dashboard."""
-    # Get recent books and recommendations
+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(
         select(ListeningSession)
+        .where(ListeningSession.user_id == user.id)
         .order_by(ListeningSession.last_update.desc())
         .limit(10)
     )
@@ -62,10 +88,13 @@ async def home(request: Request, db: AsyncSession = Depends(get_db)):
                 "session": session
             })
 
-    # Get recent recommendations
+    # Get user's recent recommendations
     recs_result = await db.execute(
         select(Recommendation)
-        .where(Recommendation.dismissed == False)
+        .where(
+            Recommendation.user_id == user.id,
+            Recommendation.dismissed == False
+        )
         .order_by(Recommendation.created_at.desc())
         .limit(5)
     )
@@ -75,16 +104,126 @@ async def home(request: Request, db: AsyncSession = Depends(get_db)):
         "index.html",
         {
             "request": request,
+            "user": user,
             "books": books,
             "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")
-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."""
     try:
+        # Create user-specific ABS client
+        abs_client = get_abs_client(user)
+
         # Get user info which includes all media progress
         user_info = await abs_client.get_user_info()
         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(
                 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())
                 .limit(1)
             )
@@ -154,6 +296,7 @@ async def sync_with_audiobookshelf(db: AsyncSession = Depends(get_db)):
 
             if not session:
                 session = ListeningSession(
+                    user_id=user.id,
                     book_id=book_id,
                     progress=progress_data,
                     current_time=current_time,
@@ -188,14 +331,20 @@ async def sync_with_audiobookshelf(db: AsyncSession = Depends(get_db)):
 
 
 @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."""
     try:
         # Get finished books for context
         finished_result = await db.execute(
             select(ListeningSession, Book)
             .join(Book, ListeningSession.book_id == Book.id)
-            .where(ListeningSession.is_finished == True)
+            .where(
+                ListeningSession.user_id == user.id,
+                ListeningSession.is_finished == True
+            )
             .order_by(ListeningSession.finished_at.desc())
             .limit(20)
         )
@@ -226,6 +375,7 @@ async def generate_recommendations(db: AsyncSession = Depends(get_db)):
         # Save to database
         for rec in new_recs:
             recommendation = Recommendation(
+                user_id=user.id,
                 title=rec.get("title"),
                 author=rec.get("author"),
                 description=rec.get("description"),
@@ -250,11 +400,17 @@ async def generate_recommendations(db: AsyncSession = Depends(get_db)):
 
 
 @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."""
     result = await db.execute(
         select(Recommendation)
-        .where(Recommendation.dismissed == False)
+        .where(
+            Recommendation.user_id == user.id,
+            Recommendation.dismissed == False
+        )
         .order_by(Recommendation.created_at.desc())
     )
     recommendations = result.scalars().all()
@@ -276,11 +432,15 @@ async def get_recommendations(db: AsyncSession = Depends(get_db)):
 
 
 @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."""
     result = await db.execute(
         select(ListeningSession, Book)
         .join(Book, ListeningSession.book_id == Book.id)
+        .where(ListeningSession.user_id == user.id)
         .order_by(ListeningSession.last_update.desc())
     )
     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")
 async def health_check():
     """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.orm import relationship
 from sqlalchemy.sql import func
 from datetime import datetime
 
 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):
     """Book information from Audiobookshelf."""
     __tablename__ = "books"
@@ -29,6 +54,7 @@ class ListeningSession(Base):
     __tablename__ = "listening_sessions"
 
     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)
 
     # Progress tracking
@@ -44,12 +70,16 @@ class ListeningSession(Base):
     # Ratings and preferences
     rating = Column(Integer, nullable=True)  # 1-5 stars, optional
 
+    # Relationships
+    user = relationship("User", back_populates="listening_sessions")
+
 
 class Recommendation(Base):
     """AI-generated book recommendations."""
     __tablename__ = "recommendations"
 
     id = Column(Integer, primary_key=True, autoincrement=True)
+    user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
 
     # Recommendation details
     title = Column(String, nullable=False)
@@ -61,3 +91,6 @@ class Recommendation(Base):
     genres = Column(String)  # JSON string
     created_at = Column(DateTime, default=func.now())
     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;
 }
 
+/* ==================== 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) {
     header h1 {
         font-size: 2rem;
@@ -240,4 +610,30 @@ header h1 {
         align-items: flex-start;
         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') {
     const messageEl = document.getElementById('message');
+    if (!messageEl) return;
+
     messageEl.textContent = text;
     messageEl.className = `message ${type}`;
 
@@ -8,11 +12,100 @@ function showMessage(text, type = 'success') {
     }, 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() {
     showMessage('Syncing with Audiobookshelf...', 'success');
 
     try {
         const response = await fetch('/api/sync');
+
+        if (await handleApiError(response)) return;
+
         const data = await response.json();
 
         if (data.status === 'success') {
@@ -31,6 +124,9 @@ async function generateRecommendations() {
 
     try {
         const response = await fetch('/api/recommendations/generate');
+
+        if (await handleApiError(response)) return;
+
         const data = await response.json();
 
         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>
 <html lang="en">
 <head>
@@ -7,77 +87,38 @@
     <link rel="stylesheet" href="/static/css/style.css">
 </head>
 <body>
-    <div class="container">
-        <header>
+    <div class="landing-container">
+        <div class="landing-hero">
             <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>
-        </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>
-        </section>
+        </div>
     </div>
 
     <script src="/static/js/app.js"></script>
 </body>
 </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
 pydantic==2.5.3
 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