"""Library service.""" from datetime import datetime, timedelta, timezone from typing import List, Optional from uuid import UUID from sqlalchemy import select, delete, update, func, and_, desc from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.models.listening_history import ListeningHistory from app.models.liked_track import LikedTrack from app.models.track import Track class LibraryService: """Service for library operations (listening history and liked tracks).""" def __init__(self, db: AsyncSession): self.db = db # ============ LISTENING HISTORY METHODS ============ async def add_to_listening_history( self, user_id: UUID, track_id: UUID, played_for: int, completed: bool = False, source: Optional[str] = None, ) -> ListeningHistory: """ Add a track to user's listening history. Args: user_id: User UUID track_id: Track UUID played_for: Duration played in seconds completed: Whether track was played to completion source: Playback source (library, playlist, search, etc.) Returns: Created listening history entry """ history_entry = ListeningHistory( user_id=user_id, track_id=track_id, played_for=played_for, completed=completed, source=source, played_at=datetime.now(timezone.utc).replace(tzinfo=None), ) self.db.add(history_entry) # Update track play count atomically update_stmt = ( update(Track) .where(Track.id == track_id) .values(play_count=Track.play_count + 1) ) await self.db.execute(update_stmt) await self.db.commit() await self.db.refresh(history_entry) return history_entry async def get_listening_history( self, user_id: UUID, limit: int = 50, offset: int = 0, days: Optional[int] = None, ) -> List[ListeningHistory]: """ Get user's listening history. Args: user_id: User UUID limit: Maximum results offset: Pagination offset days: Filter by last N days (None for all time) Returns: List of listening history entries """ stmt = ( select(ListeningHistory) .where(ListeningHistory.user_id == user_id) .options(selectinload(ListeningHistory.track)) .order_by(desc(ListeningHistory.played_at)) ) if days is not None: cutoff_date = (datetime.now(timezone.utc) - timedelta(days=days)).replace(tzinfo=None) stmt = stmt.where(ListeningHistory.played_at >= cutoff_date) stmt = stmt.limit(limit).offset(offset) result = await self.db.execute(stmt) return list(result.scalars().all()) async def get_recently_played( self, user_id: UUID, limit: int = 20, ) -> List[Track]: """ Get user's recently played tracks (unique tracks). Args: user_id: User UUID limit: Maximum results Returns: List of unique recently played tracks """ # Subquery to get most recent play for each track subquery = ( select( ListeningHistory.track_id, func.max(ListeningHistory.played_at).label("last_played"), ) .where(ListeningHistory.user_id == user_id) .group_by(ListeningHistory.track_id) .order_by(desc("last_played")) .limit(limit) .subquery() ) # Main query to get track details stmt = ( select(Track) .join(subquery, Track.id == subquery.c.track_id) .order_by(desc(subquery.c.last_played)) ) result = await self.db.execute(stmt) return list(result.scalars().all()) async def get_most_played_tracks( self, user_id: UUID, limit: int = 20, days: Optional[int] = None, ) -> List[tuple[Track, int]]: """ Get user's most played tracks. Args: user_id: User UUID limit: Maximum results days: Filter by last N days (None for all time) Returns: List of tuples (track, play_count) """ stmt = ( select( Track, func.count(ListeningHistory.id).label("play_count"), ) .join(ListeningHistory, Track.id == ListeningHistory.track_id) .where(ListeningHistory.user_id == user_id) .group_by(Track.id) .order_by(desc("play_count")) .limit(limit) ) if days is not None: cutoff_date = (datetime.now(timezone.utc) - timedelta(days=days)).replace(tzinfo=None) stmt = stmt.where(ListeningHistory.played_at >= cutoff_date) result = await self.db.execute(stmt) return [(row[0], row[1]) for row in result.all()] async def clear_listening_history( self, user_id: UUID, before_date: Optional[datetime] = None, ) -> int: """ Clear user's listening history. Args: user_id: User UUID before_date: Clear history before this date (None for all) Returns: Number of entries deleted """ stmt = delete(ListeningHistory).where(ListeningHistory.user_id == user_id) if before_date is not None: stmt = stmt.where(ListeningHistory.played_at < before_date) result = await self.db.execute(stmt) await self.db.commit() return result.rowcount # ============ LIKED TRACKS METHODS ============ async def like_track( self, user_id: UUID, track_id: UUID, notes: Optional[str] = None, ) -> LikedTrack: """ Add a track to user's liked tracks. Args: user_id: User UUID track_id: Track UUID notes: Optional user notes Returns: Created liked track entry Raises: ValueError: If track is already liked """ # Check if already liked existing_stmt = select(LikedTrack).where( and_( LikedTrack.user_id == user_id, LikedTrack.track_id == track_id, ) ) existing_result = await self.db.execute(existing_stmt) existing = existing_result.scalar_one_or_none() if existing: raise ValueError("Track is already in liked tracks") liked_track = LikedTrack( user_id=user_id, track_id=track_id, notes=notes, ) self.db.add(liked_track) await self.db.commit() await self.db.refresh(liked_track) return liked_track async def unlike_track( self, user_id: UUID, track_id: UUID, ) -> None: """ Remove a track from user's liked tracks. Args: user_id: User UUID track_id: Track UUID Raises: ValueError: If track is not in liked tracks """ stmt = select(LikedTrack).where( and_( LikedTrack.user_id == user_id, LikedTrack.track_id == track_id, ) ) result = await self.db.execute(stmt) liked_track = result.scalar_one_or_none() if not liked_track: raise ValueError("Track is not in liked tracks") await self.db.delete(liked_track) await self.db.commit() async def get_liked_tracks( self, user_id: UUID, limit: int = 50, offset: int = 0, ) -> List[LikedTrack]: """ Get user's liked tracks. Args: user_id: User UUID limit: Maximum results offset: Pagination offset Returns: List of liked track entries """ stmt = ( select(LikedTrack) .where(LikedTrack.user_id == user_id) .options(selectinload(LikedTrack.track)) .order_by(desc(LikedTrack.created_at)) .limit(limit) .offset(offset) ) result = await self.db.execute(stmt) return list(result.scalars().all()) async def check_track_liked( self, user_id: UUID, track_id: UUID, ) -> bool: """ Check if a track is in user's liked tracks. Args: user_id: User UUID track_id: Track UUID Returns: True if track is liked, False otherwise """ stmt = select(LikedTrack).where( and_( LikedTrack.user_id == user_id, LikedTrack.track_id == track_id, ) ) result = await self.db.execute(stmt) liked_track = result.scalar_one_or_none() return liked_track is not None async def update_liked_track_notes( self, user_id: UUID, track_id: UUID, notes: str, ) -> LikedTrack: """ Update notes for a liked track. Args: user_id: User UUID track_id: Track UUID notes: New notes Returns: Updated liked track entry Raises: ValueError: If track is not in liked tracks """ stmt = select(LikedTrack).where( and_( LikedTrack.user_id == user_id, LikedTrack.track_id == track_id, ) ) result = await self.db.execute(stmt) liked_track = result.scalar_one_or_none() if not liked_track: raise ValueError("Track is not in liked tracks") liked_track.notes = notes liked_track.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) await self.db.commit() await self.db.refresh(liked_track) return liked_track # ============ LIBRARY STATISTICS METHODS ============ async def get_library_stats( self, user_id: UUID, ) -> dict: """ Get user's library statistics. Args: user_id: User UUID Returns: Dictionary with library statistics """ # Total liked tracks liked_count_stmt = ( select(func.count()) .select_from(LikedTrack) .where(LikedTrack.user_id == user_id) ) liked_count_result = await self.db.execute(liked_count_stmt) liked_count = liked_count_result.scalar() # Total plays total_plays_stmt = ( select(func.count()) .select_from(ListeningHistory) .where(ListeningHistory.user_id == user_id) ) total_plays_result = await self.db.execute(total_plays_stmt) total_plays = total_plays_result.scalar() # Plays in last 30 days thirty_days_ago = (datetime.now(timezone.utc) - timedelta(days=30)).replace(tzinfo=None) recent_plays_stmt = ( select(func.count()) .select_from(ListeningHistory) .where( and_( ListeningHistory.user_id == user_id, ListeningHistory.played_at >= thirty_days_ago, ) ) ) recent_plays_result = await self.db.execute(recent_plays_stmt) recent_plays = recent_plays_result.scalar() # Unique tracks played unique_tracks_stmt = ( select(func.count(func.distinct(ListeningHistory.track_id))) .select_from(ListeningHistory) .where(ListeningHistory.user_id == user_id) ) unique_tracks_result = await self.db.execute(unique_tracks_stmt) unique_tracks = unique_tracks_result.scalar() return { "liked_tracks_count": liked_count, "total_plays": total_plays, "plays_last_30_days": recent_plays, "unique_tracks_played": unique_tracks, }