prod: UI Optimisée mise en production
- 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>
This commit is contained in:
@@ -0,0 +1,436 @@
|
||||
"""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,
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Music service."""
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
@@ -8,6 +9,8 @@ from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.track import Track
|
||||
from app.models.artist import Artist
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from app.models.album import Album
|
||||
from app.services.youtube_service import YouTubeService
|
||||
|
||||
@@ -331,7 +334,7 @@ class MusicService:
|
||||
async for chunk in response.aiter_bytes(chunk_size=8192):
|
||||
yield chunk
|
||||
except Exception as e:
|
||||
print(f"Streaming error: {e}")
|
||||
logger.error(f"Streaming error: {e}")
|
||||
|
||||
response_headers = {
|
||||
"Accept-Ranges": "bytes",
|
||||
@@ -356,3 +359,76 @@ class MusicService:
|
||||
status_code=200,
|
||||
headers=response_headers
|
||||
)
|
||||
|
||||
async def get_trending(
|
||||
self,
|
||||
limit: int = 20,
|
||||
days: int = 7,
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Get trending tracks based on play count and recent listens.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of tracks
|
||||
days: Number of days to look back for trending
|
||||
|
||||
Returns:
|
||||
List of trending tracks with metadata
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from app.models.listening_history import ListeningHistory
|
||||
|
||||
# Calculate date threshold
|
||||
threshold = datetime.now() - timedelta(days=days)
|
||||
|
||||
# Get tracks with most plays in the recent period
|
||||
# Count recent plays from ListeningHistory
|
||||
from sqlalchemy import func
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
Track.id,
|
||||
Track.title,
|
||||
Track.duration,
|
||||
Track.youtube_id,
|
||||
Track.image_url,
|
||||
Track.play_count,
|
||||
func.count(ListeningHistory.id).label("recent_plays"),
|
||||
Artist.id.label("artist_id"),
|
||||
Artist.name.label("artist_name"),
|
||||
)
|
||||
.join(Track.artist)
|
||||
.outerjoin(
|
||||
ListeningHistory,
|
||||
(ListeningHistory.track_id == Track.id) &
|
||||
(ListeningHistory.created_at >= threshold)
|
||||
)
|
||||
.group_by(Track.id, Artist.id)
|
||||
.order_by(
|
||||
func.count(ListeningHistory.id).desc(), # Order by recent plays
|
||||
Track.created_at.desc()
|
||||
)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
result = await self.db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
# Convert to dict format
|
||||
tracks = []
|
||||
for row in rows:
|
||||
tracks.append({
|
||||
"id": str(row.id),
|
||||
"title": row.title,
|
||||
"duration": row.duration,
|
||||
"youtube_id": row.youtube_id,
|
||||
"image_url": row.image_url,
|
||||
"play_count": row.play_count,
|
||||
"artist": {
|
||||
"id": str(row.artist_id),
|
||||
"name": row.artist_name
|
||||
} if row.artist_id else None,
|
||||
"artist_name": row.artist_name,
|
||||
})
|
||||
|
||||
return tracks
|
||||
|
||||
Reference in New Issue
Block a user