main.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566
  1. from fastapi import FastAPI, Request, Depends, Form, Response, HTTPException, status
  2. from fastapi.templating import Jinja2Templates
  3. from fastapi.staticfiles import StaticFiles
  4. from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
  5. from sqlalchemy.ext.asyncio import AsyncSession
  6. from sqlalchemy import select
  7. from contextlib import asynccontextmanager
  8. import json
  9. from datetime import datetime
  10. from typing import Optional
  11. from app.database import init_db, get_db
  12. from app.models import Book, ListeningSession, Recommendation, User
  13. from app.abs_client import get_abs_client
  14. from app.recommender import BookRecommender
  15. from app.config import get_settings
  16. from app.auth import (
  17. get_current_user,
  18. get_current_user_optional,
  19. authenticate_user,
  20. create_user,
  21. set_session_cookie,
  22. clear_session_cookie
  23. )
  24. from app.services.stats import ReadingStatsService
  25. @asynccontextmanager
  26. async def lifespan(app: FastAPI):
  27. """Initialize database on startup."""
  28. await init_db()
  29. yield
  30. # Initialize FastAPI app
  31. app = FastAPI(
  32. title="Audiobookshelf Recommendations",
  33. description="AI-powered book recommendations based on your listening history",
  34. lifespan=lifespan
  35. )
  36. # Setup templates and static files
  37. templates = Jinja2Templates(directory="app/templates")
  38. app.mount("/static", StaticFiles(directory="app/static"), name="static")
  39. # Initialize recommender (shared across users)
  40. recommender = BookRecommender()
  41. @app.get("/", response_class=HTMLResponse)
  42. async def home(
  43. request: Request,
  44. db: AsyncSession = Depends(get_db),
  45. user: Optional[User] = Depends(get_current_user_optional)
  46. ):
  47. """Home page showing dashboard or landing page."""
  48. # If user not logged in, show landing page
  49. if not user:
  50. return templates.TemplateResponse(
  51. "index.html",
  52. {
  53. "request": request,
  54. "user": None,
  55. "books": [],
  56. "recommendations": []
  57. }
  58. )
  59. # Get user's recent books and recommendations
  60. recent_sessions = await db.execute(
  61. select(ListeningSession)
  62. .where(ListeningSession.user_id == user.id)
  63. .order_by(ListeningSession.last_update.desc())
  64. .limit(10)
  65. )
  66. sessions = recent_sessions.scalars().all()
  67. # Get book details for sessions
  68. books = []
  69. for session in sessions:
  70. book_result = await db.execute(
  71. select(Book).where(Book.id == session.book_id)
  72. )
  73. book = book_result.scalar_one_or_none()
  74. if book:
  75. books.append({
  76. "book": book,
  77. "session": session
  78. })
  79. # Get user's recent recommendations
  80. recs_result = await db.execute(
  81. select(Recommendation)
  82. .where(
  83. Recommendation.user_id == user.id,
  84. Recommendation.dismissed == False
  85. )
  86. .order_by(Recommendation.created_at.desc())
  87. .limit(5)
  88. )
  89. recommendations = recs_result.scalars().all()
  90. return templates.TemplateResponse(
  91. "index.html",
  92. {
  93. "request": request,
  94. "user": user,
  95. "books": books,
  96. "recommendations": recommendations
  97. }
  98. )
  99. # ==================== Authentication Routes ====================
  100. @app.get("/login", response_class=HTMLResponse)
  101. async def login_page(request: Request):
  102. """Login page."""
  103. return templates.TemplateResponse("login.html", {"request": request})
  104. @app.post("/api/auth/login")
  105. async def login(
  106. response: Response,
  107. username: str = Form(...),
  108. password: str = Form(...),
  109. db: AsyncSession = Depends(get_db)
  110. ):
  111. """Authenticate user and create session."""
  112. user = await authenticate_user(db, username, password)
  113. if not user:
  114. raise HTTPException(
  115. status_code=status.HTTP_401_UNAUTHORIZED,
  116. detail="Incorrect username or password"
  117. )
  118. # Set session cookie
  119. set_session_cookie(response, user.id)
  120. return JSONResponse({
  121. "status": "success",
  122. "message": "Logged in successfully",
  123. "user": {
  124. "username": user.username,
  125. "email": user.email,
  126. "display_name": user.display_name
  127. }
  128. })
  129. @app.get("/register", response_class=HTMLResponse)
  130. async def register_page(request: Request):
  131. """Registration page."""
  132. return templates.TemplateResponse("register.html", {"request": request})
  133. @app.post("/api/auth/register")
  134. async def register(
  135. response: Response,
  136. username: str = Form(...),
  137. email: str = Form(...),
  138. password: str = Form(...),
  139. abs_url: str = Form(...),
  140. abs_api_token: str = Form(...),
  141. display_name: Optional[str] = Form(None),
  142. db: AsyncSession = Depends(get_db)
  143. ):
  144. """Register a new user."""
  145. try:
  146. user = await create_user(
  147. db=db,
  148. username=username,
  149. email=email,
  150. password=password,
  151. abs_url=abs_url,
  152. abs_api_token=abs_api_token,
  153. display_name=display_name
  154. )
  155. # Set session cookie
  156. set_session_cookie(response, user.id)
  157. return JSONResponse({
  158. "status": "success",
  159. "message": "Account created successfully",
  160. "user": {
  161. "username": user.username,
  162. "email": user.email,
  163. "display_name": user.display_name
  164. }
  165. })
  166. except HTTPException as e:
  167. raise e
  168. except Exception as e:
  169. raise HTTPException(
  170. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  171. detail=str(e)
  172. )
  173. @app.post("/api/auth/logout")
  174. async def logout(response: Response):
  175. """Logout user and clear session."""
  176. clear_session_cookie(response)
  177. return JSONResponse({
  178. "status": "success",
  179. "message": "Logged out successfully"
  180. })
  181. # ==================== API Routes ====================
  182. @app.get("/api/sync")
  183. async def sync_with_audiobookshelf(
  184. db: AsyncSession = Depends(get_db),
  185. user: User = Depends(get_current_user)
  186. ):
  187. """Sync library and progress from Audiobookshelf."""
  188. try:
  189. # Create user-specific ABS client
  190. abs_client = get_abs_client(user)
  191. # Get user info which includes all media progress
  192. user_info = await abs_client.get_user_info()
  193. media_progress = user_info.get("mediaProgress", [])
  194. synced_count = 0
  195. for progress_item in media_progress:
  196. # Skip podcast episodes, only process books
  197. if progress_item.get("mediaItemType") != "book":
  198. continue
  199. library_item_id = progress_item.get("libraryItemId")
  200. if not library_item_id:
  201. continue
  202. # Fetch full library item details
  203. try:
  204. item = await abs_client.get_item_details(library_item_id)
  205. except:
  206. # Skip if item not found
  207. continue
  208. # Extract book info
  209. media = item.get("media", {})
  210. metadata = media.get("metadata", {})
  211. book_id = item.get("id")
  212. if not book_id:
  213. continue
  214. # Check if book exists in DB
  215. result = await db.execute(select(Book).where(Book.id == book_id))
  216. book = result.scalar_one_or_none()
  217. # Create or update book
  218. if not book:
  219. book = Book(
  220. id=book_id,
  221. title=metadata.get("title", "Unknown"),
  222. author=metadata.get("authorName", "Unknown"),
  223. narrator=metadata.get("narratorName"),
  224. description=metadata.get("description"),
  225. genres=json.dumps(metadata.get("genres", [])),
  226. tags=json.dumps(media.get("tags", [])),
  227. duration=media.get("duration", 0),
  228. cover_url=media.get("coverPath")
  229. )
  230. db.add(book)
  231. else:
  232. # Update existing book
  233. book.title = metadata.get("title", book.title)
  234. book.author = metadata.get("authorName", book.author)
  235. book.updated_at = datetime.now()
  236. # Update or create listening session
  237. progress_data = progress_item.get("progress", 0)
  238. current_time = progress_item.get("currentTime", 0)
  239. is_finished = progress_item.get("isFinished", False)
  240. started_at_ts = progress_item.get("startedAt")
  241. finished_at_ts = progress_item.get("finishedAt")
  242. session_result = await db.execute(
  243. select(ListeningSession)
  244. .where(
  245. ListeningSession.user_id == user.id,
  246. ListeningSession.book_id == book_id
  247. )
  248. .order_by(ListeningSession.last_update.desc())
  249. .limit(1)
  250. )
  251. session = session_result.scalar_one_or_none()
  252. if not session:
  253. session = ListeningSession(
  254. user_id=user.id,
  255. book_id=book_id,
  256. progress=progress_data,
  257. current_time=current_time,
  258. is_finished=is_finished,
  259. started_at=datetime.fromtimestamp(started_at_ts / 1000) if started_at_ts else datetime.now(),
  260. finished_at=datetime.fromtimestamp(finished_at_ts / 1000) if finished_at_ts else None
  261. )
  262. db.add(session)
  263. else:
  264. # Update existing session
  265. session.progress = progress_data
  266. session.current_time = current_time
  267. session.is_finished = is_finished
  268. if finished_at_ts and not session.finished_at:
  269. session.finished_at = datetime.fromtimestamp(finished_at_ts / 1000)
  270. synced_count += 1
  271. await db.commit()
  272. return JSONResponse({
  273. "status": "success",
  274. "synced": synced_count,
  275. "message": f"Synced {synced_count} books from Audiobookshelf"
  276. })
  277. except Exception as e:
  278. return JSONResponse(
  279. {"status": "error", "message": str(e)},
  280. status_code=500
  281. )
  282. @app.get("/api/recommendations/generate")
  283. async def generate_recommendations(
  284. db: AsyncSession = Depends(get_db),
  285. user: User = Depends(get_current_user)
  286. ):
  287. """Generate new AI recommendations based on reading history."""
  288. try:
  289. # Get finished books for context
  290. finished_result = await db.execute(
  291. select(ListeningSession, Book)
  292. .join(Book, ListeningSession.book_id == Book.id)
  293. .where(
  294. ListeningSession.user_id == user.id,
  295. ListeningSession.is_finished == True
  296. )
  297. .order_by(ListeningSession.finished_at.desc())
  298. .limit(20)
  299. )
  300. finished_items = finished_result.all()
  301. if not finished_items:
  302. return JSONResponse({
  303. "status": "error",
  304. "message": "No reading history found. Please sync with Audiobookshelf first."
  305. })
  306. # Format reading history
  307. reading_history = []
  308. for session, book in finished_items:
  309. reading_history.append({
  310. "title": book.title,
  311. "author": book.author,
  312. "genres": json.loads(book.genres) if book.genres else [],
  313. "progress": session.progress,
  314. "is_finished": session.is_finished
  315. })
  316. # Generate recommendations
  317. new_recs = await recommender.generate_recommendations(
  318. reading_history, num_recommendations=5
  319. )
  320. # Save to database
  321. for rec in new_recs:
  322. recommendation = Recommendation(
  323. user_id=user.id,
  324. title=rec.get("title"),
  325. author=rec.get("author"),
  326. description=rec.get("description"),
  327. reason=rec.get("reason"),
  328. genres=json.dumps(rec.get("genres", []))
  329. )
  330. db.add(recommendation)
  331. await db.commit()
  332. return JSONResponse({
  333. "status": "success",
  334. "recommendations": new_recs,
  335. "count": len(new_recs)
  336. })
  337. except Exception as e:
  338. return JSONResponse(
  339. {"status": "error", "message": str(e)},
  340. status_code=500
  341. )
  342. @app.get("/api/recommendations")
  343. async def get_recommendations(
  344. db: AsyncSession = Depends(get_db),
  345. user: User = Depends(get_current_user)
  346. ):
  347. """Get saved recommendations."""
  348. result = await db.execute(
  349. select(Recommendation)
  350. .where(
  351. Recommendation.user_id == user.id,
  352. Recommendation.dismissed == False
  353. )
  354. .order_by(Recommendation.created_at.desc())
  355. )
  356. recommendations = result.scalars().all()
  357. return JSONResponse({
  358. "recommendations": [
  359. {
  360. "id": rec.id,
  361. "title": rec.title,
  362. "author": rec.author,
  363. "description": rec.description,
  364. "reason": rec.reason,
  365. "genres": json.loads(rec.genres) if rec.genres else [],
  366. "created_at": rec.created_at.isoformat()
  367. }
  368. for rec in recommendations
  369. ]
  370. })
  371. @app.get("/api/history")
  372. async def get_listening_history(
  373. db: AsyncSession = Depends(get_db),
  374. user: User = Depends(get_current_user)
  375. ):
  376. """Get listening history."""
  377. result = await db.execute(
  378. select(ListeningSession, Book)
  379. .join(Book, ListeningSession.book_id == Book.id)
  380. .where(ListeningSession.user_id == user.id)
  381. .order_by(ListeningSession.last_update.desc())
  382. )
  383. items = result.all()
  384. return JSONResponse({
  385. "history": [
  386. {
  387. "book": {
  388. "id": book.id,
  389. "title": book.title,
  390. "author": book.author,
  391. "cover_url": book.cover_url,
  392. },
  393. "session": {
  394. "progress": session.progress,
  395. "is_finished": session.is_finished,
  396. "started_at": session.started_at.isoformat() if session.started_at else None,
  397. "finished_at": session.finished_at.isoformat() if session.finished_at else None,
  398. }
  399. }
  400. for session, book in items
  401. ]
  402. })
  403. # ==================== Reading Log Routes ====================
  404. @app.get("/reading-log", response_class=HTMLResponse)
  405. async def reading_log_page(
  406. request: Request,
  407. user: User = Depends(get_current_user)
  408. ):
  409. """Reading log page with stats and filters."""
  410. return templates.TemplateResponse(
  411. "reading_log.html",
  412. {
  413. "request": request,
  414. "user": user
  415. }
  416. )
  417. @app.get("/api/reading-log/stats")
  418. async def get_reading_stats(
  419. db: AsyncSession = Depends(get_db),
  420. user: User = Depends(get_current_user),
  421. start_date: Optional[str] = None,
  422. end_date: Optional[str] = None
  423. ):
  424. """Get reading statistics for the user."""
  425. try:
  426. # Parse dates if provided
  427. start_dt = datetime.fromisoformat(start_date) if start_date else None
  428. end_dt = datetime.fromisoformat(end_date) if end_date else None
  429. # Calculate stats
  430. stats_service = ReadingStatsService(db, user.id)
  431. stats = await stats_service.calculate_stats(start_dt, end_dt)
  432. return JSONResponse(stats)
  433. except Exception as e:
  434. return JSONResponse(
  435. {"status": "error", "message": str(e)},
  436. status_code=500
  437. )
  438. @app.put("/api/sessions/{session_id}/rating")
  439. async def update_session_rating(
  440. session_id: int,
  441. rating: int = Form(...),
  442. db: AsyncSession = Depends(get_db),
  443. user: User = Depends(get_current_user)
  444. ):
  445. """Update the rating for a listening session."""
  446. try:
  447. # Validate rating
  448. if rating < 1 or rating > 5:
  449. raise HTTPException(
  450. status_code=status.HTTP_400_BAD_REQUEST,
  451. detail="Rating must be between 1 and 5"
  452. )
  453. # Get session and verify ownership
  454. result = await db.execute(
  455. select(ListeningSession).where(
  456. ListeningSession.id == session_id,
  457. ListeningSession.user_id == user.id
  458. )
  459. )
  460. session = result.scalar_one_or_none()
  461. if not session:
  462. raise HTTPException(
  463. status_code=status.HTTP_404_NOT_FOUND,
  464. detail="Session not found"
  465. )
  466. # Update rating
  467. session.rating = rating
  468. await db.commit()
  469. return JSONResponse({
  470. "status": "success",
  471. "message": "Rating updated successfully",
  472. "rating": rating
  473. })
  474. except HTTPException as e:
  475. raise e
  476. except Exception as e:
  477. return JSONResponse(
  478. {"status": "error", "message": str(e)},
  479. status_code=500
  480. )
  481. @app.get("/health")
  482. async def health_check():
  483. """Health check endpoint."""
  484. return {"status": "healthy"}