main.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. from fastapi import FastAPI, Request, Depends
  2. from fastapi.templating import Jinja2Templates
  3. from fastapi.staticfiles import StaticFiles
  4. from fastapi.responses import HTMLResponse, JSONResponse
  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 app.database import init_db, get_db
  11. from app.models import Book, ListeningSession, Recommendation
  12. from app.abs_client import AudiobookshelfClient
  13. from app.recommender import BookRecommender
  14. from app.config import get_settings
  15. @asynccontextmanager
  16. async def lifespan(app: FastAPI):
  17. """Initialize database on startup."""
  18. await init_db()
  19. yield
  20. # Initialize FastAPI app
  21. app = FastAPI(
  22. title="Audiobookshelf Recommendations",
  23. description="AI-powered book recommendations based on your listening history",
  24. lifespan=lifespan
  25. )
  26. # Setup templates and static files
  27. templates = Jinja2Templates(directory="app/templates")
  28. app.mount("/static", StaticFiles(directory="app/static"), name="static")
  29. # Initialize clients
  30. abs_client = AudiobookshelfClient()
  31. recommender = BookRecommender()
  32. @app.get("/", response_class=HTMLResponse)
  33. async def home(request: Request, db: AsyncSession = Depends(get_db)):
  34. """Home page showing dashboard."""
  35. # Get recent books and recommendations
  36. recent_sessions = await db.execute(
  37. select(ListeningSession)
  38. .order_by(ListeningSession.last_update.desc())
  39. .limit(10)
  40. )
  41. sessions = recent_sessions.scalars().all()
  42. # Get book details for sessions
  43. books = []
  44. for session in sessions:
  45. book_result = await db.execute(
  46. select(Book).where(Book.id == session.book_id)
  47. )
  48. book = book_result.scalar_one_or_none()
  49. if book:
  50. books.append({
  51. "book": book,
  52. "session": session
  53. })
  54. # Get recent recommendations
  55. recs_result = await db.execute(
  56. select(Recommendation)
  57. .where(Recommendation.dismissed == False)
  58. .order_by(Recommendation.created_at.desc())
  59. .limit(5)
  60. )
  61. recommendations = recs_result.scalars().all()
  62. return templates.TemplateResponse(
  63. "index.html",
  64. {
  65. "request": request,
  66. "books": books,
  67. "recommendations": recommendations
  68. }
  69. )
  70. @app.get("/api/sync")
  71. async def sync_with_audiobookshelf(db: AsyncSession = Depends(get_db)):
  72. """Sync library and progress from Audiobookshelf."""
  73. try:
  74. # Get user info which includes all media progress
  75. user_info = await abs_client.get_user_info()
  76. media_progress = user_info.get("mediaProgress", [])
  77. synced_count = 0
  78. for progress_item in media_progress:
  79. # Skip podcast episodes, only process books
  80. if progress_item.get("mediaItemType") != "book":
  81. continue
  82. library_item_id = progress_item.get("libraryItemId")
  83. if not library_item_id:
  84. continue
  85. # Fetch full library item details
  86. try:
  87. item = await abs_client.get_item_details(library_item_id)
  88. except:
  89. # Skip if item not found
  90. continue
  91. # Extract book info
  92. media = item.get("media", {})
  93. metadata = media.get("metadata", {})
  94. book_id = item.get("id")
  95. if not book_id:
  96. continue
  97. # Check if book exists in DB
  98. result = await db.execute(select(Book).where(Book.id == book_id))
  99. book = result.scalar_one_or_none()
  100. # Create or update book
  101. if not book:
  102. book = Book(
  103. id=book_id,
  104. title=metadata.get("title", "Unknown"),
  105. author=metadata.get("authorName", "Unknown"),
  106. narrator=metadata.get("narratorName"),
  107. description=metadata.get("description"),
  108. genres=json.dumps(metadata.get("genres", [])),
  109. tags=json.dumps(media.get("tags", [])),
  110. duration=media.get("duration", 0),
  111. cover_url=media.get("coverPath")
  112. )
  113. db.add(book)
  114. else:
  115. # Update existing book
  116. book.title = metadata.get("title", book.title)
  117. book.author = metadata.get("authorName", book.author)
  118. book.updated_at = datetime.now()
  119. # Update or create listening session
  120. progress_data = progress_item.get("progress", 0)
  121. current_time = progress_item.get("currentTime", 0)
  122. is_finished = progress_item.get("isFinished", False)
  123. started_at_ts = progress_item.get("startedAt")
  124. finished_at_ts = progress_item.get("finishedAt")
  125. session_result = await db.execute(
  126. select(ListeningSession)
  127. .where(ListeningSession.book_id == book_id)
  128. .order_by(ListeningSession.last_update.desc())
  129. .limit(1)
  130. )
  131. session = session_result.scalar_one_or_none()
  132. if not session:
  133. session = ListeningSession(
  134. book_id=book_id,
  135. progress=progress_data,
  136. current_time=current_time,
  137. is_finished=is_finished,
  138. started_at=datetime.fromtimestamp(started_at_ts / 1000) if started_at_ts else datetime.now(),
  139. finished_at=datetime.fromtimestamp(finished_at_ts / 1000) if finished_at_ts else None
  140. )
  141. db.add(session)
  142. else:
  143. # Update existing session
  144. session.progress = progress_data
  145. session.current_time = current_time
  146. session.is_finished = is_finished
  147. if finished_at_ts and not session.finished_at:
  148. session.finished_at = datetime.fromtimestamp(finished_at_ts / 1000)
  149. synced_count += 1
  150. await db.commit()
  151. return JSONResponse({
  152. "status": "success",
  153. "synced": synced_count,
  154. "message": f"Synced {synced_count} books from Audiobookshelf"
  155. })
  156. except Exception as e:
  157. return JSONResponse(
  158. {"status": "error", "message": str(e)},
  159. status_code=500
  160. )
  161. @app.get("/api/recommendations/generate")
  162. async def generate_recommendations(db: AsyncSession = Depends(get_db)):
  163. """Generate new AI recommendations based on reading history."""
  164. try:
  165. # Get finished books for context
  166. finished_result = await db.execute(
  167. select(ListeningSession, Book)
  168. .join(Book, ListeningSession.book_id == Book.id)
  169. .where(ListeningSession.is_finished == True)
  170. .order_by(ListeningSession.finished_at.desc())
  171. .limit(20)
  172. )
  173. finished_items = finished_result.all()
  174. if not finished_items:
  175. return JSONResponse({
  176. "status": "error",
  177. "message": "No reading history found. Please sync with Audiobookshelf first."
  178. })
  179. # Format reading history
  180. reading_history = []
  181. for session, book in finished_items:
  182. reading_history.append({
  183. "title": book.title,
  184. "author": book.author,
  185. "genres": json.loads(book.genres) if book.genres else [],
  186. "progress": session.progress,
  187. "is_finished": session.is_finished
  188. })
  189. # Generate recommendations
  190. new_recs = await recommender.generate_recommendations(
  191. reading_history, num_recommendations=5
  192. )
  193. # Save to database
  194. for rec in new_recs:
  195. recommendation = Recommendation(
  196. title=rec.get("title"),
  197. author=rec.get("author"),
  198. description=rec.get("description"),
  199. reason=rec.get("reason"),
  200. genres=json.dumps(rec.get("genres", []))
  201. )
  202. db.add(recommendation)
  203. await db.commit()
  204. return JSONResponse({
  205. "status": "success",
  206. "recommendations": new_recs,
  207. "count": len(new_recs)
  208. })
  209. except Exception as e:
  210. return JSONResponse(
  211. {"status": "error", "message": str(e)},
  212. status_code=500
  213. )
  214. @app.get("/api/recommendations")
  215. async def get_recommendations(db: AsyncSession = Depends(get_db)):
  216. """Get saved recommendations."""
  217. result = await db.execute(
  218. select(Recommendation)
  219. .where(Recommendation.dismissed == False)
  220. .order_by(Recommendation.created_at.desc())
  221. )
  222. recommendations = result.scalars().all()
  223. return JSONResponse({
  224. "recommendations": [
  225. {
  226. "id": rec.id,
  227. "title": rec.title,
  228. "author": rec.author,
  229. "description": rec.description,
  230. "reason": rec.reason,
  231. "genres": json.loads(rec.genres) if rec.genres else [],
  232. "created_at": rec.created_at.isoformat()
  233. }
  234. for rec in recommendations
  235. ]
  236. })
  237. @app.get("/api/history")
  238. async def get_listening_history(db: AsyncSession = Depends(get_db)):
  239. """Get listening history."""
  240. result = await db.execute(
  241. select(ListeningSession, Book)
  242. .join(Book, ListeningSession.book_id == Book.id)
  243. .order_by(ListeningSession.last_update.desc())
  244. )
  245. items = result.all()
  246. return JSONResponse({
  247. "history": [
  248. {
  249. "book": {
  250. "id": book.id,
  251. "title": book.title,
  252. "author": book.author,
  253. "cover_url": book.cover_url,
  254. },
  255. "session": {
  256. "progress": session.progress,
  257. "is_finished": session.is_finished,
  258. "started_at": session.started_at.isoformat() if session.started_at else None,
  259. "finished_at": session.finished_at.isoformat() if session.finished_at else None,
  260. }
  261. }
  262. for session, book in items
  263. ]
  264. })
  265. @app.get("/health")
  266. async def health_check():
  267. """Health check endpoint."""
  268. return {"status": "healthy"}