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
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Rate limiter configuration."""
|
||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||
from slowapi.util import get_remote_address
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
# Create limiter instance
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
# Custom rate limit exceeded handler
|
||||
def rate_limit_exceeded_handler(request: Request, exception):
|
||||
"""Custom handler for rate limit exceeded."""
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={"detail": "Too many requests. Please try again later."},
|
||||
)
|
||||
|
||||
# Replace the default handler
|
||||
limiter._rate_limit_exceeded_handler = rate_limit_exceeded_handler
|
||||
|
||||
# Rate limit rules
|
||||
# Example: 100 requests per minute for general endpoints
|
||||
# 10 requests per minute for authentication endpoints
|
||||
# 5 requests per second for expensive operations
|
||||
+17
-8
@@ -1,4 +1,5 @@
|
||||
"""Main FastAPI application entry point."""
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
@@ -7,9 +8,13 @@ from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import close_db, init_db
|
||||
from app.core.rate_limiter import limiter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Get the base directory
|
||||
@@ -24,22 +29,22 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
Handles startup and shutdown events.
|
||||
"""
|
||||
# Startup
|
||||
print("Starting up...")
|
||||
logger.info("Starting up...")
|
||||
if settings.DEBUG:
|
||||
print("Debug mode is ON")
|
||||
print(f"Database URL: {settings.DATABASE_URL}")
|
||||
print(f"Redis URL: {settings.FULL_REDIS_URL}")
|
||||
logger.debug("Debug mode is ON")
|
||||
logger.debug(f"Database URL: {settings.DATABASE_URL}")
|
||||
logger.debug(f"Redis URL: {settings.FULL_REDIS_URL}")
|
||||
|
||||
# Initialize database
|
||||
await init_db()
|
||||
print("Database initialized")
|
||||
logger.info("Database initialized")
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
print("Shutting down...")
|
||||
logger.info("Shutting down...")
|
||||
await close_db()
|
||||
print("Database connections closed")
|
||||
logger.info("Database connections closed")
|
||||
|
||||
|
||||
# Create FastAPI application
|
||||
@@ -53,6 +58,9 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Set up rate limiting
|
||||
app.state.limiter = limiter
|
||||
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
@@ -109,11 +117,12 @@ async def global_exception_handler(request, exc) -> JSONResponse:
|
||||
|
||||
|
||||
# API routes
|
||||
from app.api.v1 import auth, music, playlists
|
||||
from app.api.v1 import auth, music, playlists, library
|
||||
|
||||
app.include_router(auth.router, prefix=settings.API_V1_PREFIX, tags=["authentication"])
|
||||
app.include_router(music.router, prefix=settings.API_V1_PREFIX, tags=["music"])
|
||||
app.include_router(playlists.router, prefix=settings.API_V1_PREFIX, tags=["playlists"])
|
||||
app.include_router(library.router, prefix=settings.API_V1_PREFIX, tags=["library"])
|
||||
|
||||
# Mount static files
|
||||
static_dir = BASE_DIR / "app" / "static"
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
"""SQLAlchemy models."""
|
||||
from app.core.database import Base
|
||||
|
||||
from app.models.album import Album
|
||||
from app.models.artist import Artist
|
||||
from app.models.liked_track import LikedTrack
|
||||
from app.models.listening_history import ListeningHistory
|
||||
from app.models.playlist import Playlist
|
||||
from app.models.playlist_track import PlaylistTrack
|
||||
from app.models.track import Track
|
||||
from app.models.user import User
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"Album",
|
||||
"Artist",
|
||||
"LikedTrack",
|
||||
"ListeningHistory",
|
||||
"Playlist",
|
||||
"PlaylistTrack",
|
||||
"Track",
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Liked Track model."""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String, ForeignKey, Index
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
from app.models.track import Track
|
||||
|
||||
|
||||
class LikedTrack(Base):
|
||||
"""Liked Track model representing user's liked/favorited tracks."""
|
||||
|
||||
__tablename__ = "liked_tracks"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Foreign keys
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
track_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tracks.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Additional metadata
|
||||
notes: Mapped[str | None] = mapped_column(
|
||||
String(1000),
|
||||
nullable=True,
|
||||
comment="User notes about the track",
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
default=datetime.utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship(
|
||||
"User",
|
||||
back_populates="liked_tracks",
|
||||
lazy="selectin",
|
||||
)
|
||||
track: Mapped["Track"] = relationship(
|
||||
"Track",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
# Table indices for optimal queries and uniqueness constraint
|
||||
__table_args__ = (
|
||||
Index("ix_liked_tracks_user_track", "user_id", "track_id", unique=True),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<LikedTrack user={self.user_id} track={self.track_id}>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert liked track model to dictionary."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"user_id": str(self.user_id),
|
||||
"track_id": str(self.track_id),
|
||||
"notes": self.notes,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Listening History model."""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Integer, String, Boolean, ForeignKey, Index
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
from app.models.track import Track
|
||||
|
||||
|
||||
class ListeningHistory(Base):
|
||||
"""Listening History model representing user's track listening history."""
|
||||
|
||||
__tablename__ = "listening_history"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Foreign keys
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
track_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tracks.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Playback details
|
||||
played_for: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=0,
|
||||
comment="Duration played in seconds",
|
||||
)
|
||||
completed: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=False,
|
||||
comment="Whether the track was played to completion",
|
||||
)
|
||||
|
||||
# Source information
|
||||
source: Mapped[str | None] = mapped_column(
|
||||
String(50),
|
||||
comment="Playback source (library, playlist, search, etc.)",
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
played_at: Mapped[datetime] = mapped_column(
|
||||
default=datetime.utcnow,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
default=datetime.utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship(
|
||||
"User",
|
||||
back_populates="listening_history",
|
||||
lazy="selectin",
|
||||
)
|
||||
track: Mapped["Track"] = relationship(
|
||||
"Track",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
# Table indices for optimal queries
|
||||
__table_args__ = (
|
||||
Index("ix_listening_history_user_played", "user_id", "played_at"),
|
||||
Index("ix_listening_history_user_track", "user_id", "track_id"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ListeningHistory user={self.user_id} track={self.track_id} at={self.played_at}>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert listening history model to dictionary."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"user_id": str(self.user_id),
|
||||
"track_id": str(self.track_id),
|
||||
"played_for": self.played_for,
|
||||
"completed": bool(self.completed),
|
||||
"source": self.source,
|
||||
"played_at": self.played_at.isoformat(),
|
||||
"created_at": self.created_at.isoformat(),
|
||||
}
|
||||
@@ -12,6 +12,8 @@ from app.core.database import Base
|
||||
if TYPE_CHECKING:
|
||||
from app.models.playlist import Playlist
|
||||
from app.models.playlist_track import PlaylistTrack
|
||||
from app.models.listening_history import ListeningHistory
|
||||
from app.models.liked_track import LikedTrack
|
||||
|
||||
|
||||
class User(Base):
|
||||
@@ -100,6 +102,20 @@ class User(Base):
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
listening_history: Mapped[list["ListeningHistory"]] = relationship(
|
||||
"ListeningHistory",
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
liked_tracks: Mapped[list["LikedTrack"]] = relationship(
|
||||
"LikedTrack",
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User {self.username} ({self.email})>"
|
||||
|
||||
|
||||
@@ -76,3 +76,10 @@ class RefreshTokenRequest(BaseModel):
|
||||
"""Schema for token refresh request."""
|
||||
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
"""Schema for password change request."""
|
||||
|
||||
old_password: str = Field(..., min_length=8, max_length=100)
|
||||
new_password: str = Field(..., min_length=8, max_length=100)
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Library schemas."""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
|
||||
# ============ LISTENING HISTORY SCHEMAS ============
|
||||
|
||||
class ListeningHistoryBase(BaseModel):
|
||||
"""Base listening history schema."""
|
||||
|
||||
played_for: int = Field(..., ge=0, description="Duration played in seconds")
|
||||
completed: bool = False
|
||||
source: Optional[str] = Field(None, max_length=50, description="Playback source")
|
||||
|
||||
|
||||
class ListeningHistoryCreate(ListeningHistoryBase):
|
||||
"""Schema for creating a listening history entry."""
|
||||
|
||||
track_id: UUID
|
||||
|
||||
|
||||
class ListeningHistoryResponse(BaseModel):
|
||||
"""Schema for listening history response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
user_id: UUID
|
||||
track_id: UUID
|
||||
played_for: int
|
||||
completed: bool
|
||||
source: Optional[str]
|
||||
played_at: datetime
|
||||
created_at: datetime
|
||||
|
||||
# Embedded track information
|
||||
track: Optional[dict] = None
|
||||
|
||||
|
||||
class ListeningHistoryStats(BaseModel):
|
||||
"""Schema for listening history statistics."""
|
||||
|
||||
total_plays: int
|
||||
plays_last_30_days: int
|
||||
unique_tracks_played: int
|
||||
|
||||
|
||||
# ============ LIKED TRACKS SCHEMAS ============
|
||||
|
||||
class LikedTrackBase(BaseModel):
|
||||
"""Base liked track schema."""
|
||||
|
||||
notes: Optional[str] = Field(None, max_length=1000, description="User notes about the track")
|
||||
|
||||
|
||||
class LikedTrackCreate(BaseModel):
|
||||
"""Schema for liking a track."""
|
||||
|
||||
track_id: UUID
|
||||
notes: Optional[str] = Field(None, max_length=1000)
|
||||
|
||||
|
||||
class LikedTrackUpdate(BaseModel):
|
||||
"""Schema for updating liked track notes."""
|
||||
|
||||
notes: str = Field(..., max_length=1000)
|
||||
|
||||
|
||||
class LikedTrackResponse(BaseModel):
|
||||
"""Schema for liked track response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
user_id: UUID
|
||||
track_id: UUID
|
||||
notes: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
# Embedded track information
|
||||
track: Optional[dict] = None
|
||||
|
||||
|
||||
class LikedTrackCheckResponse(BaseModel):
|
||||
"""Schema for checking if track is liked."""
|
||||
|
||||
is_liked: bool
|
||||
|
||||
|
||||
# ============ LIBRARY STATS SCHEMAS ============
|
||||
|
||||
class LibraryStatsResponse(BaseModel):
|
||||
"""Schema for library statistics response."""
|
||||
|
||||
liked_tracks_count: int
|
||||
total_plays: int
|
||||
plays_last_30_days: int
|
||||
unique_tracks_played: int
|
||||
|
||||
|
||||
class RecentlyPlayedResponse(BaseModel):
|
||||
"""Schema for recently played tracks."""
|
||||
|
||||
tracks: List[dict]
|
||||
total: int
|
||||
|
||||
|
||||
class MostPlayedTrackResponse(BaseModel):
|
||||
"""Schema for most played track response."""
|
||||
|
||||
track: dict
|
||||
play_count: int
|
||||
|
||||
|
||||
class MostPlayedTracksResponse(BaseModel):
|
||||
"""Schema for most played tracks response."""
|
||||
|
||||
tracks: List[MostPlayedTrackResponse]
|
||||
total: int
|
||||
@@ -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
|
||||
|
||||
+669
-252
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Diagnostic AudiOhm</title>
|
||||
<style>
|
||||
body { font-family: monospace; padding: 20px; background: #1a1a2e; color: #eee; }
|
||||
.test { margin: 10px 0; padding: 10px; border: 1px solid #444; }
|
||||
.pass { background: #1b4332; }
|
||||
.fail { background: #4a1a1a; }
|
||||
button { padding: 10px 20px; margin: 5px; cursor: pointer; }
|
||||
pre { background: #0d0d1a; padding: 10px; overflow-x: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🔧 Diagnostic AudiOhm</h1>
|
||||
|
||||
<div class="test" id="test-api">Test API...</div>
|
||||
<div class="test" id="test-auth">Test Auth...</div>
|
||||
<div class="test" id="test-trending">Test Trending...</div>
|
||||
<div class="test" id="test-stream">Test Stream URL...</div>
|
||||
|
||||
<h2>Actions</h2>
|
||||
<button onclick="testAll()">Exécuter tous les tests</button>
|
||||
<button onclick="testLogin()">Test Login</button>
|
||||
|
||||
<h2>Résultats</h2>
|
||||
<pre id="output">Cliquez sur un bouton pour commencer...</pre>
|
||||
|
||||
<script>
|
||||
let authToken = null;
|
||||
|
||||
function log(msg) {
|
||||
const output = document.getElementById('output');
|
||||
output.textContent += msg + '\n';
|
||||
}
|
||||
|
||||
function updateStatus(id, passed, msg) {
|
||||
const el = document.getElementById(id);
|
||||
el.className = 'test ' + (passed ? 'pass' : 'fail');
|
||||
el.textContent = msg;
|
||||
}
|
||||
|
||||
async function testAPI() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/music/trending?limit=1');
|
||||
const data = await response.json();
|
||||
updateStatus('test-api', response.ok, `API: ${response.status} - ${response.statusText}`);
|
||||
log('✅ API accessible');
|
||||
log('Données: ' + JSON.stringify(data[0], null, 2).substring(0, 200) + '...');
|
||||
} catch (error) {
|
||||
updateStatus('test-api', false, 'API: Error - ' + error.message);
|
||||
log('❌ API error: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function testLogin() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: 'admin@example.com',
|
||||
password: 'admin123'
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
if (response.ok && data.access_token) {
|
||||
authToken = data.access_token;
|
||||
updateStatus('test-auth', true, 'Auth: ✅ Connecté');
|
||||
log('✅ Login réussi');
|
||||
log('Token: ' + authToken.substring(0, 20) + '...');
|
||||
} else {
|
||||
updateStatus('test-auth', false, 'Auth: ❌ ' + JSON.stringify(data));
|
||||
log('❌ Login failed: ' + JSON.stringify(data));
|
||||
}
|
||||
} catch (error) {
|
||||
updateStatus('test-auth', false, 'Auth: Error - ' + error.message);
|
||||
log('❌ Auth error: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function testTrending() {
|
||||
if (!authToken) {
|
||||
await testLogin();
|
||||
}
|
||||
if (!authToken) {
|
||||
updateStatus('test-trending', false, 'Trending: Pas de token');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/music/trending?limit=2', {
|
||||
headers: { 'Authorization': 'Bearer ' + authToken }
|
||||
});
|
||||
const data = await response.json();
|
||||
updateStatus('test-trending', response.ok, `Trending: ${response.status} - ${data.length} pistes`);
|
||||
log('✅ Trending: ' + data.length + ' pistes trouvées');
|
||||
log('Piste 1: ' + data[0].title);
|
||||
} catch (error) {
|
||||
updateStatus('test-trending', false, 'Trending: Error - ' + error.message);
|
||||
log('❌ Trending error: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function testStream() {
|
||||
const youtubeId = 'NqDGkdDh8WE';
|
||||
try {
|
||||
const response = await fetch(`/api/v1/music/youtube/${youtubeId}/stream`);
|
||||
const data = await response.json();
|
||||
if (response.ok && data.stream_url) {
|
||||
updateStatus('test-stream', true, 'Stream: ✅ URL obtenue');
|
||||
log('✅ Stream URL obtenue');
|
||||
log('URL: ' + data.stream_url.substring(0, 100) + '...');
|
||||
} else {
|
||||
updateStatus('test-stream', false, 'Stream: ❌ ' + JSON.stringify(data));
|
||||
log('❌ Stream failed: ' + JSON.stringify(data));
|
||||
}
|
||||
} catch (error) {
|
||||
updateStatus('test-stream', false, 'Stream: Error - ' + error.message);
|
||||
log('❌ Stream error: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function testAll() {
|
||||
document.getElementById('output').textContent = '=== Tests en cours ===\n';
|
||||
await testAPI();
|
||||
await testLogin();
|
||||
await testTrending();
|
||||
await testStream();
|
||||
log('\n=== Tests terminés ===');
|
||||
}
|
||||
|
||||
// Auto-run on load
|
||||
window.onload = function() {
|
||||
log('Page chargée - Prêt à tester');
|
||||
log('Date: ' + new Date().toISOString());
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+3228
-147
File diff suppressed because it is too large
Load Diff
+3781
-382
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Test AudiOhm</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Test API</h1>
|
||||
<button onclick="testTrending()">Test Trending</button>
|
||||
<button onclick="testStream()">Test Stream</button>
|
||||
<pre id="output"></pre>
|
||||
|
||||
<script>
|
||||
async function testTrending() {
|
||||
const output = document.getElementById('output');
|
||||
output.textContent = 'Testing trending...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/music/trending?limit=1');
|
||||
const data = await response.json();
|
||||
output.textContent = 'Trending Response:\n' + JSON.stringify(data, null, 2);
|
||||
} catch (error) {
|
||||
output.textContent = 'Error: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function testStream() {
|
||||
const output = document.getElementById('output');
|
||||
output.textContent = 'Testing stream...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/music/youtube/NqDGkdDh8WE/stream');
|
||||
const data = await response.json();
|
||||
output.textContent = 'Stream Response:\n' + JSON.stringify(data, null, 2);
|
||||
} catch (error) {
|
||||
output.textContent = 'Error: ' + error.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Test Functions</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Test des fonctions JavaScript</h1>
|
||||
<div id="results"></div>
|
||||
|
||||
<script src="js/app.js"></script>
|
||||
<script>
|
||||
const results = document.getElementById('results');
|
||||
|
||||
function testFunction(name, exists) {
|
||||
const div = document.createElement('div');
|
||||
div.style.color = exists ? 'green' : 'red';
|
||||
div.textContent = (exists ? '✅' : '❌') + ' ' + name;
|
||||
results.appendChild(div);
|
||||
}
|
||||
|
||||
// Tester les fonctions critiques
|
||||
testFunction('switchLibraryTab', typeof window.switchLibraryTab === 'function');
|
||||
testFunction('loadUserData', typeof window.loadUserData === 'function');
|
||||
testFunction('playPrevious', typeof window.playPrevious === 'function');
|
||||
testFunction('playNext', typeof window.playNext === 'function');
|
||||
testFunction('togglePlayPause', typeof window.togglePlayPause === 'function');
|
||||
testFunction('toggleShuffle', typeof window.toggleShuffle === 'function');
|
||||
testFunction('toggleRepeat', typeof window.toggleRepeat === 'function');
|
||||
testFunction('toggleMute', typeof window.toggleMute === 'function');
|
||||
testFunction('handleSeek', typeof window.handleSeek === 'function');
|
||||
testFunction('handleVolumeChange', typeof window.handleVolumeChange === 'function');
|
||||
testFunction('updateProgress', typeof window.updateProgress === 'function');
|
||||
testFunction('updateDuration', typeof window.updateDuration === 'function');
|
||||
testFunction('handleTrackEnd', typeof window.handleTrackEnd === 'function');
|
||||
testFunction('toggleLike', typeof window.toggleLike === 'function');
|
||||
testFunction('loadPlaylists', typeof window.loadPlaylists === 'function');
|
||||
testFunction('loadLikedTracks', typeof window.loadLikedTracks === 'function');
|
||||
testFunction('loadListeningHistory', typeof window.loadListeningHistory === 'function');
|
||||
testFunction('playTrack', typeof window.playTrack === 'function');
|
||||
testFunction('createPlaylist', typeof window.createPlaylist === 'function');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,99 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Test AudiOhm API</title>
|
||||
<style>
|
||||
body { font-family: Arial; padding: 20px; background: #1a1a1a; color: #fff; }
|
||||
.test { margin: 20px 0; padding: 15px; background: #2a2a2a; border-radius: 8px; }
|
||||
.pass { color: #4ade80; }
|
||||
.fail { color: #f87171; }
|
||||
pre { background: #1a1a1a; padding: 10px; overflow-x: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🧪 Test API AudiOhm</h1>
|
||||
<div id="results"></div>
|
||||
|
||||
<script>
|
||||
const results = document.getElementById('results');
|
||||
|
||||
async function testAPI() {
|
||||
let token = localStorage.getItem('token');
|
||||
|
||||
if (!token) {
|
||||
// Login first
|
||||
addTest('POST /api/v1/auth/login', async () => {
|
||||
const response = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: 'admin@example.com',
|
||||
password: 'admin123'
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.access_token) {
|
||||
localStorage.setItem('token', data.access_token);
|
||||
token = data.access_token;
|
||||
return { status: '✅', token: token.substring(0, 20) + '...' };
|
||||
}
|
||||
throw new Error('No token');
|
||||
});
|
||||
}
|
||||
|
||||
// Test Playlists
|
||||
await addTest('GET /api/v1/playlists', async () => {
|
||||
const response = await fetch('/api/v1/playlists', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const data = await response.json();
|
||||
return { status: response.ok ? '✅' : '❌', count: data.length, data: data };
|
||||
});
|
||||
|
||||
// Test Trending
|
||||
await addTest('GET /api/v1/music/trending', async () => {
|
||||
const response = await fetch('/api/v1/music/trending', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const data = await response.json();
|
||||
return { status: response.ok ? '✅' : '❌', count: data.length };
|
||||
});
|
||||
|
||||
// Test Liked Tracks
|
||||
await addTest('GET /api/v1/library/liked-tracks', async () => {
|
||||
const response = await fetch('/api/v1/library/liked-tracks', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.detail) throw new Error(data.detail);
|
||||
return { status: response.ok ? '✅' : '❌', count: data.length };
|
||||
});
|
||||
|
||||
// Test History
|
||||
await addTest('GET /api/v1/library/history', async () => {
|
||||
const response = await fetch('/api/v1/library/history', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.detail) throw new Error(data.detail);
|
||||
return { status: response.ok ? '✅' : '❌', count: data.length };
|
||||
});
|
||||
}
|
||||
|
||||
async function addTest(name, testFn) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'test';
|
||||
results.appendChild(div);
|
||||
|
||||
try {
|
||||
const result = await testFn();
|
||||
div.innerHTML = `<span class="${result.status === '✅' ? 'pass' : 'fail'}">${result.status}</span> <strong>${name}</strong><br><pre>${JSON.stringify(result, null, 2)}</pre>`;
|
||||
} catch (error) {
|
||||
div.innerHTML = `<span class="fail">❌</span> <strong>${name}</strong><br><pre>${error.message}</pre>`;
|
||||
}
|
||||
}
|
||||
|
||||
testAPI();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,244 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AudiOhm - Web Player</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Toast Container -->
|
||||
<div id="toast-container" class="toast-container"></div>
|
||||
|
||||
<!-- App Container -->
|
||||
<div id="app">
|
||||
<!-- Loading Screen -->
|
||||
<div id="loading-screen" class="loading-screen">
|
||||
<div class="spinner"></div>
|
||||
<h2>Chargement de AudiOhm...</h2>
|
||||
</div>
|
||||
|
||||
<!-- Login Screen -->
|
||||
<div id="login-screen" class="screen hidden">
|
||||
<div class="login-container">
|
||||
<h1 class="logo">
|
||||
<i class="fas fa-headphones"></i> AudiOhm
|
||||
</h1>
|
||||
<form id="login-form" class="login-form">
|
||||
<div class="form-group">
|
||||
<input type="email" id="login-email" placeholder="Email" required autocomplete="email">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="password" id="login-password" placeholder="Mot de passe" required autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-sign-in-alt"></i> Se connecter
|
||||
</button>
|
||||
<p class="register-link">
|
||||
Pas encore de compte ? <a href="#" id="show-register">Créer un compte</a>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<form id="register-form" class="login-form hidden">
|
||||
<div class="form-group">
|
||||
<input type="text" id="register-username" placeholder="Nom d'utilisateur" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="email" id="register-email" placeholder="Email" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="password" id="register-password" placeholder="Mot de passe" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-user-plus"></i> Créer un compte
|
||||
</button>
|
||||
<p class="register-link">
|
||||
Déjà un compte ? <a href="#" id="show-login">Se connecter</a>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<div id="auth-error" class="error-message hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main App -->
|
||||
<div id="main-app" class="screen hidden">
|
||||
<!-- Mobile Menu Button -->
|
||||
<button class="mobile-menu-btn" id="mobile-menu-btn">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h1 class="logo">
|
||||
<i class="fas fa-headphones"></i> AudiOhm
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<a href="#" class="nav-item active" data-page="home">
|
||||
<i class="fas fa-home"></i> Accueil
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-page="search">
|
||||
<i class="fas fa-search"></i> Rechercher
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-page="library">
|
||||
<i class="fas fa-music"></i> Bibliothèque
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<button id="logout-btn" class="btn btn-secondary">
|
||||
<i class="fas fa-sign-out-alt"></i> Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<!-- Home Page -->
|
||||
<div id="home-page" class="page active">
|
||||
<div class="page-header">
|
||||
<h1>Bienvenue sur AudiOhm 🎵</h1>
|
||||
<p>Votre alternative à Spotify avec streaming YouTube</p>
|
||||
</div>
|
||||
|
||||
<section class="section">
|
||||
<h2><i class="fas fa-bolt"></i> Recherche rapide</h2>
|
||||
<div class="search-bar">
|
||||
<input type="text" id="quick-search" placeholder="Rechercher une musique, un artiste...">
|
||||
<button class="btn btn-primary" id="quick-search-btn">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2><i class="fas fa-fire"></i> Musiques tendance</h2>
|
||||
<div class="track-list" id="trending-tracks">
|
||||
<div class="loading">
|
||||
<div class="spinner" style="width: 40px; height: 40px; margin: 0 auto 1rem;"></div>
|
||||
Chargement...
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2><i class="fas fa-clock"></i> Récemment écoutées</h2>
|
||||
<div class="track-list" id="recent-tracks">
|
||||
<p style="color: var(--text-secondary);">Aucune écoute récente</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Search Page -->
|
||||
<div id="search-page" class="page">
|
||||
<div class="page-header">
|
||||
<h1><i class="fas fa-search"></i> Recherche</h1>
|
||||
</div>
|
||||
|
||||
<div class="search-bar">
|
||||
<input type="text" id="search-input" placeholder="Que voulez-vous écouter ?">
|
||||
<button class="btn btn-primary" id="search-btn">
|
||||
<i class="fas fa-search"></i> Rechercher
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="search-results" class="search-results"></div>
|
||||
</div>
|
||||
|
||||
<!-- Library Page -->
|
||||
<div id="library-page" class="page">
|
||||
<div class="page-header">
|
||||
<h1><i class="fas fa-music"></i> Ma Bibliothèque</h1>
|
||||
</div>
|
||||
|
||||
<section class="section">
|
||||
<h2><i class="fas fa-list"></i> Mes Playlists</h2>
|
||||
<div class="playlist-list" id="my-playlists">
|
||||
<div class="loading">
|
||||
<div class="spinner" style="width: 40px; height: 40px; margin: 0 auto 1rem;"></div>
|
||||
Chargement...
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2><i class="fas fa-heart"></i> Titres likés</h2>
|
||||
<div class="track-list" id="liked-tracks">
|
||||
<p style="color: var(--text-secondary);">Aucun titre liké pour le moment</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Player -->
|
||||
<div id="player" class="player">
|
||||
<div class="player-info">
|
||||
<img id="player-cover" src="/static/img/default-cover.png" alt="Cover" class="player-cover">
|
||||
<div class="player-details">
|
||||
<div id="player-title" class="player-title">Aucun titre</div>
|
||||
<div id="player-artist" class="player-artist">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="player-controls">
|
||||
<button class="btn-control" id="shuffle-btn" title="Aléatoire">
|
||||
<i class="fas fa-random"></i>
|
||||
</button>
|
||||
<button class="btn-control" id="prev-btn" title="Précédent">
|
||||
<i class="fas fa-step-backward"></i>
|
||||
</button>
|
||||
<button class="btn-control btn-play" id="play-btn" title="Play/Pause">
|
||||
<i class="fas fa-play"></i>
|
||||
</button>
|
||||
<button class="btn-control" id="next-btn" title="Suivant">
|
||||
<i class="fas fa-step-forward"></i>
|
||||
</button>
|
||||
<button class="btn-control" id="repeat-btn" title="Répéter">
|
||||
<i class="fas fa-redo"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="player-progress">
|
||||
<span id="current-time" class="time">0:00</span>
|
||||
<input type="range" id="progress-bar" min="0" max="100" value="0" class="progress-bar">
|
||||
<span id="total-time" class="time">0:00</span>
|
||||
</div>
|
||||
|
||||
<div class="player-volume">
|
||||
<button class="btn-control" id="mute-btn" title="Muet">
|
||||
<i class="fas fa-volume-up"></i>
|
||||
</button>
|
||||
<input type="range" id="volume-bar" min="0" max="100" value="100" class="volume-bar">
|
||||
</div>
|
||||
|
||||
<div class="player-actions">
|
||||
<button class="btn-control" id="like-btn" title="J'aime">
|
||||
<i class="far fa-heart"></i>
|
||||
</button>
|
||||
<button class="btn-control" id="playlist-btn" title="Ajouter à la playlist">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<audio id="audio-player" preload="none"></audio>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Fallback: Hide loading screen after 5 seconds if JS fails
|
||||
setTimeout(function() {
|
||||
const loadingScreen = document.getElementById('loading-screen');
|
||||
if (loadingScreen) {
|
||||
console.error('Loading screen timeout - JS may have failed to load');
|
||||
loadingScreen.style.display = 'none';
|
||||
}
|
||||
}, 5000);
|
||||
</script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+695
-157
@@ -1,244 +1,782 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<html lang="fr" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AudiOhm - Web Player</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
200: '#bae6fd',
|
||||
300: '#7dd3fc',
|
||||
400: '#38bdf8',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
800: '#075985',
|
||||
900: '#0c4a6e',
|
||||
},
|
||||
accent: {
|
||||
50: '#fdf2f8',
|
||||
100: '#fce7f3',
|
||||
200: '#fbcfe8',
|
||||
300: '#f9a8d4',
|
||||
400: '#f472b6',
|
||||
500: '#ec4899',
|
||||
600: '#db2777',
|
||||
700: '#be185d',
|
||||
800: '#9d174d',
|
||||
900: '#831843',
|
||||
},
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
/* Custom animations */
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-slideIn {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #4b5563;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
/* Glassmorphism */
|
||||
.glass {
|
||||
background: rgba(17, 24, 39, 0.8);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: rgba(31, 41, 55, 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Range slider styling */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-track {
|
||||
background: #374151;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
margin-top: -4px;
|
||||
background-color: #0ea5e9;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb:hover {
|
||||
background-color: #38bdf8;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
/* Larger slider for desktop */
|
||||
@media (min-width: 640px) {
|
||||
input[type="range"]::-webkit-slider-track {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Screen reader only utility */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
.sr-only.focusable:focus {
|
||||
position: static;
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: inherit;
|
||||
margin: inherit;
|
||||
overflow: visible;
|
||||
clip: auto;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Focus visible styles for keyboard navigation */
|
||||
:focus-visible {
|
||||
outline: 2px solid #0ea5e9;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Better focus styles for interactive elements */
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
input:focus-visible,
|
||||
[tabindex]:focus-visible {
|
||||
outline: 2px solid #0ea5e9;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Library Tabs Styles */
|
||||
.library-tab {
|
||||
background: rgba(31, 41, 55, 0.6);
|
||||
color: #9ca3af;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.library-tab:hover {
|
||||
background: rgba(55, 65, 81, 0.6);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.library-tab.active {
|
||||
background: rgba(14, 165, 233, 0.2);
|
||||
color: #38bdf8;
|
||||
border-color: rgba(56, 189, 248, 0.3);
|
||||
}
|
||||
|
||||
.tab-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-panel.active {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<body class="bg-gradient-to-br from-gray-900 via-slate-900 to-gray-900 min-h-screen text-white font-sans">
|
||||
<!-- Skip Link for Accessibility -->
|
||||
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-primary-600 focus:text-white focus:rounded-lg">
|
||||
Aller au contenu principal
|
||||
</a>
|
||||
|
||||
<!-- Toast Container -->
|
||||
<div id="toast-container" class="toast-container"></div>
|
||||
<div id="toast-container" class="fixed top-4 right-4 z-50 flex flex-col gap-2" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||
|
||||
<!-- App Container -->
|
||||
<div id="app">
|
||||
<div id="app" class="min-h-screen">
|
||||
<!-- Loading Screen -->
|
||||
<div id="loading-screen" class="loading-screen">
|
||||
<div class="spinner"></div>
|
||||
<h2>Chargement de AudiOhm...</h2>
|
||||
<div id="loading-screen" class="fixed inset-0 bg-gray-900 flex flex-col items-center justify-center z-50" role="status" aria-live="polite" aria-busy="true">
|
||||
<div class="relative w-16 h-16 mb-6">
|
||||
<div class="absolute inset-0 border-4 border-primary-500/30 rounded-full"></div>
|
||||
<div class="absolute inset-0 border-4 border-transparent border-t-primary-500 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">
|
||||
Chargement de AudiOhm...
|
||||
</h2>
|
||||
<p class="text-gray-400 mt-2">Préparation de votre expérience musicale</p>
|
||||
</div>
|
||||
|
||||
<!-- Login Screen -->
|
||||
<div id="login-screen" class="screen hidden">
|
||||
<div class="login-container">
|
||||
<h1 class="logo">
|
||||
<i class="fas fa-headphones"></i> AudiOhm
|
||||
</h1>
|
||||
<form id="login-form" class="login-form">
|
||||
<div class="form-group">
|
||||
<input type="email" id="login-email" placeholder="Email" required autocomplete="email">
|
||||
<div id="login-screen" class="hidden fixed inset-0 bg-gradient-to-br from-gray-900 via-slate-900 to-gray-900 flex items-center justify-center p-4" role="dialog" aria-modal="true" aria-labelledby="login-title">
|
||||
<div class="glass-card rounded-2xl p-8 w-full max-w-md animate-fadeIn">
|
||||
<!-- Logo -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-gradient-to-br from-primary-500 to-accent-500 mb-4 shadow-lg shadow-primary-500/25" aria-hidden="true">
|
||||
<i class="fas fa-headphones text-4xl text-white"></i>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="password" id="login-password" placeholder="Mot de passe" required autocomplete="current-password">
|
||||
<h1 id="login-title" class="text-3xl font-bold bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">
|
||||
AudiOhm
|
||||
</h1>
|
||||
<p class="text-gray-400 mt-2">Votre musique, illimitée</p>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form id="login-form" class="space-y-4" aria-label="Formulaire de connexion">
|
||||
<div>
|
||||
<label for="login-email" class="block text-sm font-medium text-gray-300 mb-2">Email</label>
|
||||
<div class="relative">
|
||||
<i class="fas fa-envelope absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" aria-hidden="true"></i>
|
||||
<input type="email" id="login-email" required
|
||||
class="w-full pl-10 pr-4 py-3 bg-gray-800/50 border border-gray-700 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-transparent focus:outline-none transition-all"
|
||||
placeholder="vous@example.com" autocomplete="email" aria-describedby="login-email-hint">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-sign-in-alt"></i> Se connecter
|
||||
|
||||
<div>
|
||||
<label for="login-password" class="block text-sm font-medium text-gray-300 mb-2">Mot de passe</label>
|
||||
<div class="relative">
|
||||
<i class="fas fa-lock absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" aria-hidden="true"></i>
|
||||
<input type="password" id="login-password" required
|
||||
class="w-full pl-10 pr-4 py-3 bg-gray-800/50 border border-gray-700 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-transparent focus:outline-none transition-all"
|
||||
placeholder="••••••••" autocomplete="current-password">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="w-full py-3 px-4 bg-gradient-to-r from-primary-600 to-primary-500 hover:from-primary-500 hover:to-primary-400 rounded-xl font-semibold transition-all transform hover:scale-[1.02] active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-primary-400 focus:ring-offset-2 focus:ring-offset-gray-900 shadow-lg shadow-primary-500/25">
|
||||
<i class="fas fa-sign-in-alt mr-2" aria-hidden="true"></i>
|
||||
Se connecter
|
||||
</button>
|
||||
<p class="register-link">
|
||||
Pas encore de compte ? <a href="#" id="show-register">Créer un compte</a>
|
||||
</p>
|
||||
|
||||
<div class="text-center">
|
||||
<p class="text-gray-400 text-sm">
|
||||
Pas encore de compte ?
|
||||
<button type="button" id="show-register" class="text-primary-400 hover:text-primary-300 font-medium transition-colors focus:outline-none focus:underline focus:ring-2 focus:ring-primary-400 rounded">
|
||||
Créer un compte
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form id="register-form" class="login-form hidden">
|
||||
<div class="form-group">
|
||||
<input type="text" id="register-username" placeholder="Nom d'utilisateur" required>
|
||||
<!-- Register Form -->
|
||||
<form id="register-form" class="hidden space-y-4" aria-label="Formulaire d'inscription">
|
||||
<div>
|
||||
<label for="register-username" class="block text-sm font-medium text-gray-300 mb-2">Nom d'utilisateur</label>
|
||||
<div class="relative">
|
||||
<i class="fas fa-user absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" aria-hidden="true"></i>
|
||||
<input type="text" id="register-username" required
|
||||
class="w-full pl-10 pr-4 py-3 bg-gray-800/50 border border-gray-700 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-transparent focus:outline-none transition-all"
|
||||
placeholder="votre_pseudo" autocomplete="username">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="email" id="register-email" placeholder="Email" required>
|
||||
|
||||
<div>
|
||||
<label for="register-email" class="block text-sm font-medium text-gray-300 mb-2">Email</label>
|
||||
<div class="relative">
|
||||
<i class="fas fa-envelope absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" aria-hidden="true"></i>
|
||||
<input type="email" id="register-email" required
|
||||
class="w-full pl-10 pr-4 py-3 bg-gray-800/50 border border-gray-700 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-transparent focus:outline-none transition-all"
|
||||
placeholder="vous@example.com" autocomplete="email">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="password" id="register-password" placeholder="Mot de passe" required>
|
||||
|
||||
<div>
|
||||
<label for="register-password" class="block text-sm font-medium text-gray-300 mb-2">Mot de passe</label>
|
||||
<div class="relative">
|
||||
<i class="fas fa-lock absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" aria-hidden="true"></i>
|
||||
<input type="password" id="register-password" required minlength="8"
|
||||
class="w-full pl-10 pr-4 py-3 bg-gray-800/50 border border-gray-700 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-transparent focus:outline-none transition-all"
|
||||
placeholder="Min. 8 caractères" autocomplete="new-password" aria-describedby="password-requirements">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-user-plus"></i> Créer un compte
|
||||
|
||||
<button type="submit"
|
||||
class="w-full py-3 px-4 bg-gradient-to-r from-accent-600 to-accent-500 hover:from-accent-500 hover:to-accent-400 rounded-xl font-semibold transition-all transform hover:scale-[1.02] active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-accent-400 focus:ring-offset-2 focus:ring-offset-gray-900 shadow-lg shadow-accent-500/25">
|
||||
<i class="fas fa-user-plus mr-2" aria-hidden="true"></i>
|
||||
Créer un compte
|
||||
</button>
|
||||
<p class="register-link">
|
||||
Déjà un compte ? <a href="#" id="show-login">Se connecter</a>
|
||||
</p>
|
||||
|
||||
<div class="text-center">
|
||||
<p class="text-gray-400 text-sm">
|
||||
Déjà un compte ?
|
||||
<button type="button" id="show-login" class="text-accent-400 hover:text-accent-300 font-medium transition-colors focus:outline-none focus:underline focus:ring-2 focus:ring-accent-400 rounded">
|
||||
Se connecter
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="auth-error" class="error-message hidden"></div>
|
||||
<div id="auth-error" class="hidden mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main App -->
|
||||
<div id="main-app" class="screen hidden">
|
||||
<div id="main-app" class="hidden">
|
||||
<!-- Mobile Menu Button -->
|
||||
<button class="mobile-menu-btn" id="mobile-menu-btn">
|
||||
<i class="fas fa-bars"></i>
|
||||
<button id="mobile-menu-btn" class="lg:hidden fixed top-4 left-4 z-40 p-3 glass rounded-xl hover:bg-gray-800/50 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all" aria-label="Ouvrir le menu" aria-expanded="false" aria-controls="sidebar">
|
||||
<i class="fas fa-bars text-xl" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h1 class="logo">
|
||||
<i class="fas fa-headphones"></i> AudiOhm
|
||||
</h1>
|
||||
<aside id="sidebar" class="fixed left-0 top-0 h-full w-64 glass border-r border-gray-800 z-30 transform -translate-x-full lg:translate-x-0 transition-transform duration-300" aria-label="Navigation principale">
|
||||
<div class="p-6">
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-accent-500 flex items-center justify-center shadow-lg" aria-hidden="true">
|
||||
<i class="fas fa-headphones text-white"></i>
|
||||
</div>
|
||||
<h1 class="text-xl font-bold">AudiOhm</h1>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="space-y-2" aria-label="Navigation principale">
|
||||
<a href="#" data-page="home" class="nav-item active flex items-center gap-3 px-4 py-3 rounded-xl bg-primary-500/10 text-primary-400 transition-all focus:outline-none focus:ring-2 focus:ring-primary-500" role="button" aria-current="page">
|
||||
<i class="fas fa-home w-5" aria-hidden="true"></i>
|
||||
<span>Accueil</span>
|
||||
</a>
|
||||
<a href="#" data-page="search" class="nav-item flex items-center gap-3 px-4 py-3 rounded-xl text-gray-400 hover:bg-gray-800/50 transition-all focus:outline-none focus:ring-2 focus:ring-primary-500" role="button">
|
||||
<i class="fas fa-search w-5" aria-hidden="true"></i>
|
||||
<span>Rechercher</span>
|
||||
</a>
|
||||
<a href="#" data-page="library" class="nav-item flex items-center gap-3 px-4 py-3 rounded-xl text-gray-400 hover:bg-gray-800/50 transition-all focus:outline-none focus:ring-2 focus:ring-primary-500" role="button">
|
||||
<i class="fas fa-music w-5" aria-hidden="true"></i>
|
||||
<span>Bibliothèque</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<a href="#" class="nav-item active" data-page="home">
|
||||
<i class="fas fa-home"></i> Accueil
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-page="search">
|
||||
<i class="fas fa-search"></i> Rechercher
|
||||
</a>
|
||||
<a href="#" class="nav-item" data-page="library">
|
||||
<i class="fas fa-music"></i> Bibliothèque
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<button id="logout-btn" class="btn btn-secondary">
|
||||
<i class="fas fa-sign-out-alt"></i> Déconnexion
|
||||
<!-- Logout -->
|
||||
<div class="absolute bottom-0 left-0 right-0 p-6 border-t border-gray-800">
|
||||
<button id="logout-btn" class="w-full flex items-center justify-center gap-2 px-4 py-3 text-gray-400 hover:text-white hover:bg-gray-800/50 rounded-xl transition-all focus:outline-none focus:ring-2 focus:ring-accent-500" aria-label="Se déconnecter">
|
||||
<i class="fas fa-sign-out-alt" aria-hidden="true"></i>
|
||||
<span>Déconnexion</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<main id="main-content" class="lg:ml-64 min-h-screen pb-20 sm:pb-32" tabindex="-1">
|
||||
<!-- Home Page -->
|
||||
<div id="home-page" class="page active">
|
||||
<div class="page-header">
|
||||
<h1>Bienvenue sur AudiOhm 🎵</h1>
|
||||
<p>Votre alternative à Spotify avec streaming YouTube</p>
|
||||
<div id="home-page" class="page active p-4 sm:p-6 lg:p-10 pt-16 sm:pt-6 lg:pt-10">
|
||||
<!-- Header -->
|
||||
<div class="mb-6 sm:mb-8">
|
||||
<h1 class="text-2xl sm:text-3xl lg:text-4xl font-bold mb-2">
|
||||
<span class="bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">
|
||||
Bienvenue sur AudiOhm
|
||||
</span>
|
||||
<span class="text-xl sm:text-2xl"> 🎵</span>
|
||||
</h1>
|
||||
<p class="text-sm sm:text-base text-gray-400">Votre alternative à Spotify avec streaming YouTube</p>
|
||||
</div>
|
||||
|
||||
<section class="section">
|
||||
<h2><i class="fas fa-bolt"></i> Recherche rapide</h2>
|
||||
<div class="search-bar">
|
||||
<input type="text" id="quick-search" placeholder="Rechercher une musique, un artiste...">
|
||||
<button class="btn btn-primary" id="quick-search-btn">
|
||||
<i class="fas fa-search"></i>
|
||||
<!-- Quick Search -->
|
||||
<section class="mb-8 sm:mb-10" aria-labelledby="quick-search-heading">
|
||||
<h2 id="quick-search-heading" class="text-lg sm:text-xl font-semibold mb-3 sm:mb-4 flex items-center gap-2">
|
||||
<i class="fas fa-bolt text-primary-400" aria-hidden="true"></i>
|
||||
Recherche rapide
|
||||
</h2>
|
||||
<div class="flex flex-col sm:flex-row gap-2 sm:gap-3">
|
||||
<label for="quick-search" class="sr-only">Rechercher une musique</label>
|
||||
<input type="search" id="quick-search"
|
||||
class="flex-1 px-3 sm:px-4 py-2 sm:py-3 bg-gray-800/50 border border-gray-700 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-transparent focus:outline-none transition-all text-sm"
|
||||
placeholder="Rechercher une musique, un artiste..." aria-describedby="quick-search-hint">
|
||||
<button id="quick-search-btn" class="px-4 sm:px-6 py-2 sm:py-3 bg-primary-600 hover:bg-primary-500 rounded-xl font-medium transition-all focus:outline-none focus:ring-2 focus:ring-primary-400 focus:ring-offset-2 focus:ring-offset-gray-900 min-h-[44px] sm:min-h-[48px]" aria-label="Lancer la recherche">
|
||||
<i class="fas fa-search text-sm sm:text-base" aria-hidden="true"></i>
|
||||
<span class="sr-only">Rechercher</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2><i class="fas fa-fire"></i> Musiques tendance</h2>
|
||||
<div class="track-list" id="trending-tracks">
|
||||
<div class="loading">
|
||||
<div class="spinner" style="width: 40px; height: 40px; margin: 0 auto 1rem;"></div>
|
||||
Chargement...
|
||||
<!-- Trending -->
|
||||
<section class="mb-8 sm:mb-10">
|
||||
<h2 class="text-lg sm:text-xl font-semibold mb-3 sm:mb-4 flex items-center gap-2">
|
||||
<i class="fas fa-fire text-accent-400"></i>
|
||||
Musiques tendance
|
||||
</h2>
|
||||
<div id="trending-tracks" class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3 sm:gap-4">
|
||||
<div class="flex flex-col items-center justify-center py-16 sm:py-20">
|
||||
<div class="w-10 h-10 sm:w-12 sm:h-12 border-4 border-primary-500/30 border-t-primary-500 rounded-full animate-spin mb-3 sm:mb-4"></div>
|
||||
<p class="text-sm sm:text-base text-gray-400">Chargement...</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2><i class="fas fa-clock"></i> Récemment écoutées</h2>
|
||||
<div class="track-list" id="recent-tracks">
|
||||
<p style="color: var(--text-secondary);">Aucune écoute récente</p>
|
||||
<!-- Recent -->
|
||||
<section>
|
||||
<h2 class="text-lg sm:text-xl font-semibold mb-3 sm:mb-4 flex items-center gap-2">
|
||||
<i class="fas fa-clock text-primary-400"></i>
|
||||
Récemment écoutées
|
||||
</h2>
|
||||
<div id="recent-tracks" class="text-sm sm:text-base text-gray-400">
|
||||
<p>Aucune écoute récente</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Search Page -->
|
||||
<div id="search-page" class="page">
|
||||
<div class="page-header">
|
||||
<h1><i class="fas fa-search"></i> Recherche</h1>
|
||||
</div>
|
||||
<div id="search-page" class="page hidden p-4 sm:p-6 lg:p-10 pt-16 sm:pt-6 lg:pt-10">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold mb-4 sm:mb-6 flex items-center gap-2 sm:gap-3">
|
||||
<i class="fas fa-search text-primary-400" aria-hidden="true"></i>
|
||||
Recherche
|
||||
</h1>
|
||||
|
||||
<div class="search-bar">
|
||||
<input type="text" id="search-input" placeholder="Que voulez-vous écouter ?">
|
||||
<button class="btn btn-primary" id="search-btn">
|
||||
<i class="fas fa-search"></i> Rechercher
|
||||
<div class="flex flex-col sm:flex-row gap-2 sm:gap-3 mb-6 sm:mb-8">
|
||||
<label for="search-input" class="sr-only">Rechercher de la musique</label>
|
||||
<input type="search" id="search-input"
|
||||
class="flex-1 px-3 sm:px-4 py-2 sm:py-3 bg-gray-800/50 border border-gray-700 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-transparent focus:outline-none transition-all text-sm"
|
||||
placeholder="Que voulez-vous écouter ?" aria-describedby="search-hint">
|
||||
<button id="search-btn" class="px-4 sm:px-8 py-2 sm:py-3 bg-gradient-to-r from-primary-600 to-primary-500 hover:from-primary-500 hover:to-primary-400 rounded-xl font-semibold transition-all focus:outline-none focus:ring-2 focus:ring-primary-400 focus:ring-offset-2 focus:ring-offset-gray-900 min-h-[44px] sm:min-h-[48px] text-sm sm:text-base">
|
||||
<i class="fas fa-search mr-0 sm:mr-2" aria-hidden="true"></i>
|
||||
<span class="hidden sm:inline">Rechercher</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="search-results" class="search-results"></div>
|
||||
<div id="search-results" role="list" aria-label="Résultats de recherche" class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3 sm:gap-4"></div>
|
||||
</div>
|
||||
|
||||
<!-- Library Page -->
|
||||
<div id="library-page" class="page">
|
||||
<div class="page-header">
|
||||
<h1><i class="fas fa-music"></i> Ma Bibliothèque</h1>
|
||||
<div id="library-page" class="page hidden p-4 sm:p-6 lg:p-10 pt-16 sm:pt-6 lg:pt-10">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold mb-4 sm:mb-6 flex items-center gap-2 sm:gap-3">
|
||||
<i class="fas fa-music text-accent-400"></i>
|
||||
Ma Bibliothèque
|
||||
</h1>
|
||||
|
||||
<!-- Tabs Navigation -->
|
||||
<div class="flex gap-2 mb-6 overflow-x-auto pb-2" role="tablist" aria-label="Onglets de la bibliothèque">
|
||||
<button id="tab-playlists" class="library-tab active px-4 py-2 rounded-lg font-medium transition-all whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
role="tab"
|
||||
aria-selected="true"
|
||||
aria-controls="library-playlists"
|
||||
onclick="switchLibraryTab('playlists')">
|
||||
<i class="fas fa-list mr-2"></i>
|
||||
<span class="hidden sm:inline">Playlists</span>
|
||||
<span class="sm:hidden">Playlists</span>
|
||||
</button>
|
||||
<button id="tab-liked" class="library-tab px-4 py-2 rounded-lg font-medium transition-all whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
aria-controls="library-liked"
|
||||
onclick="switchLibraryTab('liked')">
|
||||
<i class="fas fa-heart mr-2"></i>
|
||||
<span class="hidden sm:inline">Titres likés</span>
|
||||
<span class="sm:hidden">Likés</span>
|
||||
</button>
|
||||
<button id="tab-history" class="library-tab px-4 py-2 rounded-lg font-medium transition-all whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
aria-controls="library-history"
|
||||
onclick="switchLibraryTab('history')">
|
||||
<i class="fas fa-history mr-2"></i>
|
||||
<span class="hidden sm:inline">Historique</span>
|
||||
<span class="sm:hidden">Historique</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section class="section">
|
||||
<h2><i class="fas fa-list"></i> Mes Playlists</h2>
|
||||
<div class="playlist-list" id="my-playlists">
|
||||
<div class="loading">
|
||||
<div class="spinner" style="width: 40px; height: 40px; margin: 0 auto 1rem;"></div>
|
||||
Chargement...
|
||||
</div>
|
||||
<!-- Tab Panels -->
|
||||
<div class="tab-panels">
|
||||
<!-- Playlists Tab -->
|
||||
<div id="library-playlists" class="tab-panel active" role="tabpanel" aria-labelledby="tab-playlists">
|
||||
<section class="mb-8 sm:mb-10">
|
||||
<div class="flex items-center justify-between mb-3 sm:mb-4">
|
||||
<h2 class="text-lg sm:text-xl font-semibold flex items-center gap-2">
|
||||
<i class="fas fa-list text-primary-400"></i>
|
||||
Mes Playlists
|
||||
</h2>
|
||||
<button id="create-playlist-btn" class="px-3 sm:px-4 py-2 bg-primary-600 hover:bg-primary-500 rounded-lg font-medium transition-all text-sm flex items-center gap-2 focus:outline-none focus:ring-2 focus:ring-primary-400" aria-label="Créer une nouvelle playlist">
|
||||
<i class="fas fa-plus"></i>
|
||||
<span class="hidden sm:inline">Créer</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="my-playlists" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
|
||||
<div class="flex flex-col items-center justify-center py-16 sm:py-20">
|
||||
<div class="w-10 h-10 sm:w-12 sm:h-12 border-4 border-primary-500/30 border-t-primary-500 rounded-full animate-spin mb-3 sm:mb-4"></div>
|
||||
<p class="text-sm sm:text-base text-gray-400">Chargement...</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2><i class="fas fa-heart"></i> Titres likés</h2>
|
||||
<div class="track-list" id="liked-tracks">
|
||||
<p style="color: var(--text-secondary);">Aucun titre liké pour le moment</p>
|
||||
<!-- Liked Tracks Tab -->
|
||||
<div id="library-liked" class="tab-panel hidden" role="tabpanel" aria-labelledby="tab-liked">
|
||||
<section>
|
||||
<h2 class="text-lg sm:text-xl font-semibold mb-3 sm:mb-4 flex items-center gap-2">
|
||||
<i class="fas fa-heart text-accent-400"></i>
|
||||
Titres likés
|
||||
</h2>
|
||||
<div id="liked-tracks" class="space-y-2 max-w-4xl">
|
||||
<div class="flex flex-col items-center justify-center py-16 sm:py-20">
|
||||
<div class="w-10 h-10 sm:w-12 sm:h-12 border-4 border-accent-500/30 border-t-accent-500 rounded-full animate-spin mb-3 sm:mb-4"></div>
|
||||
<p class="text-sm sm:text-base text-gray-400">Chargement...</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Listening History Tab -->
|
||||
<div id="library-history" class="tab-panel hidden" role="tabpanel" aria-labelledby="tab-history">
|
||||
<section>
|
||||
<h2 class="text-lg sm:text-xl font-semibold mb-3 sm:mb-4 flex items-center gap-2">
|
||||
<i class="fas fa-history text-primary-400"></i>
|
||||
Historique d'écoute
|
||||
</h2>
|
||||
<div id="listening-history" class="space-y-2 max-w-4xl">
|
||||
<div class="flex flex-col items-center justify-center py-16 sm:py-20">
|
||||
<div class="w-10 h-10 sm:w-12 sm:h-12 border-4 border-primary-500/30 border-t-primary-500 rounded-full animate-spin mb-3 sm:mb-4"></div>
|
||||
<p class="text-sm sm:text-base text-gray-400">Chargement...</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Player -->
|
||||
<div id="player" class="player">
|
||||
<div class="player-info">
|
||||
<img id="player-cover" src="/static/img/default-cover.png" alt="Cover" class="player-cover">
|
||||
<div class="player-details">
|
||||
<div id="player-title" class="player-title">Aucun titre</div>
|
||||
<div id="player-artist" class="player-artist">-</div>
|
||||
<div id="player" class="hidden fixed bottom-0 left-0 right-0 glass border-t border-gray-800 px-2 sm:px-4 py-2 sm:py-3 z-40" role="region" aria-label="Lecteur audio">
|
||||
<!-- Mobile Compact View -->
|
||||
<div class="sm:hidden flex items-center gap-2">
|
||||
<!-- Track Info (Mobile) -->
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<img id="player-cover" src="/static/img/default-cover.png" alt=""
|
||||
class="w-10 h-10 rounded-lg object-cover bg-gray-800 flex-shrink-0" aria-hidden="true">
|
||||
<button id="mobile-play-btn" class="p-2 bg-primary-600 rounded-full flex-shrink-0" aria-label="Lecture/Pause">
|
||||
<i class="fas fa-play text-xs"></i>
|
||||
</button>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div id="player-title" class="font-medium text-xs truncate" aria-live="polite">Aucun titre</div>
|
||||
<div id="player-artist" class="text-xs text-gray-400 truncate" aria-live="polite">-</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Actions (Mobile) -->
|
||||
<div class="flex items-center gap-1 flex-shrink-0">
|
||||
<button id="mobile-like-btn" class="p-2 text-gray-400 hover:text-accent-400 transition-all" aria-label="J'aime">
|
||||
<i class="far fa-heart text-sm"></i>
|
||||
</button>
|
||||
<button id="mobile-expand-btn" class="p-2 text-gray-400 hover:text-white transition-all" aria-label="Agrandir le player">
|
||||
<i class="fas fa-chevron-up text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="player-controls">
|
||||
<button class="btn-control" id="shuffle-btn" title="Aléatoire">
|
||||
<i class="fas fa-random"></i>
|
||||
</button>
|
||||
<button class="btn-control" id="prev-btn" title="Précédent">
|
||||
<i class="fas fa-step-backward"></i>
|
||||
</button>
|
||||
<button class="btn-control btn-play" id="play-btn" title="Play/Pause">
|
||||
<i class="fas fa-play"></i>
|
||||
</button>
|
||||
<button class="btn-control" id="next-btn" title="Suivant">
|
||||
<i class="fas fa-step-forward"></i>
|
||||
</button>
|
||||
<button class="btn-control" id="repeat-btn" title="Répéter">
|
||||
<i class="fas fa-redo"></i>
|
||||
</button>
|
||||
<!-- Desktop Full View -->
|
||||
<div class="hidden sm:flex items-center gap-2 lg:gap-4 max-w-screen-2xl mx-auto">
|
||||
<!-- Track Info -->
|
||||
<div class="flex items-center gap-2 lg:gap-3 flex-shrink-0 w-32 lg:w-64">
|
||||
<img id="player-cover-desktop" src="/static/img/default-cover.png" alt=""
|
||||
class="w-10 h-10 lg:w-14 lg:h-14 rounded-lg object-cover bg-gray-800 flex-shrink-0" aria-hidden="true">
|
||||
<div class="min-w-0 flex-1 hidden sm:block">
|
||||
<div id="player-title-desktop" class="font-medium text-xs lg:text-sm truncate" aria-live="polite">Aucun titre</div>
|
||||
<div id="player-artist-desktop" class="text-xs text-gray-400 truncate" aria-live="polite">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="flex-1 flex flex-col items-center gap-1 lg:gap-2">
|
||||
<!-- Main Controls -->
|
||||
<div class="flex items-center gap-1 lg:gap-2">
|
||||
<button id="shuffle-btn" class="p-1.5 lg:p-3 text-gray-400 hover:text-white hover:bg-gray-800/50 rounded-lg transition-all focus:outline-none focus:ring-2 focus:ring-primary-500 min-w-[36px] lg:min-w-[44px] min-h-[36px] lg:min-h-[44px] flex items-center justify-center" aria-label="Mode aléatoire" aria-pressed="false">
|
||||
<i class="fas fa-random text-sm lg:text-base" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button id="prev-btn" class="p-1.5 lg:p-3 text-gray-400 hover:text-white hover:bg-gray-800/50 rounded-lg transition-all focus:outline-none focus:ring-2 focus:ring-primary-500 min-w-[36px] lg:min-w-[44px] min-h-[36px] lg:min-h-[44px] flex items-center justify-center" aria-label="Piste précédente">
|
||||
<i class="fas fa-step-backward text-sm lg:text-base" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button id="play-btn" class="p-2 lg:p-4 bg-primary-600 hover:bg-primary-500 rounded-full transition-all transform hover:scale-110 focus:outline-none focus:ring-4 focus:ring-primary-500/50 min-w-[40px] lg:min-w-[52px] min-h-[40px] lg:min-h-[52px] flex items-center justify-center" aria-label="Lecture" aria-pressed="false">
|
||||
<i class="fas fa-play text-sm lg:text-base" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button id="next-btn" class="p-1.5 lg:p-3 text-gray-400 hover:text-white hover:bg-gray-800/50 rounded-lg transition-all focus:outline-none focus:ring-2 focus:ring-primary-500 min-w-[36px] lg:min-w-[44px] min-h-[36px] lg:min-h-[44px] flex items-center justify-center" aria-label="Piste suivante">
|
||||
<i class="fas fa-step-forward text-sm lg:text-base" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button id="repeat-btn" class="p-1.5 lg:p-3 text-gray-400 hover:text-white hover:bg-gray-800/50 rounded-lg transition-all focus:outline-none focus:ring-2 focus:ring-primary-500 min-w-[36px] lg:min-w-[44px] min-h-[36px] lg:min-h-[44px] flex items-center justify-center" aria-label="Répéter" aria-pressed="false">
|
||||
<i class="fas fa-redo text-sm lg:text-base" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Progress -->
|
||||
<div class="flex items-center gap-2 lg:gap-3 w-full max-w-xl px-2">
|
||||
<span id="current-time" class="text-xs text-gray-400 w-8 lg:w-10 text-right flex-shrink-0" aria-live="off" aria-label="Temps écoulé">0:00</span>
|
||||
<label for="progress-bar" class="sr-only">Barre de progression</label>
|
||||
<input type="range" id="progress-bar" min="0" max="100" value="0"
|
||||
class="flex-1 h-1" aria-label="Progression de la lecture" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-valuetext="0%">
|
||||
<span id="total-time" class="text-xs text-gray-400 w-8 lg:w-10 flex-shrink-0" aria-live="off" aria-label="Durée totale">0:00</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Volume & Actions -->
|
||||
<div class="flex items-center gap-1 lg:gap-2 flex-shrink-0">
|
||||
<button id="mute-btn" class="p-1.5 lg:p-3 text-gray-400 hover:text-white transition-all focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-lg min-w-[36px] lg:min-w-[44px] min-h-[36px] lg:min-h-[44px] flex items-center justify-center" aria-label="Couper le son" aria-pressed="false">
|
||||
<i class="fas fa-volume-up text-sm lg:text-base" aria-hidden="true"></i>
|
||||
</button>
|
||||
<label for="volume-bar" class="sr-only">Volume</label>
|
||||
<input type="range" id="volume-bar" min="0" max="100" value="100"
|
||||
class="w-12 lg:w-20 hidden md:block" aria-label="Volume" aria-valuemin="0" aria-valuemax="100" aria-valuenow="100" aria-valuetext="100%">
|
||||
<div class="w-px h-6 lg:h-8 bg-gray-700 mx-1 lg:mx-2 hidden md:block" aria-hidden="true"></div>
|
||||
<button id="like-btn" class="p-1.5 lg:p-3 text-gray-400 hover:text-accent-400 transition-all focus:outline-none focus:ring-2 focus:ring-accent-500 rounded-lg min-w-[36px] lg:min-w-[44px] min-h-[36px] lg:min-h-[44px] flex items-center justify-center" aria-label="J'aime" aria-pressed="false">
|
||||
<i class="far fa-heart text-sm lg:text-base" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button id="queue-open-btn" class="p-1.5 lg:p-3 text-gray-400 hover:text-white transition-all focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-lg min-w-[36px] lg:min-w-[44px] min-h-[36px] lg:min-h-[44px] flex items-center justify-center relative" aria-label="File d'attente" aria-expanded="false">
|
||||
<i class="fas fa-list-ul text-sm lg:text-base" aria-hidden="true"></i>
|
||||
<span id="queue-count" class="absolute -top-1 -right-1 bg-primary-600 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center font-bold">0</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="player-progress">
|
||||
<span id="current-time" class="time">0:00</span>
|
||||
<input type="range" id="progress-bar" min="0" max="100" value="0" class="progress-bar">
|
||||
<span id="total-time" class="time">0:00</span>
|
||||
<audio id="audio-player" preload="none" class="hidden"></audio>
|
||||
</div>
|
||||
|
||||
<!-- Create Playlist Modal -->
|
||||
<div id="create-playlist-modal" class="hidden fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" role="dialog" aria-labelledby="create-playlist-title" aria-modal="true" aria-hidden="true">
|
||||
<div class="glass-card rounded-2xl p-6 w-full max-w-md animate-fadeIn">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 id="create-playlist-title" class="text-xl font-bold">Créer une playlist</h2>
|
||||
<button id="close-create-playlist-modal" class="p-2 text-gray-400 hover:text-white transition-all rounded-lg hover:bg-gray-700/50 focus:outline-none focus:ring-2 focus:ring-primary-500" aria-label="Fermer">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="create-playlist-form" aria-label="Formulaire de création de playlist">
|
||||
<div class="mb-4">
|
||||
<label for="playlist-name" class="block text-sm font-medium text-gray-300 mb-2">Nom de la playlist *</label>
|
||||
<input type="text" id="playlist-name" required
|
||||
class="w-full px-4 py-3 bg-gray-800/50 border border-gray-700 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-transparent focus:outline-none transition-all"
|
||||
placeholder="Ma nouvelle playlist" aria-describedby="playlist-name-hint">
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="playlist-description" class="block text-sm font-medium text-gray-300 mb-2">Description (optionnel)</label>
|
||||
<textarea id="playlist-description" rows="3"
|
||||
class="w-full px-4 py-3 bg-gray-800/50 border border-gray-700 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-transparent focus:outline-none transition-all resize-none"
|
||||
placeholder="Décrivez votre playlist..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button type="button" id="cancel-create-playlist"
|
||||
class="flex-1 px-4 py-3 bg-gray-700 hover:bg-gray-600 rounded-xl font-medium transition-all focus:outline-none focus:ring-2 focus:ring-gray-500">
|
||||
Annuler
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="flex-1 px-4 py-3 bg-gradient-to-r from-primary-600 to-primary-500 hover:from-primary-500 hover:to-primary-400 rounded-xl font-semibold transition-all transform hover:scale-[1.02] active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-primary-400 shadow-lg">
|
||||
Créer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Playlist Details Modal -->
|
||||
<div id="playlist-details-modal" class="hidden fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" role="dialog" aria-labelledby="playlist-details-title" aria-modal="true" aria-hidden="true">
|
||||
<div class="glass-card rounded-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden animate-fadeIn flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="p-6 border-b border-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 id="playlist-details-title" class="text-xl font-bold truncate flex-1">Titre de la playlist</h2>
|
||||
<button id="close-playlist-details" class="p-2 text-gray-400 hover:text-white transition-all rounded-lg hover:bg-gray-700/50 focus:outline-none focus:ring-2 focus:ring-primary-500 ml-2" aria-label="Fermer">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p id="playlist-details-description" class="text-gray-400 text-sm mb-4"></p>
|
||||
<div class="flex items-center gap-3">
|
||||
<button id="play-playlist-btn" class="px-4 py-2 bg-primary-600 hover:bg-primary-500 rounded-lg font-medium transition-all flex items-center gap-2 focus:outline-none focus:ring-2 focus:ring-primary-400">
|
||||
<i class="fas fa-play"></i>
|
||||
Lecture
|
||||
</button>
|
||||
<button id="shuffle-playlist-btn" class="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg font-medium transition-all flex items-center gap-2 focus:outline-none focus:ring-2 focus:ring-gray-500">
|
||||
<i class="fas fa-random"></i>
|
||||
Aléatoire
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tracks -->
|
||||
<div id="playlist-tracks" class="flex-1 overflow-y-auto p-4">
|
||||
<div class="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||
<i class="fas fa-music text-4xl mb-4"></i>
|
||||
<p class="text-lg">Aucune piste</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Queue Panel -->
|
||||
<div id="queue-panel" class="fixed inset-y-0 right-0 w-full sm:w-96 glass border-l border-gray-800 z-50 transform translate-x-full transition-transform duration-300 ease-out" role="dialog" aria-labelledby="queue-title" aria-hidden="true">
|
||||
<!-- Header -->
|
||||
<div class="p-4 sm:p-6 border-b border-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 id="queue-title" class="text-lg sm:text-xl font-bold flex items-center gap-2">
|
||||
<i class="fas fa-list-ul text-primary-400"></i>
|
||||
File d'attente
|
||||
<span id="queue-count-badge" class="text-sm font-normal text-gray-400">(0)</span>
|
||||
</h2>
|
||||
<button id="queue-close-btn" class="p-2 text-gray-400 hover:text-white transition-all focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-lg" aria-label="Fermer la file d'attente">
|
||||
<i class="fas fa-times text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Queue Actions -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="queue-shuffle-btn" class="flex-1 px-4 py-2 bg-gray-800/50 hover:bg-gray-700/50 text-gray-300 hover:text-white rounded-lg transition-all text-sm font-medium focus:outline-none focus:ring-2 focus:ring-primary-500 flex items-center justify-center gap-2" aria-label="Mélanger la file d'attente">
|
||||
<i class="fas fa-random"></i>
|
||||
Mélanger
|
||||
</button>
|
||||
<button id="queue-clear-btn" class="flex-1 px-4 py-2 bg-gray-800/50 hover:bg-red-600/30 text-gray-300 hover:text-red-400 rounded-lg transition-all text-sm font-medium focus:outline-none focus:ring-2 focus:ring-red-500 flex items-center justify-center gap-2" aria-label="Vider la file d'attente">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
Vider
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="player-volume">
|
||||
<button class="btn-control" id="mute-btn" title="Muet">
|
||||
<i class="fas fa-volume-up"></i>
|
||||
</button>
|
||||
<input type="range" id="volume-bar" min="0" max="100" value="100" class="volume-bar">
|
||||
<!-- Queue List -->
|
||||
<div id="queue-list" class="p-4 overflow-y-auto" style="max-height: calc(100vh - 200px);" role="list" aria-label="Pistes dans la file d'attente">
|
||||
<!-- Queue items will be dynamically inserted here -->
|
||||
<div class="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||
<i class="fas fa-list-ul text-4xl mb-4"></i>
|
||||
<p class="text-lg">File d'attente vide</p>
|
||||
<p class="text-sm mt-2">Cliquez sur une piste pour l'ajouter</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="player-actions">
|
||||
<button class="btn-control" id="like-btn" title="J'aime">
|
||||
<i class="far fa-heart"></i>
|
||||
</button>
|
||||
<button class="btn-control" id="playlist-btn" title="Ajouter à la playlist">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<audio id="audio-player" preload="none"></audio>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Fallback: Hide loading screen after 5 seconds if JS fails
|
||||
setTimeout(function() {
|
||||
const loadingScreen = document.getElementById('loading-screen');
|
||||
if (loadingScreen) {
|
||||
console.error('Loading screen timeout - JS may have failed to load');
|
||||
loadingScreen.style.display = 'none';
|
||||
}
|
||||
}, 5000);
|
||||
</script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user