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