stats.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. """
  2. Reading statistics service.
  3. Calculates various statistics about user's reading habits.
  4. """
  5. from datetime import datetime, timedelta
  6. from typing import Dict, Any, List, Optional
  7. from sqlalchemy.ext.asyncio import AsyncSession
  8. from sqlalchemy import select, func, and_
  9. import json
  10. from app.models import ListeningSession, Book
  11. class ReadingStatsService:
  12. """Service for calculating reading statistics."""
  13. def __init__(self, db: AsyncSession, user_id: int, abs_url: str = None):
  14. """
  15. Initialize statistics service for a user.
  16. Args:
  17. db: Database session
  18. user_id: User ID to calculate stats for
  19. abs_url: Audiobookshelf server URL for constructing full cover URLs
  20. """
  21. self.db = db
  22. self.user_id = user_id
  23. self.abs_url = abs_url
  24. async def calculate_stats(
  25. self,
  26. start_date: Optional[datetime] = None,
  27. end_date: Optional[datetime] = None
  28. ) -> Dict[str, Any]:
  29. """
  30. Calculate comprehensive reading statistics.
  31. Args:
  32. start_date: Optional start date filter
  33. end_date: Optional end date filter
  34. Returns:
  35. Dictionary with various statistics
  36. """
  37. # Build base query
  38. query = select(ListeningSession).where(
  39. ListeningSession.user_id == self.user_id
  40. )
  41. if start_date:
  42. query = query.where(ListeningSession.started_at >= start_date)
  43. if end_date:
  44. query = query.where(ListeningSession.started_at <= end_date)
  45. result = await self.db.execute(query)
  46. sessions = result.scalars().all()
  47. # Calculate finished books
  48. finished_sessions = [s for s in sessions if s.is_finished and s.finished_at]
  49. # Get book details for finished books first (needed for calculations)
  50. finished_book_ids = [s.book_id for s in finished_sessions]
  51. books_dict = {}
  52. if finished_book_ids:
  53. books_result = await self.db.execute(
  54. select(Book).where(Book.id.in_(finished_book_ids))
  55. )
  56. books_dict = {book.id: book for book in books_result.scalars().all()}
  57. # Calculate total listening time using actual book durations
  58. total_hours = 0.0
  59. for session in finished_sessions:
  60. book = books_dict.get(session.book_id)
  61. if book and book.duration:
  62. # duration is in seconds, convert to hours
  63. total_hours += book.duration / 3600
  64. # Calculate average rating
  65. rated_sessions = [s for s in finished_sessions if s.rating]
  66. avg_rating = (
  67. sum(s.rating for s in rated_sessions) / len(rated_sessions)
  68. if rated_sessions else None
  69. )
  70. # Calculate books per month
  71. books_by_month = await self._calculate_books_by_month(finished_sessions)
  72. # Calculate books by genre
  73. books_by_genre = await self._calculate_books_by_genre(finished_sessions, books_dict)
  74. # Calculate current streak
  75. streak = await self._calculate_streak(finished_sessions)
  76. # Calculate recent books (show all finished books)
  77. recent_books = await self._get_recent_books(finished_sessions, books_dict, limit=1000)
  78. return {
  79. "total_books": len(finished_sessions),
  80. "total_hours": round(total_hours, 1),
  81. "average_rating": round(avg_rating, 1) if avg_rating else None,
  82. "books_in_progress": len([s for s in sessions if not s.is_finished]),
  83. "books_by_month": books_by_month,
  84. "books_by_genre": books_by_genre,
  85. "current_streak": streak,
  86. "recent_books": recent_books,
  87. "total_sessions": len(sessions)
  88. }
  89. async def _calculate_books_by_month(
  90. self,
  91. finished_sessions: List[ListeningSession]
  92. ) -> List[Dict[str, Any]]:
  93. """
  94. Calculate books finished per month.
  95. Returns:
  96. List of {month, year, count} dictionaries
  97. """
  98. books_by_month = {}
  99. for session in finished_sessions:
  100. if not session.finished_at:
  101. continue
  102. month_key = (session.finished_at.year, session.finished_at.month)
  103. books_by_month[month_key] = books_by_month.get(month_key, 0) + 1
  104. # Convert to list and sort
  105. result = [
  106. {
  107. "year": year,
  108. "month": month,
  109. "count": count,
  110. "month_name": datetime(year, month, 1).strftime("%B")
  111. }
  112. for (year, month), count in sorted(books_by_month.items())
  113. ]
  114. return result
  115. async def _calculate_books_by_genre(
  116. self,
  117. finished_sessions: List[ListeningSession],
  118. books_dict: Dict[str, Book]
  119. ) -> List[Dict[str, Any]]:
  120. """
  121. Calculate books finished by genre.
  122. Returns:
  123. List of {genre, count} dictionaries sorted by count
  124. """
  125. genre_counts = {}
  126. for session in finished_sessions:
  127. book = books_dict.get(session.book_id)
  128. if not book or not book.genres:
  129. continue
  130. try:
  131. genres = json.loads(book.genres) if isinstance(book.genres, str) else book.genres
  132. for genre in genres:
  133. genre_counts[genre] = genre_counts.get(genre, 0) + 1
  134. except (json.JSONDecodeError, TypeError):
  135. continue
  136. # Sort by count descending
  137. result = [
  138. {"genre": genre, "count": count}
  139. for genre, count in sorted(
  140. genre_counts.items(),
  141. key=lambda x: x[1],
  142. reverse=True
  143. )
  144. ]
  145. return result
  146. async def _calculate_streak(
  147. self,
  148. finished_sessions: List[ListeningSession]
  149. ) -> int:
  150. """
  151. Calculate current reading streak (consecutive days with finished books).
  152. Returns:
  153. Number of consecutive days
  154. """
  155. if not finished_sessions:
  156. return 0
  157. # Get unique finish dates, sorted descending
  158. finish_dates = sorted(
  159. {s.finished_at.date() for s in finished_sessions if s.finished_at},
  160. reverse=True
  161. )
  162. if not finish_dates:
  163. return 0
  164. # Check if most recent is today or yesterday
  165. today = datetime.now().date()
  166. if finish_dates[0] not in [today, today - timedelta(days=1)]:
  167. return 0
  168. # Count consecutive days
  169. streak = 1
  170. for i in range(len(finish_dates) - 1):
  171. diff = (finish_dates[i] - finish_dates[i + 1]).days
  172. if diff == 1:
  173. streak += 1
  174. elif diff == 0:
  175. # Same day, continue
  176. continue
  177. else:
  178. break
  179. return streak
  180. async def _get_recent_books(
  181. self,
  182. finished_sessions: List[ListeningSession],
  183. books_dict: Dict[str, Book],
  184. limit: int = 10
  185. ) -> List[Dict[str, Any]]:
  186. """
  187. Get recently finished books with details.
  188. Returns:
  189. List of book details with finish date and rating
  190. """
  191. # Sort by finish date descending
  192. sorted_sessions = sorted(
  193. [s for s in finished_sessions if s.finished_at],
  194. key=lambda x: x.finished_at,
  195. reverse=True
  196. )[:limit]
  197. recent = []
  198. for session in sorted_sessions:
  199. book = books_dict.get(session.book_id)
  200. if not book:
  201. continue
  202. listening_duration = None
  203. if book.duration:
  204. # Use actual book duration instead of wall-clock time
  205. listening_duration = round(book.duration / 3600, 1)
  206. recent.append({
  207. "session_id": session.id, # Session ID for rating updates
  208. "book_id": book.id,
  209. "title": book.title,
  210. "author": book.author,
  211. "finished_at": session.finished_at.isoformat(),
  212. "rating": session.rating,
  213. "listening_duration": listening_duration,
  214. "cover_url": book.cover_url # Now stores local path like /static/covers/book_id.jpg
  215. })
  216. return recent