main.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770
  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, StreamingResponse
  5. from sqlalchemy.ext.asyncio import AsyncSession
  6. from sqlalchemy import select, func
  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, AppSettings
  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. get_current_admin,
  20. authenticate_user,
  21. create_user,
  22. set_session_cookie,
  23. clear_session_cookie
  24. )
  25. from app.services.stats import ReadingStatsService
  26. @asynccontextmanager
  27. async def lifespan(app: FastAPI):
  28. """Initialize database on startup."""
  29. await init_db()
  30. yield
  31. # Initialize FastAPI app
  32. app = FastAPI(
  33. title="Audiobookshelf Recommendations",
  34. description="AI-powered book recommendations based on your listening history",
  35. lifespan=lifespan
  36. )
  37. # Setup templates and static files
  38. templates = Jinja2Templates(directory="app/templates")
  39. app.mount("/static", StaticFiles(directory="app/static"), name="static")
  40. # Initialize recommender (shared across users)
  41. recommender = BookRecommender()
  42. @app.get("/", response_class=HTMLResponse)
  43. async def home(
  44. request: Request,
  45. db: AsyncSession = Depends(get_db),
  46. user: Optional[User] = Depends(get_current_user_optional)
  47. ):
  48. """Home page showing dashboard or landing page."""
  49. # If user not logged in, show landing page
  50. if not user:
  51. return templates.TemplateResponse(
  52. "index.html",
  53. {
  54. "request": request,
  55. "user": None,
  56. "books": [],
  57. "recommendations": []
  58. }
  59. )
  60. # Get user's recent books and recommendations
  61. recent_sessions = await db.execute(
  62. select(ListeningSession)
  63. .where(ListeningSession.user_id == user.id)
  64. .order_by(ListeningSession.last_update.desc())
  65. .limit(10)
  66. )
  67. sessions = recent_sessions.scalars().all()
  68. # Get book details for sessions
  69. books = []
  70. for session in sessions:
  71. book_result = await db.execute(
  72. select(Book).where(Book.id == session.book_id)
  73. )
  74. book = book_result.scalar_one_or_none()
  75. if book:
  76. books.append({
  77. "book": book,
  78. "session": session
  79. })
  80. # Get user's recent recommendations
  81. recs_result = await db.execute(
  82. select(Recommendation)
  83. .where(
  84. Recommendation.user_id == user.id,
  85. Recommendation.dismissed == False
  86. )
  87. .order_by(Recommendation.created_at.desc())
  88. .limit(5)
  89. )
  90. recommendations_raw = recs_result.scalars().all()
  91. # Parse JSON fields for template
  92. recommendations = []
  93. for rec in recommendations_raw:
  94. rec_dict = {
  95. "id": rec.id,
  96. "title": rec.title,
  97. "author": rec.author,
  98. "description": rec.description,
  99. "reason": rec.reason,
  100. "genres": json.loads(rec.genres) if rec.genres else []
  101. }
  102. recommendations.append(rec_dict)
  103. return templates.TemplateResponse(
  104. "index.html",
  105. {
  106. "request": request,
  107. "user": user,
  108. "books": books,
  109. "recommendations": recommendations
  110. }
  111. )
  112. # ==================== Authentication Routes ====================
  113. @app.get("/login", response_class=HTMLResponse)
  114. async def login_page(request: Request):
  115. """Login page."""
  116. return templates.TemplateResponse("login.html", {"request": request})
  117. @app.post("/api/auth/login")
  118. async def login(
  119. username: str = Form(...),
  120. password: str = Form(...),
  121. db: AsyncSession = Depends(get_db)
  122. ):
  123. """Authenticate user and create session."""
  124. user = await authenticate_user(db, username, password)
  125. if not user:
  126. raise HTTPException(
  127. status_code=status.HTTP_401_UNAUTHORIZED,
  128. detail="Incorrect username or password"
  129. )
  130. # Create redirect response and set session cookie
  131. redirect = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
  132. set_session_cookie(redirect, user.id)
  133. return redirect
  134. @app.get("/register", response_class=HTMLResponse)
  135. async def register_page(request: Request):
  136. """Registration page."""
  137. return templates.TemplateResponse("register.html", {"request": request})
  138. @app.post("/api/auth/register")
  139. async def register(
  140. username: str = Form(...),
  141. email: str = Form(...),
  142. password: str = Form(...),
  143. abs_url: str = Form(...),
  144. abs_api_token: str = Form(...),
  145. display_name: Optional[str] = Form(None),
  146. db: AsyncSession = Depends(get_db)
  147. ):
  148. """Register a new user."""
  149. try:
  150. # Check if registration is allowed
  151. result = await db.execute(
  152. select(AppSettings).where(AppSettings.key == "allow_registration")
  153. )
  154. allow_reg_setting = result.scalar_one_or_none()
  155. # Check if there are any existing users (first user is always allowed)
  156. result = await db.execute(select(func.count(User.id)))
  157. user_count = result.scalar()
  158. if user_count > 0 and allow_reg_setting and allow_reg_setting.value.lower() != 'true':
  159. raise HTTPException(
  160. status_code=status.HTTP_403_FORBIDDEN,
  161. detail="Registration is currently disabled"
  162. )
  163. user = await create_user(
  164. db=db,
  165. username=username,
  166. email=email,
  167. password=password,
  168. abs_url=abs_url,
  169. abs_api_token=abs_api_token,
  170. display_name=display_name
  171. )
  172. # Create redirect response and set session cookie
  173. redirect = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
  174. set_session_cookie(redirect, user.id)
  175. return redirect
  176. except HTTPException as e:
  177. raise e
  178. except Exception as e:
  179. raise HTTPException(
  180. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  181. detail=str(e)
  182. )
  183. @app.post("/api/auth/logout")
  184. async def logout(response: Response):
  185. """Logout user and clear session."""
  186. clear_session_cookie(response)
  187. return JSONResponse({
  188. "status": "success",
  189. "message": "Logged out successfully"
  190. })
  191. # ==================== API Routes ====================
  192. @app.get("/api/sync")
  193. async def sync_with_audiobookshelf(
  194. db: AsyncSession = Depends(get_db),
  195. user: User = Depends(get_current_user)
  196. ):
  197. """Sync library and progress from Audiobookshelf."""
  198. try:
  199. # Create user-specific ABS client
  200. abs_client = get_abs_client(user)
  201. # Get user info which includes all media progress
  202. user_info = await abs_client.get_user_info()
  203. media_progress = user_info.get("mediaProgress", [])
  204. synced_count = 0
  205. for progress_item in media_progress:
  206. # Skip podcast episodes, only process books
  207. if progress_item.get("mediaItemType") != "book":
  208. continue
  209. library_item_id = progress_item.get("libraryItemId")
  210. if not library_item_id:
  211. continue
  212. # Fetch full library item details
  213. try:
  214. item = await abs_client.get_item_details(library_item_id)
  215. except:
  216. # Skip if item not found
  217. continue
  218. # Extract book info
  219. media = item.get("media", {})
  220. metadata = media.get("metadata", {})
  221. book_id = item.get("id")
  222. if not book_id:
  223. continue
  224. # Check if book exists in DB
  225. result = await db.execute(select(Book).where(Book.id == book_id))
  226. book = result.scalar_one_or_none()
  227. # Create or update book
  228. if not book:
  229. book = Book(
  230. id=book_id,
  231. title=metadata.get("title", "Unknown"),
  232. author=metadata.get("authorName", "Unknown"),
  233. narrator=metadata.get("narratorName"),
  234. description=metadata.get("description"),
  235. genres=json.dumps(metadata.get("genres", [])),
  236. tags=json.dumps(media.get("tags", [])),
  237. duration=media.get("duration", 0),
  238. cover_url=media.get("coverPath") # Store relative path
  239. )
  240. db.add(book)
  241. else:
  242. # Update existing book
  243. book.title = metadata.get("title", book.title)
  244. book.author = metadata.get("authorName", book.author)
  245. book.cover_url = media.get("coverPath") # Store relative path
  246. book.updated_at = datetime.now()
  247. # Update or create listening session
  248. progress_data = progress_item.get("progress", 0)
  249. current_time = progress_item.get("currentTime", 0)
  250. is_finished = progress_item.get("isFinished", False)
  251. started_at_ts = progress_item.get("startedAt")
  252. finished_at_ts = progress_item.get("finishedAt")
  253. session_result = await db.execute(
  254. select(ListeningSession)
  255. .where(
  256. ListeningSession.user_id == user.id,
  257. ListeningSession.book_id == book_id
  258. )
  259. .order_by(ListeningSession.last_update.desc())
  260. .limit(1)
  261. )
  262. session = session_result.scalar_one_or_none()
  263. if not session:
  264. session = ListeningSession(
  265. user_id=user.id,
  266. book_id=book_id,
  267. progress=progress_data,
  268. current_time=current_time,
  269. is_finished=is_finished,
  270. started_at=datetime.fromtimestamp(started_at_ts / 1000) if started_at_ts else datetime.now(),
  271. finished_at=datetime.fromtimestamp(finished_at_ts / 1000) if finished_at_ts else None
  272. )
  273. db.add(session)
  274. else:
  275. # Update existing session
  276. session.progress = progress_data
  277. session.current_time = current_time
  278. session.is_finished = is_finished
  279. if finished_at_ts and not session.finished_at:
  280. session.finished_at = datetime.fromtimestamp(finished_at_ts / 1000)
  281. synced_count += 1
  282. await db.commit()
  283. return JSONResponse({
  284. "status": "success",
  285. "synced": synced_count,
  286. "message": f"Synced {synced_count} books from Audiobookshelf"
  287. })
  288. except Exception as e:
  289. return JSONResponse(
  290. {"status": "error", "message": str(e)},
  291. status_code=500
  292. )
  293. @app.get("/api/recommendations/generate")
  294. async def generate_recommendations(
  295. db: AsyncSession = Depends(get_db),
  296. user: User = Depends(get_current_user)
  297. ):
  298. """Generate new AI recommendations based on reading history."""
  299. try:
  300. # Get finished books for context
  301. finished_result = await db.execute(
  302. select(ListeningSession, Book)
  303. .join(Book, ListeningSession.book_id == Book.id)
  304. .where(
  305. ListeningSession.user_id == user.id,
  306. ListeningSession.is_finished == True
  307. )
  308. .order_by(ListeningSession.finished_at.desc())
  309. .limit(20)
  310. )
  311. finished_items = finished_result.all()
  312. if not finished_items:
  313. return JSONResponse({
  314. "status": "error",
  315. "message": "No reading history found. Please sync with Audiobookshelf first."
  316. })
  317. # Format reading history
  318. reading_history = []
  319. for session, book in finished_items:
  320. reading_history.append({
  321. "title": book.title,
  322. "author": book.author,
  323. "genres": json.loads(book.genres) if book.genres else [],
  324. "progress": session.progress,
  325. "is_finished": session.is_finished
  326. })
  327. # Generate recommendations
  328. new_recs = await recommender.generate_recommendations(
  329. reading_history, num_recommendations=5
  330. )
  331. # Save to database
  332. for rec in new_recs:
  333. recommendation = Recommendation(
  334. user_id=user.id,
  335. title=rec.get("title"),
  336. author=rec.get("author"),
  337. description=rec.get("description"),
  338. reason=rec.get("reason"),
  339. genres=json.dumps(rec.get("genres", []))
  340. )
  341. db.add(recommendation)
  342. await db.commit()
  343. return JSONResponse({
  344. "status": "success",
  345. "recommendations": new_recs,
  346. "count": len(new_recs)
  347. })
  348. except Exception as e:
  349. return JSONResponse(
  350. {"status": "error", "message": str(e)},
  351. status_code=500
  352. )
  353. @app.get("/api/recommendations")
  354. async def get_recommendations(
  355. db: AsyncSession = Depends(get_db),
  356. user: User = Depends(get_current_user)
  357. ):
  358. """Get saved recommendations."""
  359. result = await db.execute(
  360. select(Recommendation)
  361. .where(
  362. Recommendation.user_id == user.id,
  363. Recommendation.dismissed == False
  364. )
  365. .order_by(Recommendation.created_at.desc())
  366. )
  367. recommendations = result.scalars().all()
  368. return JSONResponse({
  369. "recommendations": [
  370. {
  371. "id": rec.id,
  372. "title": rec.title,
  373. "author": rec.author,
  374. "description": rec.description,
  375. "reason": rec.reason,
  376. "genres": json.loads(rec.genres) if rec.genres else [],
  377. "created_at": rec.created_at.isoformat()
  378. }
  379. for rec in recommendations
  380. ]
  381. })
  382. @app.get("/api/history")
  383. async def get_listening_history(
  384. db: AsyncSession = Depends(get_db),
  385. user: User = Depends(get_current_user)
  386. ):
  387. """Get listening history."""
  388. result = await db.execute(
  389. select(ListeningSession, Book)
  390. .join(Book, ListeningSession.book_id == Book.id)
  391. .where(ListeningSession.user_id == user.id)
  392. .order_by(ListeningSession.last_update.desc())
  393. )
  394. items = result.all()
  395. return JSONResponse({
  396. "history": [
  397. {
  398. "book": {
  399. "id": book.id,
  400. "title": book.title,
  401. "author": book.author,
  402. "cover_url": book.cover_url,
  403. },
  404. "session": {
  405. "progress": session.progress,
  406. "is_finished": session.is_finished,
  407. "started_at": session.started_at.isoformat() if session.started_at else None,
  408. "finished_at": session.finished_at.isoformat() if session.finished_at else None,
  409. }
  410. }
  411. for session, book in items
  412. ]
  413. })
  414. # ==================== Reading Log Routes ====================
  415. @app.get("/reading-log", response_class=HTMLResponse)
  416. async def reading_log_page(
  417. request: Request,
  418. user: User = Depends(get_current_user)
  419. ):
  420. """Reading log page with stats and filters."""
  421. return templates.TemplateResponse(
  422. "reading_log.html",
  423. {
  424. "request": request,
  425. "user": user
  426. }
  427. )
  428. @app.get("/api/reading-log/stats")
  429. async def get_reading_stats(
  430. db: AsyncSession = Depends(get_db),
  431. user: User = Depends(get_current_user),
  432. start_date: Optional[str] = None,
  433. end_date: Optional[str] = None
  434. ):
  435. """Get reading statistics for the user."""
  436. try:
  437. # Parse dates if provided
  438. start_dt = datetime.fromisoformat(start_date) if start_date else None
  439. end_dt = datetime.fromisoformat(end_date) if end_date else None
  440. # Calculate stats
  441. stats_service = ReadingStatsService(db, user.id, user.abs_url)
  442. stats = await stats_service.calculate_stats(start_dt, end_dt)
  443. return JSONResponse(stats)
  444. except Exception as e:
  445. return JSONResponse(
  446. {"status": "error", "message": str(e)},
  447. status_code=500
  448. )
  449. @app.put("/api/sessions/{session_id}/rating")
  450. async def update_session_rating(
  451. session_id: int,
  452. rating: int = Form(...),
  453. db: AsyncSession = Depends(get_db),
  454. user: User = Depends(get_current_user)
  455. ):
  456. """Update the rating for a listening session."""
  457. try:
  458. # Validate rating
  459. if rating < 1 or rating > 5:
  460. raise HTTPException(
  461. status_code=status.HTTP_400_BAD_REQUEST,
  462. detail="Rating must be between 1 and 5"
  463. )
  464. # Get session and verify ownership
  465. result = await db.execute(
  466. select(ListeningSession).where(
  467. ListeningSession.id == session_id,
  468. ListeningSession.user_id == user.id
  469. )
  470. )
  471. session = result.scalar_one_or_none()
  472. if not session:
  473. raise HTTPException(
  474. status_code=status.HTTP_404_NOT_FOUND,
  475. detail="Session not found"
  476. )
  477. # Update rating
  478. session.rating = rating
  479. await db.commit()
  480. return JSONResponse({
  481. "status": "success",
  482. "message": "Rating updated successfully",
  483. "rating": rating
  484. })
  485. except HTTPException as e:
  486. raise e
  487. except Exception as e:
  488. return JSONResponse(
  489. {"status": "error", "message": str(e)},
  490. status_code=500
  491. )
  492. # ==================== Admin Routes ====================
  493. @app.get("/admin", response_class=HTMLResponse)
  494. async def admin_page(
  495. request: Request,
  496. user: User = Depends(get_current_admin)
  497. ):
  498. """Admin panel page."""
  499. return templates.TemplateResponse(
  500. "admin.html",
  501. {
  502. "request": request,
  503. "user": user
  504. }
  505. )
  506. @app.get("/api/admin/settings")
  507. async def get_admin_settings(
  508. db: AsyncSession = Depends(get_db),
  509. admin: User = Depends(get_current_admin)
  510. ):
  511. """Get application settings."""
  512. result = await db.execute(select(AppSettings))
  513. settings = result.scalars().all()
  514. settings_dict = {s.key: s.value for s in settings}
  515. return JSONResponse(settings_dict)
  516. @app.put("/api/admin/settings/{key}")
  517. async def update_setting(
  518. key: str,
  519. value: str = Form(...),
  520. db: AsyncSession = Depends(get_db),
  521. admin: User = Depends(get_current_admin)
  522. ):
  523. """Update an application setting."""
  524. result = await db.execute(
  525. select(AppSettings).where(AppSettings.key == key)
  526. )
  527. setting = result.scalar_one_or_none()
  528. if not setting:
  529. # Create new setting
  530. setting = AppSettings(key=key, value=value)
  531. db.add(setting)
  532. else:
  533. # Update existing
  534. setting.value = value
  535. await db.commit()
  536. return JSONResponse({
  537. "status": "success",
  538. "message": f"Setting {key} updated"
  539. })
  540. @app.get("/api/admin/users")
  541. async def get_users(
  542. db: AsyncSession = Depends(get_db),
  543. admin: User = Depends(get_current_admin)
  544. ):
  545. """Get all users."""
  546. result = await db.execute(select(User).order_by(User.created_at.desc()))
  547. users = result.scalars().all()
  548. users_list = [
  549. {
  550. "id": user.id,
  551. "username": user.username,
  552. "email": user.email,
  553. "display_name": user.display_name,
  554. "is_admin": user.is_admin,
  555. "is_active": user.is_active,
  556. "created_at": user.created_at.isoformat() if user.created_at else None,
  557. "last_login": user.last_login.isoformat() if user.last_login else None,
  558. "is_current": user.id == admin.id
  559. }
  560. for user in users
  561. ]
  562. return JSONResponse({"users": users_list})
  563. @app.put("/api/admin/users/{user_id}/admin")
  564. async def toggle_user_admin(
  565. user_id: int,
  566. is_admin: str = Form(...),
  567. db: AsyncSession = Depends(get_db),
  568. admin: User = Depends(get_current_admin)
  569. ):
  570. """Toggle admin status for a user."""
  571. # Prevent admin from removing their own admin status
  572. if user_id == admin.id:
  573. return JSONResponse(
  574. {"status": "error", "message": "Cannot modify your own admin status"},
  575. status_code=400
  576. )
  577. result = await db.execute(select(User).where(User.id == user_id))
  578. user = result.scalar_one_or_none()
  579. if not user:
  580. raise HTTPException(
  581. status_code=status.HTTP_404_NOT_FOUND,
  582. detail="User not found"
  583. )
  584. user.is_admin = is_admin.lower() == 'true'
  585. await db.commit()
  586. return JSONResponse({
  587. "status": "success",
  588. "message": "User admin status updated"
  589. })
  590. @app.delete("/api/admin/users/{user_id}")
  591. async def delete_user(
  592. user_id: int,
  593. db: AsyncSession = Depends(get_db),
  594. admin: User = Depends(get_current_admin)
  595. ):
  596. """Delete a user."""
  597. # Prevent admin from deleting themselves
  598. if user_id == admin.id:
  599. return JSONResponse(
  600. {"status": "error", "message": "Cannot delete your own account"},
  601. status_code=400
  602. )
  603. result = await db.execute(select(User).where(User.id == user_id))
  604. user = result.scalar_one_or_none()
  605. if not user:
  606. raise HTTPException(
  607. status_code=status.HTTP_404_NOT_FOUND,
  608. detail="User not found"
  609. )
  610. await db.delete(user)
  611. await db.commit()
  612. return JSONResponse({
  613. "status": "success",
  614. "message": "User deleted successfully"
  615. })
  616. @app.get("/health")
  617. async def health_check():
  618. """Health check endpoint."""
  619. return {"status": "healthy"}
  620. @app.get("/api/cover/{book_id}")
  621. async def get_book_cover(
  622. book_id: str,
  623. db: AsyncSession = Depends(get_db),
  624. user: User = Depends(get_current_user)
  625. ):
  626. """Proxy book cover images from Audiobookshelf with authentication."""
  627. import httpx
  628. # Get book from database
  629. result = await db.execute(select(Book).where(Book.id == book_id))
  630. book = result.scalar_one_or_none()
  631. if not book or not book.cover_url:
  632. raise HTTPException(status_code=404, detail="Cover not found")
  633. # Get user's ABS client
  634. abs_client = get_abs_client(user)
  635. # Fetch cover from Audiobookshelf with auth
  636. cover_url = f"{abs_client.base_url}{book.cover_url}"
  637. async with httpx.AsyncClient() as client:
  638. try:
  639. response = await client.get(cover_url, headers=abs_client.headers)
  640. response.raise_for_status()
  641. # Return image with appropriate content type
  642. return Response(
  643. content=response.content,
  644. media_type=response.headers.get("content-type", "image/jpeg")
  645. )
  646. except httpx.HTTPError:
  647. raise HTTPException(status_code=404, detail="Cover image not found")