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:
root
2026-01-20 09:56:39 +00:00
parent bc03225e47
commit 801e6a050b
263 changed files with 33100 additions and 23058 deletions
+48
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, HTTPException, status
from app.api.dependencies import AuthServiceDep, CurrentUser, DBSession
from app.schemas.auth import (
ChangePasswordRequest,
LoginRequest,
RefreshTokenRequest,
Token,
@@ -176,3 +177,50 @@ async def logout(
# - Log the logout event
return None
@router.post("/change-password")
async def change_password(
password_data: ChangePasswordRequest,
current_user: CurrentUser,
auth_service: AuthServiceDep,
db: DBSession,
):
"""
Change user password.
Requires authentication and current password verification.
- **password_data**: Object containing old_password and new_password
"""
from app.core.security import verify_password, hash_password
# Verify old password
if not verify_password(password_data.old_password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Current password is incorrect"
)
# Validate new password
if len(password_data.new_password) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="New password must be at least 8 characters"
)
if password_data.old_password == password_data.new_password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="New password must be different from current password"
)
# Hash new password
new_password_hash = hash_password(password_data.new_password)
# Update password
current_user.password_hash = new_password_hash
await db.commit()
await db.refresh(current_user)
return {"message": "Password changed successfully"}
+516
View File
@@ -0,0 +1,516 @@
"""Library API routes."""
from typing import List, Optional
from datetime import datetime
from fastapi import APIRouter, HTTPException, Query, status
from app.models.track import Track
from app.api.dependencies import CurrentUser, DBSession
from app.schemas.library import (
ListeningHistoryCreate,
ListeningHistoryResponse,
ListeningHistoryStats,
LibraryStatsResponse,
LikedTrackCreate,
LikedTrackResponse,
LikedTrackUpdate,
LikedTrackCheckResponse,
RecentlyPlayedResponse,
MostPlayedTrackResponse,
MostPlayedTracksResponse,
)
from app.services.library_service import LibraryService
router = APIRouter(prefix="/library", tags=["library"])
def build_track_response(track: Track) -> dict:
"""
Build standardized track response dictionary.
Args:
track: Track model instance
Returns:
Dictionary with track data including artist and album info
"""
return {
"id": str(track.id),
"title": track.title,
"duration": track.duration,
"artist": {
"id": str(track.artist.id),
"name": track.artist.name,
} if track.artist else None,
"album": {
"id": str(track.album.id),
"name": track.album.name,
} if track.album else None,
"image_url": track.image_url,
"play_count": track.play_count,
}
# ============ LISTENING HISTORY ENDPOINTS ============
@router.post("/history", response_model=ListeningHistoryResponse, status_code=status.HTTP_201_CREATED)
async def add_to_history(
history_data: ListeningHistoryCreate,
current_user: CurrentUser,
db: DBSession,
):
"""
Add a track to listening history.
- **track_id**: Track UUID
- **played_for**: Duration played in seconds
- **completed**: Whether track was played to completion (default: false)
- **source**: Playback source (library, playlist, search, etc.)
"""
library_service = LibraryService(db)
history_entry = await library_service.add_to_listening_history(
user_id=current_user.id,
track_id=history_data.track_id,
played_for=history_data.played_for,
completed=history_data.completed,
source=history_data.source,
)
# Load track details
from sqlalchemy import select
track_stmt = select(Track).where(Track.id == history_entry.track_id)
track_result = await db.execute(track_stmt)
track = track_result.scalar_one_or_none()
# Build response manually to avoid SQLAlchemy object validation issues
response_data = {
"id": str(history_entry.id),
"user_id": str(history_entry.user_id),
"track_id": str(history_entry.track_id),
"played_for": history_entry.played_for,
"completed": history_entry.completed,
"source": history_entry.source,
"played_at": history_entry.played_at.isoformat(),
"created_at": history_entry.created_at.isoformat(),
}
if track:
response_data["track"] = build_track_response(track)
return ListeningHistoryResponse(**response_data)
@router.get("/history", response_model=List[ListeningHistoryResponse])
async def get_listening_history(
current_user: CurrentUser,
db: DBSession,
limit: int = Query(50, ge=1, le=100, description="Maximum results"),
offset: int = Query(0, ge=0, description="Pagination offset"),
days: int = Query(None, ge=1, le=365, description="Filter by last N days"),
):
"""
Get user's listening history.
- **limit**: Maximum results (1-100, default: 50)
- **offset**: Pagination offset (default: 0)
- **days**: Filter by last N days (1-365, optional)
"""
library_service = LibraryService(db)
history_entries = await library_service.get_listening_history(
user_id=current_user.id,
limit=limit,
offset=offset,
days=days,
)
responses = []
for entry in history_entries:
# Build response manually to avoid SQLAlchemy object validation issues
response_data = {
"id": str(entry.id),
"user_id": str(entry.user_id),
"track_id": str(entry.track_id),
"played_for": entry.played_for,
"completed": entry.completed,
"source": entry.source,
"played_at": entry.played_at.isoformat(),
"created_at": entry.created_at.isoformat(),
}
# Add track info if available
if entry.track:
response_data["track"] = build_track_response(entry.track)
responses.append(ListeningHistoryResponse(**response_data))
return responses
@router.get("/history/recent", response_model=RecentlyPlayedResponse)
async def get_recently_played(
current_user: CurrentUser,
db: DBSession,
limit: int = Query(20, ge=1, le=50, description="Maximum results"),
):
"""
Get user's recently played tracks (unique tracks).
- **limit**: Maximum results (1-50, default: 20)
"""
library_service = LibraryService(db)
tracks = await library_service.get_recently_played(
user_id=current_user.id,
limit=limit,
)
track_data = []
for track in tracks:
track_data.append(build_track_response(track))
return RecentlyPlayedResponse(tracks=track_data, total=len(tracks))
@router.get("/history/most-played", response_model=MostPlayedTracksResponse)
async def get_most_played(
current_user: CurrentUser,
db: DBSession,
limit: int = Query(20, ge=1, le=50, description="Maximum results"),
days: int = Query(None, ge=1, le=365, description="Filter by last N days"),
):
"""
Get user's most played tracks.
- **limit**: Maximum results (1-50, default: 20)
- **days**: Filter by last N days (1-365, optional)
"""
library_service = LibraryService(db)
tracks_with_count = await library_service.get_most_played_tracks(
user_id=current_user.id,
limit=limit,
days=days,
)
track_data = []
for track, play_count in tracks_with_count:
track_response = MostPlayedTrackResponse(
track=build_track_response(track),
play_count=play_count,
)
track_data.append(track_response)
return MostPlayedTracksResponse(tracks=track_data, total=len(track_data))
@router.delete("/history", status_code=status.HTTP_204_NO_CONTENT)
async def clear_listening_history(
current_user: CurrentUser,
db: DBSession,
before_date: datetime = Query(None, description="Clear history before this date (ISO 8601)"),
):
"""
Clear user's listening history.
- **before_date**: Optional cutoff date (ISO 8601 format). If not provided, clears all history.
"""
library_service = LibraryService(db)
await library_service.clear_listening_history(
user_id=current_user.id,
before_date=before_date,
)
# ============ LIKED TRACKS ENDPOINTS ============
@router.post("/liked", response_model=LikedTrackResponse, status_code=status.HTTP_201_CREATED)
async def like_track(
like_data: LikedTrackCreate,
current_user: CurrentUser,
db: DBSession,
):
"""
Add a track to user's liked tracks.
- **track_id**: Track UUID
- **notes**: Optional user notes (max 1000 characters)
"""
library_service = LibraryService(db)
try:
liked_track = await library_service.like_track(
user_id=current_user.id,
track_id=like_data.track_id,
notes=like_data.notes,
)
except ValueError as e:
if "already" in str(e).lower():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(e),
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
# Load track details
from sqlalchemy import select
track_stmt = select(Track).where(Track.id == liked_track.track_id)
track_result = await db.execute(track_stmt)
track = track_result.scalar_one_or_none()
# Build response manually to avoid SQLAlchemy object validation issues
response_data = {
"id": str(liked_track.id),
"user_id": str(liked_track.user_id),
"track_id": str(liked_track.track_id),
"notes": liked_track.notes,
"created_at": liked_track.created_at.isoformat(),
"updated_at": liked_track.updated_at.isoformat(),
}
if track:
response_data["track"] = build_track_response(track)
return LikedTrackResponse(**response_data)
# Alias endpoint for frontend compatibility (track_id in URL path)
@router.post("/liked-tracks/{track_id}", response_model=LikedTrackResponse, status_code=status.HTTP_201_CREATED)
async def like_track_alias(
track_id: str,
current_user: CurrentUser,
db: DBSession,
):
"""
Add a track to user's liked tracks (alias for frontend compatibility).
- **track_id**: Track UUID in URL path
"""
from uuid import UUID
# Create the request data from the URL parameter
like_data = LikedTrackCreate(track_id=UUID(track_id), notes=None)
return await like_track(like_data, current_user, db)
@router.delete("/liked/{track_id}", status_code=status.HTTP_204_NO_CONTENT)
async def unlike_track(
track_id: str,
current_user: CurrentUser,
db: DBSession,
):
"""
Remove a track from user's liked tracks.
- **track_id**: Track UUID
"""
from uuid import UUID
library_service = LibraryService(db)
try:
await library_service.unlike_track(
user_id=current_user.id,
track_id=UUID(track_id),
)
except ValueError as e:
if "not" in str(e).lower():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except Exception:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid track ID",
)
# Alias endpoint for frontend compatibility
@router.delete("/liked-tracks/{track_id}", status_code=status.HTTP_204_NO_CONTENT)
async def unlike_track_alias(
track_id: str,
current_user: CurrentUser,
db: DBSession,
):
"""
Remove a track from user's liked tracks (alias for frontend compatibility).
- **track_id**: Track UUID
"""
return await unlike_track(track_id, current_user, db)
@router.get("/liked", response_model=List[LikedTrackResponse])
async def get_liked_tracks(
current_user: CurrentUser,
db: DBSession,
limit: int = Query(50, ge=1, le=100, description="Maximum results"),
offset: int = Query(0, ge=0, description="Pagination offset"),
):
"""
Get user's liked tracks.
- **limit**: Maximum results (1-100, default: 50)
- **offset**: Pagination offset (default: 0)
"""
library_service = LibraryService(db)
liked_tracks = await library_service.get_liked_tracks(
user_id=current_user.id,
limit=limit,
offset=offset,
)
responses = []
for liked_track in liked_tracks:
# Build response manually to avoid SQLAlchemy object validation issues
response_data = {
"id": str(liked_track.id),
"user_id": str(liked_track.user_id),
"track_id": str(liked_track.track_id),
"notes": liked_track.notes,
"created_at": liked_track.created_at.isoformat(),
"updated_at": liked_track.updated_at.isoformat(),
}
# Add track info if available
if liked_track.track:
response_data["track"] = build_track_response(liked_track.track)
responses.append(LikedTrackResponse(**response_data))
return responses
# Alias endpoint for frontend compatibility
@router.get("/liked-tracks", response_model=List[LikedTrackResponse])
async def get_liked_tracks_alias(
current_user: CurrentUser,
db: DBSession,
limit: int = Query(50, ge=1, le=100, description="Maximum results"),
offset: int = Query(0, ge=0, description="Pagination offset"),
):
"""
Get user's liked tracks (alias for frontend compatibility).
- **limit**: Maximum results (1-100, default: 50)
- **offset**: Pagination offset (default: 0)
"""
return await get_liked_tracks(current_user, db, limit, offset)
@router.get("/liked/check/{track_id}", response_model=LikedTrackCheckResponse)
async def check_track_liked(
track_id: str,
current_user: CurrentUser,
db: DBSession,
):
"""
Check if a track is in user's liked tracks.
- **track_id**: Track UUID
"""
from uuid import UUID
library_service = LibraryService(db)
try:
is_liked = await library_service.check_track_liked(
user_id=current_user.id,
track_id=UUID(track_id),
)
except Exception:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid track ID",
)
return LikedTrackCheckResponse(is_liked=is_liked)
@router.put("/liked/{track_id}/notes", response_model=LikedTrackResponse)
async def update_liked_track_notes(
track_id: str,
notes_data: LikedTrackUpdate,
current_user: CurrentUser,
db: DBSession,
):
"""
Update notes for a liked track.
- **track_id**: Track UUID
- **notes**: New notes (max 1000 characters)
"""
from uuid import UUID
library_service = LibraryService(db)
try:
liked_track = await library_service.update_liked_track_notes(
user_id=current_user.id,
track_id=UUID(track_id),
notes=notes_data.notes,
)
except ValueError as e:
if "not" in str(e).lower():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except Exception:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid track ID",
)
# Load track details
from sqlalchemy import select
track_stmt = select(Track).where(Track.id == liked_track.track_id)
track_result = await db.execute(track_stmt)
track = track_result.scalar_one_or_none()
# Build response manually to avoid SQLAlchemy object validation issues
response_data = {
"id": str(liked_track.id),
"user_id": str(liked_track.user_id),
"track_id": str(liked_track.track_id),
"notes": liked_track.notes,
"created_at": liked_track.created_at.isoformat(),
"updated_at": liked_track.updated_at.isoformat(),
}
if track:
response_data["track"] = build_track_response(track)
return LikedTrackResponse(**response_data)
# ============ LIBRARY STATS ENDPOINTS ============
@router.get("/stats", response_model=LibraryStatsResponse)
async def get_library_stats(
current_user: CurrentUser,
db: DBSession,
):
"""
Get user's library statistics.
Returns statistics about listening history and liked tracks.
"""
library_service = LibraryService(db)
stats = await library_service.get_library_stats(user_id=current_user.id)
return LibraryStatsResponse(**stats)
+68 -32
View File
@@ -1,10 +1,13 @@
"""Music API routes."""
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, status, Request
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
from app.api.dependencies import CurrentUser, CurrentUserOptional, DBSession
from app.schemas.music import (
AlbumResponse,
@@ -47,13 +50,15 @@ async def search_music(
# Convert results without strict validation
tracks = []
for t in results.get("tracks", []):
# Use youtube_id as the id for YouTube-only results
track_id = t.get("id") or t.get("youtube_id")
track_data = {
"title": t.get("title", "Unknown"),
"youtube_id": t.get("youtube_id", ""),
"duration": t.get("duration"),
"image_url": t.get("thumbnail"),
"artist_name": t.get("artist", "Unknown Artist"),
"id": None,
"id": track_id,
}
tracks.append(track_data)
@@ -96,44 +101,87 @@ async def get_track(
@router.get("/youtube/{youtube_id}/stream")
@router.head("/youtube/{youtube_id}/stream")
async def stream_youtube_track(
async def stream_youtube_audio(
youtube_id: str,
db: DBSession,
request: Request = None,
):
"""
Stream a track directly from YouTube by youtube_id.
Stream audio from a YouTube video.
This endpoint bypasses the database and streams directly from YouTube.
Downloads the audio as MP3 and streams it to the client.
Supports HTTP Range requests for proper audio playback.
"""
music_service = MusicService(db)
try:
# Get YouTube stream URL
stream_url = await music_service.get_stream_url_by_youtube_id(youtube_id)
# Download audio as MP3
from pathlib import Path
if not stream_url:
audio_path = await music_service.youtube.download_audio(youtube_id)
if not audio_path or not audio_path.exists():
raise HTTPException(
status_code=404,
detail=f"Could not get stream for youtube_id: {youtube_id}"
detail=f"Could not download audio for youtube_id: {youtube_id}"
)
# Get range header from request
# Get file info
file_size = audio_path.stat().st_size
# Handle Range request
range_header = request.headers.get("range") if request else None
# Stream directly from YouTube
from fastapi.responses import StreamingResponse
if range_header:
# Parse Range header (format: "bytes=start-end")
try:
range_match = range_header.replace("bytes=", "").strip()
range_parts = range_match.split("-")
start = int(range_parts[0]) if range_parts[0] else 0
end = int(range_parts[1]) if len(range_parts) > 1 and range_parts[1] else file_size - 1
return await music_service.stream_audio_from_youtube(stream_url, range_header)
# Read the specific range
with open(audio_path, "rb") as f:
f.seek(start)
chunk_size = end - start + 1
data = f.read(chunk_size)
from fastapi.responses import Response
return Response(
content=data,
status_code=206, # Partial Content
media_type="audio/mpeg",
headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(chunk_size),
"Content-Disposition": f"inline; filename={youtube_id}.mp3",
}
)
except Exception as e:
logger.error(f"Error handling range request: {e}")
# Fall through to full file response
# Full file response
from fastapi.responses import FileResponse
return FileResponse(
audio_path,
media_type="audio/mpeg",
filename=f"{youtube_id}.mp3",
headers={
"Accept-Ranges": "bytes",
"Content-Length": str(file_size),
}
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to stream from YouTube: {str(e)}"
detail=f"Failed to stream audio: {str(e)}"
)
@@ -267,29 +315,17 @@ async def get_track_recommendations(
async def get_trending(
db: DBSession,
limit: int = Query(20, ge=1, le=50),
days: int = Query(7, ge=1, le=30, description="Number of days to look back"),
):
"""
Get trending tracks.
Get trending tracks based on play count and recent listens.
Currently returns placeholder data.
In production, this would use actual trending data.
Returns the most played tracks from the database, sorted by popularity.
Combines total play count with recent activity to determine trending tracks.
"""
music_service = MusicService(db)
# Search for popular music on YouTube
results = await music_service.search("music 2024", search_type="track", limit=limit)
# Convert YouTube results to TrackSearchResult with only available fields
tracks = []
for t in results.get("tracks", []):
track_data = {
"title": t.get("title", "Unknown"),
"youtube_id": t.get("youtube_id", ""),
"duration": t.get("duration"),
"image_url": t.get("thumbnail"),
"artist_name": t.get("artist", "Unknown Artist"),
"id": None,
}
tracks.append(track_data)
# Get trending tracks from database
tracks = await music_service.get_trending(limit=limit, days=days)
return tracks