stats.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  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. # Calculate total listening time
  50. total_hours = 0.0
  51. for session in finished_sessions:
  52. if session.started_at and session.finished_at:
  53. duration = (session.finished_at - session.started_at).total_seconds() / 3600
  54. total_hours += duration
  55. # Get book details for finished books
  56. finished_book_ids = [s.book_id for s in finished_sessions]
  57. books_dict = {}
  58. if finished_book_ids:
  59. books_result = await self.db.execute(
  60. select(Book).where(Book.id.in_(finished_book_ids))
  61. )
  62. books_dict = {book.id: book for book in books_result.scalars().all()}
  63. # Calculate average rating
  64. rated_sessions = [s for s in finished_sessions if s.rating]
  65. avg_rating = (
  66. sum(s.rating for s in rated_sessions) / len(rated_sessions)
  67. if rated_sessions else None
  68. )
  69. # Calculate books per month
  70. books_by_month = await self._calculate_books_by_month(finished_sessions)
  71. # Calculate books by genre
  72. books_by_genre = await self._calculate_books_by_genre(finished_sessions, books_dict)
  73. # Calculate current streak
  74. streak = await self._calculate_streak(finished_sessions)
  75. # Calculate recent books (show all finished books)
  76. recent_books = await self._get_recent_books(finished_sessions, books_dict, limit=1000)
  77. return {
  78. "total_books": len(finished_sessions),
  79. "total_hours": round(total_hours, 1),
  80. "average_rating": round(avg_rating, 1) if avg_rating else None,
  81. "books_in_progress": len([s for s in sessions if not s.is_finished]),
  82. "books_by_month": books_by_month,
  83. "books_by_genre": books_by_genre,
  84. "current_streak": streak,
  85. "recent_books": recent_books,
  86. "total_sessions": len(sessions)
  87. }
  88. async def _calculate_books_by_month(
  89. self,
  90. finished_sessions: List[ListeningSession]
  91. ) -> List[Dict[str, Any]]:
  92. """
  93. Calculate books finished per month.
  94. Returns:
  95. List of {month, year, count} dictionaries
  96. """
  97. books_by_month = {}
  98. for session in finished_sessions:
  99. if not session.finished_at:
  100. continue
  101. month_key = (session.finished_at.year, session.finished_at.month)
  102. books_by_month[month_key] = books_by_month.get(month_key, 0) + 1
  103. # Convert to list and sort
  104. result = [
  105. {
  106. "year": year,
  107. "month": month,
  108. "count": count,
  109. "month_name": datetime(year, month, 1).strftime("%B")
  110. }
  111. for (year, month), count in sorted(books_by_month.items())
  112. ]
  113. return result
  114. async def _calculate_books_by_genre(
  115. self,
  116. finished_sessions: List[ListeningSession],
  117. books_dict: Dict[str, Book]
  118. ) -> List[Dict[str, Any]]:
  119. """
  120. Calculate books finished by genre.
  121. Returns:
  122. List of {genre, count} dictionaries sorted by count
  123. """
  124. genre_counts = {}
  125. for session in finished_sessions:
  126. book = books_dict.get(session.book_id)
  127. if not book or not book.genres:
  128. continue
  129. try:
  130. genres = json.loads(book.genres) if isinstance(book.genres, str) else book.genres
  131. for genre in genres:
  132. genre_counts[genre] = genre_counts.get(genre, 0) + 1
  133. except (json.JSONDecodeError, TypeError):
  134. continue
  135. # Sort by count descending
  136. result = [
  137. {"genre": genre, "count": count}
  138. for genre, count in sorted(
  139. genre_counts.items(),
  140. key=lambda x: x[1],
  141. reverse=True
  142. )
  143. ]
  144. return result
  145. async def _calculate_streak(
  146. self,
  147. finished_sessions: List[ListeningSession]
  148. ) -> int:
  149. """
  150. Calculate current reading streak (consecutive days with finished books).
  151. Returns:
  152. Number of consecutive days
  153. """
  154. if not finished_sessions:
  155. return 0
  156. # Get unique finish dates, sorted descending
  157. finish_dates = sorted(
  158. {s.finished_at.date() for s in finished_sessions if s.finished_at},
  159. reverse=True
  160. )
  161. if not finish_dates:
  162. return 0
  163. # Check if most recent is today or yesterday
  164. today = datetime.now().date()
  165. if finish_dates[0] not in [today, today - timedelta(days=1)]:
  166. return 0
  167. # Count consecutive days
  168. streak = 1
  169. for i in range(len(finish_dates) - 1):
  170. diff = (finish_dates[i] - finish_dates[i + 1]).days
  171. if diff == 1:
  172. streak += 1
  173. elif diff == 0:
  174. # Same day, continue
  175. continue
  176. else:
  177. break
  178. return streak
  179. async def _get_recent_books(
  180. self,
  181. finished_sessions: List[ListeningSession],
  182. books_dict: Dict[str, Book],
  183. limit: int = 10
  184. ) -> List[Dict[str, Any]]:
  185. """
  186. Get recently finished books with details.
  187. Returns:
  188. List of book details with finish date and rating
  189. """
  190. # Sort by finish date descending
  191. sorted_sessions = sorted(
  192. [s for s in finished_sessions if s.finished_at],
  193. key=lambda x: x.finished_at,
  194. reverse=True
  195. )[:limit]
  196. recent = []
  197. for session in sorted_sessions:
  198. book = books_dict.get(session.book_id)
  199. if not book:
  200. continue
  201. listening_duration = None
  202. if session.started_at and session.finished_at:
  203. duration_hours = (session.finished_at - session.started_at).total_seconds() / 3600
  204. listening_duration = round(duration_hours, 1)
  205. # Construct full cover URL if abs_url is available
  206. cover_url = book.cover_url
  207. if cover_url and self.abs_url:
  208. # If cover_url is a relative path, prepend abs_url
  209. if not cover_url.startswith(('http://', 'https://')):
  210. cover_url = f"{self.abs_url.rstrip('/')}{cover_url if cover_url.startswith('/') else '/' + cover_url}"
  211. recent.append({
  212. "book_id": book.id,
  213. "title": book.title,
  214. "author": book.author,
  215. "finished_at": session.finished_at.isoformat(),
  216. "rating": session.rating,
  217. "listening_duration": listening_duration,
  218. "cover_url": cover_url
  219. })
  220. return recent