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:
@@ -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"}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user