| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784 |
- 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, RedirectResponse, StreamingResponse
- from sqlalchemy.ext.asyncio import AsyncSession
- from sqlalchemy import select, func
- from contextlib import asynccontextmanager
- import json
- from datetime import datetime
- from typing import Optional
- import httpx
- import os
- from pathlib import Path
- from app.database import init_db, get_db
- from app.models import Book, ListeningSession, Recommendation, User, AppSettings
- 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,
- get_current_admin,
- authenticate_user,
- create_user,
- set_session_cookie,
- clear_session_cookie
- )
- from app.services.stats import ReadingStatsService
- @asynccontextmanager
- async def lifespan(app: FastAPI):
- """Initialize database on startup."""
- await init_db()
- yield
- # Initialize FastAPI app
- app = FastAPI(
- title="Audiobookshelf Recommendations",
- description="AI-powered book recommendations based on your listening history",
- lifespan=lifespan
- )
- # Setup templates and static files
- templates = Jinja2Templates(directory="app/templates")
- app.mount("/static", StaticFiles(directory="app/static"), name="static")
- # Initialize recommender (shared across users)
- recommender = BookRecommender()
- @app.get("/", response_class=HTMLResponse)
- 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)
- )
- sessions = recent_sessions.scalars().all()
- # Get book details for sessions
- books = []
- for session in sessions:
- book_result = await db.execute(
- select(Book).where(Book.id == session.book_id)
- )
- book = book_result.scalar_one_or_none()
- if book:
- books.append({
- "book": book,
- "session": session
- })
- # Get user's recent recommendations
- recs_result = await db.execute(
- select(Recommendation)
- .where(
- Recommendation.user_id == user.id,
- Recommendation.dismissed == False
- )
- .order_by(Recommendation.created_at.desc())
- .limit(5)
- )
- recommendations_raw = recs_result.scalars().all()
- # Parse JSON fields for template
- recommendations = []
- for rec in recommendations_raw:
- rec_dict = {
- "id": rec.id,
- "title": rec.title,
- "author": rec.author,
- "description": rec.description,
- "reason": rec.reason,
- "genres": json.loads(rec.genres) if rec.genres else []
- }
- recommendations.append(rec_dict)
- return templates.TemplateResponse(
- "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(
- 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"
- )
- # Create redirect response and set session cookie
- redirect = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
- set_session_cookie(redirect, user.id)
- return redirect
- @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(
- 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:
- # Check if registration is allowed
- result = await db.execute(
- select(AppSettings).where(AppSettings.key == "allow_registration")
- )
- allow_reg_setting = result.scalar_one_or_none()
- # Check if there are any existing users (first user is always allowed)
- result = await db.execute(select(func.count(User.id)))
- user_count = result.scalar()
- if user_count > 0 and allow_reg_setting and allow_reg_setting.value.lower() != 'true':
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail="Registration is currently disabled"
- )
- 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
- )
- # Create redirect response and set session cookie
- redirect = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
- set_session_cookie(redirect, user.id)
- return redirect
- 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 ====================
- async def download_cover_image(abs_client, book_id: str) -> Optional[str]:
- """
- Download cover image from Audiobookshelf and save locally.
- Args:
- abs_client: Audiobookshelf client with authentication
- book_id: Library item ID
- Returns:
- Local path to saved cover (e.g., /static/covers/book_id.jpg) or None
- """
- if not book_id:
- return None
- # Create covers directory if it doesn't exist
- covers_dir = Path("app/static/covers")
- covers_dir.mkdir(parents=True, exist_ok=True)
- # Use Audiobookshelf API cover endpoint
- cover_url = f"{abs_client.base_url}/api/items/{book_id}/cover"
- async with httpx.AsyncClient() as client:
- try:
- response = await client.get(cover_url, headers=abs_client.headers, follow_redirects=True)
- response.raise_for_status()
- # Determine extension from content-type
- content_type = response.headers.get("content-type", "image/jpeg")
- ext = ".webp" if "webp" in content_type else ".jpg"
- local_filename = f"{book_id}{ext}"
- local_path = covers_dir / local_filename
- # Save to local file
- with open(local_path, "wb") as f:
- f.write(response.content)
- # Return path relative to static directory
- return f"/static/covers/{local_filename}"
- except Exception as e:
- print(f"Failed to download cover for {book_id}: {e}")
- return None
- @app.get("/api/sync")
- 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", [])
- synced_count = 0
- for progress_item in media_progress:
- # Skip podcast episodes, only process books
- if progress_item.get("mediaItemType") != "book":
- continue
- library_item_id = progress_item.get("libraryItemId")
- if not library_item_id:
- continue
- # Fetch full library item details
- try:
- item = await abs_client.get_item_details(library_item_id)
- except:
- # Skip if item not found
- continue
- # Extract book info
- media = item.get("media", {})
- metadata = media.get("metadata", {})
- book_id = item.get("id")
- if not book_id:
- continue
- # Check if book exists in DB
- result = await db.execute(select(Book).where(Book.id == book_id))
- book = result.scalar_one_or_none()
- # Download cover image and get local path
- local_cover_url = await download_cover_image(abs_client, book_id)
- # Create or update book
- if not book:
- book = Book(
- id=book_id,
- title=metadata.get("title", "Unknown"),
- author=metadata.get("authorName", "Unknown"),
- narrator=metadata.get("narratorName"),
- description=metadata.get("description"),
- genres=json.dumps(metadata.get("genres", [])),
- tags=json.dumps(media.get("tags", [])),
- duration=media.get("duration", 0),
- cover_url=local_cover_url # Store local path
- )
- db.add(book)
- else:
- # Update existing book
- book.title = metadata.get("title", book.title)
- book.author = metadata.get("authorName", book.author)
- if local_cover_url: # Only update if download succeeded
- book.cover_url = local_cover_url
- book.updated_at = datetime.now()
- # Update or create listening session
- progress_data = progress_item.get("progress", 0)
- current_time = progress_item.get("currentTime", 0)
- is_finished = progress_item.get("isFinished", False)
- started_at_ts = progress_item.get("startedAt")
- finished_at_ts = progress_item.get("finishedAt")
- session_result = await db.execute(
- select(ListeningSession)
- .where(
- ListeningSession.user_id == user.id,
- ListeningSession.book_id == book_id
- )
- .order_by(ListeningSession.last_update.desc())
- .limit(1)
- )
- session = session_result.scalar_one_or_none()
- if not session:
- session = ListeningSession(
- user_id=user.id,
- book_id=book_id,
- progress=progress_data,
- current_time=current_time,
- is_finished=is_finished,
- started_at=datetime.fromtimestamp(started_at_ts / 1000) if started_at_ts else datetime.now(),
- finished_at=datetime.fromtimestamp(finished_at_ts / 1000) if finished_at_ts else None
- )
- db.add(session)
- else:
- # Update existing session
- session.progress = progress_data
- session.current_time = current_time
- session.is_finished = is_finished
- if finished_at_ts and not session.finished_at:
- session.finished_at = datetime.fromtimestamp(finished_at_ts / 1000)
- synced_count += 1
- await db.commit()
- return JSONResponse({
- "status": "success",
- "synced": synced_count,
- "message": f"Synced {synced_count} books from Audiobookshelf"
- })
- except Exception as e:
- return JSONResponse(
- {"status": "error", "message": str(e)},
- status_code=500
- )
- @app.get("/api/recommendations/generate")
- async def generate_recommendations(
- db: AsyncSession = Depends(get_db),
- 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.user_id == user.id,
- ListeningSession.is_finished == True
- )
- .order_by(ListeningSession.finished_at.desc())
- .limit(20)
- )
- finished_items = finished_result.all()
- if not finished_items:
- return JSONResponse({
- "status": "error",
- "message": "No reading history found. Please sync with Audiobookshelf first."
- })
- # Format reading history
- reading_history = []
- for session, book in finished_items:
- reading_history.append({
- "title": book.title,
- "author": book.author,
- "genres": json.loads(book.genres) if book.genres else [],
- "progress": session.progress,
- "is_finished": session.is_finished
- })
- # Generate recommendations
- new_recs = await recommender.generate_recommendations(
- reading_history, num_recommendations=5
- )
- # Save to database
- for rec in new_recs:
- recommendation = Recommendation(
- user_id=user.id,
- title=rec.get("title"),
- author=rec.get("author"),
- description=rec.get("description"),
- reason=rec.get("reason"),
- genres=json.dumps(rec.get("genres", []))
- )
- db.add(recommendation)
- await db.commit()
- return JSONResponse({
- "status": "success",
- "recommendations": new_recs,
- "count": len(new_recs)
- })
- except Exception as e:
- return JSONResponse(
- {"status": "error", "message": str(e)},
- status_code=500
- )
- @app.get("/api/recommendations")
- async def get_recommendations(
- db: AsyncSession = Depends(get_db),
- user: User = Depends(get_current_user)
- ):
- """Get saved recommendations."""
- result = await db.execute(
- select(Recommendation)
- .where(
- Recommendation.user_id == user.id,
- Recommendation.dismissed == False
- )
- .order_by(Recommendation.created_at.desc())
- )
- recommendations = result.scalars().all()
- return JSONResponse({
- "recommendations": [
- {
- "id": rec.id,
- "title": rec.title,
- "author": rec.author,
- "description": rec.description,
- "reason": rec.reason,
- "genres": json.loads(rec.genres) if rec.genres else [],
- "created_at": rec.created_at.isoformat()
- }
- for rec in recommendations
- ]
- })
- @app.get("/api/history")
- async def get_listening_history(
- db: AsyncSession = Depends(get_db),
- 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()
- return JSONResponse({
- "history": [
- {
- "book": {
- "id": book.id,
- "title": book.title,
- "author": book.author,
- "cover_url": book.cover_url,
- },
- "session": {
- "progress": session.progress,
- "is_finished": session.is_finished,
- "started_at": session.started_at.isoformat() if session.started_at else None,
- "finished_at": session.finished_at.isoformat() if session.finished_at else None,
- }
- }
- for session, book in items
- ]
- })
- # ==================== 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, user.abs_url)
- 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
- )
- # ==================== Admin Routes ====================
- @app.get("/admin", response_class=HTMLResponse)
- async def admin_page(
- request: Request,
- user: User = Depends(get_current_admin)
- ):
- """Admin panel page."""
- return templates.TemplateResponse(
- "admin.html",
- {
- "request": request,
- "user": user
- }
- )
- @app.get("/api/admin/settings")
- async def get_admin_settings(
- db: AsyncSession = Depends(get_db),
- admin: User = Depends(get_current_admin)
- ):
- """Get application settings."""
- result = await db.execute(select(AppSettings))
- settings = result.scalars().all()
- settings_dict = {s.key: s.value for s in settings}
- return JSONResponse(settings_dict)
- @app.put("/api/admin/settings/{key}")
- async def update_setting(
- key: str,
- value: str = Form(...),
- db: AsyncSession = Depends(get_db),
- admin: User = Depends(get_current_admin)
- ):
- """Update an application setting."""
- result = await db.execute(
- select(AppSettings).where(AppSettings.key == key)
- )
- setting = result.scalar_one_or_none()
- if not setting:
- # Create new setting
- setting = AppSettings(key=key, value=value)
- db.add(setting)
- else:
- # Update existing
- setting.value = value
- await db.commit()
- return JSONResponse({
- "status": "success",
- "message": f"Setting {key} updated"
- })
- @app.get("/api/admin/users")
- async def get_users(
- db: AsyncSession = Depends(get_db),
- admin: User = Depends(get_current_admin)
- ):
- """Get all users."""
- result = await db.execute(select(User).order_by(User.created_at.desc()))
- users = result.scalars().all()
- users_list = [
- {
- "id": user.id,
- "username": user.username,
- "email": user.email,
- "display_name": user.display_name,
- "is_admin": user.is_admin,
- "is_active": user.is_active,
- "created_at": user.created_at.isoformat() if user.created_at else None,
- "last_login": user.last_login.isoformat() if user.last_login else None,
- "is_current": user.id == admin.id
- }
- for user in users
- ]
- return JSONResponse({"users": users_list})
- @app.put("/api/admin/users/{user_id}/admin")
- async def toggle_user_admin(
- user_id: int,
- is_admin: str = Form(...),
- db: AsyncSession = Depends(get_db),
- admin: User = Depends(get_current_admin)
- ):
- """Toggle admin status for a user."""
- # Prevent admin from removing their own admin status
- if user_id == admin.id:
- return JSONResponse(
- {"status": "error", "message": "Cannot modify your own admin status"},
- status_code=400
- )
- result = await db.execute(select(User).where(User.id == user_id))
- user = result.scalar_one_or_none()
- if not user:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="User not found"
- )
- user.is_admin = is_admin.lower() == 'true'
- await db.commit()
- return JSONResponse({
- "status": "success",
- "message": "User admin status updated"
- })
- @app.delete("/api/admin/users/{user_id}")
- async def delete_user(
- user_id: int,
- db: AsyncSession = Depends(get_db),
- admin: User = Depends(get_current_admin)
- ):
- """Delete a user."""
- # Prevent admin from deleting themselves
- if user_id == admin.id:
- return JSONResponse(
- {"status": "error", "message": "Cannot delete your own account"},
- status_code=400
- )
- result = await db.execute(select(User).where(User.id == user_id))
- user = result.scalar_one_or_none()
- if not user:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="User not found"
- )
- await db.delete(user)
- await db.commit()
- return JSONResponse({
- "status": "success",
- "message": "User deleted successfully"
- })
- @app.get("/health")
- async def health_check():
- """Health check endpoint."""
- return {"status": "healthy"}
|