801e6a050b
- Documentation archivée et réorganisée - Backend: Ajout tests, migrations, library service, rate limiting - Frontend: Suppression Flutter, focus sur interface web HTML/JS - Tailwind CSS ajouté pour le style - Améliorations UX et corrections bugs Generated with [Claude Code](https://claude.com/claude-code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
437 lines
12 KiB
Python
437 lines
12 KiB
Python
"""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,
|
|
}
|