Initial commit: AudiOhm - Alternative Spotify avec streaming YouTube
Backend: - FastAPI avec PostgreSQL et Redis - Authentification JWT complète - API REST pour musique, playlists, recherche - Streaming audio via yt-dlp - SQLAlchemy 2.0 async Frontend: - Flutter avec thème néon cyberpunk - State management Riverpod - Layout adaptatif desktop/mobile - Lecteur audio avec mini-player Infrastructure: - Docker Compose (PostgreSQL + Redis) - Scripts d'installation automatisés - Scripts de build pour exécutables Fichiers ajoutés: - BUILD_CLIENT_*.bat/sh: Scripts de compilation - BUILD_CLIENT_README.md: Documentation compilation - CHECK_FLUTTER.sh: Vérificateur d'environnement - requirements.txt mis à jour pour Python 3.13 - Modèles SQLAlchemy corrigés (metadata -> extra_metadata) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Services module."""
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Authentication service."""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.security import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
get_password_hash,
|
||||
verify_password,
|
||||
)
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""Service for authentication operations."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def register(
|
||||
self,
|
||||
email: str,
|
||||
username: str,
|
||||
password: str,
|
||||
display_name: Optional[str] = None,
|
||||
) -> User:
|
||||
"""
|
||||
Register a new user.
|
||||
|
||||
Args:
|
||||
email: User email
|
||||
username: Username
|
||||
password: Plain text password
|
||||
display_name: Optional display name
|
||||
|
||||
Returns:
|
||||
Created user
|
||||
|
||||
Raises:
|
||||
ValueError: If email or username already exists
|
||||
"""
|
||||
# Check if email exists
|
||||
result = await self.db.execute(
|
||||
select(User).where(User.email == email)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
raise ValueError("Email already registered")
|
||||
|
||||
# Check if username exists
|
||||
result = await self.db.execute(
|
||||
select(User).where(User.username == username)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
raise ValueError("Username already taken")
|
||||
|
||||
# Create user
|
||||
user = User(
|
||||
email=email,
|
||||
username=username,
|
||||
password_hash=get_password_hash(password),
|
||||
display_name=display_name or username,
|
||||
)
|
||||
|
||||
self.db.add(user)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(user)
|
||||
|
||||
return user
|
||||
|
||||
async def login(self, email: str, password: str) -> User:
|
||||
"""
|
||||
Authenticate user with email and password.
|
||||
|
||||
Args:
|
||||
email: User email
|
||||
password: Plain text password
|
||||
|
||||
Returns:
|
||||
Authenticated user
|
||||
|
||||
Raises:
|
||||
ValueError: If credentials are invalid
|
||||
"""
|
||||
# Find user by email
|
||||
result = await self.db.execute(
|
||||
select(User).where(User.email == email)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise ValueError("Invalid email or password")
|
||||
|
||||
# Verify password
|
||||
if not verify_password(password, user.password_hash):
|
||||
raise ValueError("Invalid email or password")
|
||||
|
||||
# Update last login
|
||||
user.last_login = datetime.utcnow()
|
||||
await self.db.commit()
|
||||
|
||||
return user
|
||||
|
||||
async def get_user_by_id(self, user_id: UUID) -> Optional[User]:
|
||||
"""
|
||||
Get user by ID.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
|
||||
Returns:
|
||||
User or None
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(User).where(User.id == user_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def update_user(
|
||||
self,
|
||||
user_id: UUID,
|
||||
display_name: Optional[str] = None,
|
||||
avatar_url: Optional[str] = None,
|
||||
date_of_birth: Optional[datetime] = None,
|
||||
country: Optional[str] = None,
|
||||
) -> User:
|
||||
"""
|
||||
Update user profile.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
display_name: Optional display name
|
||||
avatar_url: Optional avatar URL
|
||||
date_of_birth: Optional date of birth
|
||||
country: Optional country code
|
||||
|
||||
Returns:
|
||||
Updated user
|
||||
|
||||
Raises:
|
||||
ValueError: If user not found
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(User).where(User.id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise ValueError("User not found")
|
||||
|
||||
# Update fields
|
||||
if display_name is not None:
|
||||
user.display_name = display_name
|
||||
if avatar_url is not None:
|
||||
user.avatar_url = avatar_url
|
||||
if date_of_birth is not None:
|
||||
user.date_of_birth = date_of_birth
|
||||
if country is not None:
|
||||
user.country = country
|
||||
|
||||
user.updated_at = datetime.utcnow()
|
||||
await self.db.commit()
|
||||
await self.db.refresh(user)
|
||||
|
||||
return user
|
||||
|
||||
def create_tokens(self, user_id: UUID) -> tuple[str, str]:
|
||||
"""
|
||||
Create access and refresh tokens for user.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
|
||||
Returns:
|
||||
Tuple of (access_token, refresh_token)
|
||||
"""
|
||||
access_token = create_access_token(subject=str(user_id))
|
||||
refresh_token = create_refresh_token(subject=str(user_id))
|
||||
return access_token, refresh_token
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Music service."""
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.track import Track
|
||||
from app.models.artist import Artist
|
||||
from app.models.album import Album
|
||||
from app.services.youtube_service import YouTubeService
|
||||
|
||||
|
||||
class MusicService:
|
||||
"""Service for music operations."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.youtube = YouTubeService()
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
search_type: str = "all",
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
) -> dict:
|
||||
"""
|
||||
Search for music across database and YouTube.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
search_type: Type of content (track, artist, album, all)
|
||||
limit: Maximum results
|
||||
offset: Pagination offset
|
||||
|
||||
Returns:
|
||||
Search results with tracks, artists, albums
|
||||
"""
|
||||
results = {
|
||||
"tracks": [],
|
||||
"artists": [],
|
||||
"albums": [],
|
||||
"total": 0,
|
||||
"query": query,
|
||||
}
|
||||
|
||||
# Search database first
|
||||
if search_type in ["track", "all"]:
|
||||
results["tracks"] = await self._search_tracks(query, limit)
|
||||
results["total"] += len(results["tracks"])
|
||||
|
||||
if search_type in ["artist", "all"]:
|
||||
results["artists"] = await self._search_artists(query, limit)
|
||||
results["total"] += len(results["artists"])
|
||||
|
||||
if search_type in ["album", "all"]:
|
||||
results["albums"] = await self._search_albums(query, limit)
|
||||
results["total"] += len(results["albums"])
|
||||
|
||||
# If no local results, search YouTube
|
||||
if results["total"] == 0:
|
||||
yt_results = await self.youtube.search(query, max_results=limit)
|
||||
results["tracks"] = yt_results[:limit]
|
||||
|
||||
return results
|
||||
|
||||
async def _search_tracks(self, query: str, limit: int) -> List[dict]:
|
||||
"""Search tracks in database."""
|
||||
stmt = (
|
||||
select(Track)
|
||||
.options(selectinload(Track.artist), selectinload(Track.album))
|
||||
.where(
|
||||
or_(
|
||||
Track.title.ilike(f"%{query}%"),
|
||||
)
|
||||
)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
tracks = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(track.id),
|
||||
"title": track.title,
|
||||
"duration": track.duration,
|
||||
"image_url": track.image_url,
|
||||
"artist": track.artist.name if track.artist else None,
|
||||
"album": track.album.title if track.album else None,
|
||||
"youtube_id": track.youtube_id,
|
||||
}
|
||||
for track in tracks
|
||||
]
|
||||
|
||||
async def _search_artists(self, query: str, limit: int) -> List[dict]:
|
||||
"""Search artists in database."""
|
||||
stmt = (
|
||||
select(Artist)
|
||||
.where(Artist.name.ilike(f"%{query}%"))
|
||||
.limit(limit)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
artists = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(artist.id),
|
||||
"name": artist.name,
|
||||
"image_url": artist.image_url,
|
||||
"genres": artist.genres,
|
||||
"popularity": artist.popularity,
|
||||
}
|
||||
for artist in artists
|
||||
]
|
||||
|
||||
async def _search_albums(self, query: str, limit: int) -> List[dict]:
|
||||
"""Search albums in database."""
|
||||
stmt = (
|
||||
select(Album)
|
||||
.options(selectinload(Album.artist))
|
||||
.where(Album.title.ilike(f"%{query}%"))
|
||||
.limit(limit)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
albums = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(album.id),
|
||||
"title": album.title,
|
||||
"image_url": album.image_url,
|
||||
"artist": album.artist.name if album.artist else None,
|
||||
"total_tracks": album.total_tracks,
|
||||
"release_date": album.release_date.isoformat() if album.release_date else None,
|
||||
}
|
||||
for album in albums
|
||||
]
|
||||
|
||||
async def get_track(self, track_id: UUID) -> Optional[Track]:
|
||||
"""
|
||||
Get track by ID.
|
||||
|
||||
Args:
|
||||
track_id: Track UUID
|
||||
|
||||
Returns:
|
||||
Track or None
|
||||
"""
|
||||
stmt = (
|
||||
select(Track)
|
||||
.options(selectinload(Track.artist), selectinload(Track.album))
|
||||
.where(Track.id == track_id)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_stream_url(
|
||||
self,
|
||||
track_id: UUID,
|
||||
quality: str = "high",
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Get stream URL for a track.
|
||||
|
||||
Args:
|
||||
track_id: Track UUID
|
||||
quality: Audio quality
|
||||
|
||||
Returns:
|
||||
Stream URL or None
|
||||
"""
|
||||
track = await self.get_track(track_id)
|
||||
if not track or not track.youtube_id:
|
||||
return None
|
||||
|
||||
# Try to get direct stream URL from YouTube
|
||||
stream_url = await self.youtube.get_stream_url(track.youtube_id)
|
||||
if stream_url:
|
||||
return stream_url
|
||||
|
||||
# Fallback: download and serve locally
|
||||
cache_path = await self.youtube.download_audio(track.youtube_id, quality)
|
||||
if cache_path:
|
||||
# In production, you'd serve this through a dedicated endpoint
|
||||
return f"/api/v1/music/tracks/{track_id}/stream?cache=true"
|
||||
|
||||
return None
|
||||
|
||||
async def create_track_from_youtube(
|
||||
self,
|
||||
youtube_id: str,
|
||||
title: str,
|
||||
artist_name: Optional[str] = None,
|
||||
album_name: Optional[str] = None,
|
||||
) -> Track:
|
||||
"""
|
||||
Create a track from YouTube video ID.
|
||||
|
||||
Args:
|
||||
youtube_id: YouTube video ID
|
||||
title: Track title
|
||||
artist_name: Optional artist name
|
||||
album_name: Optional album name
|
||||
|
||||
Returns:
|
||||
Created track
|
||||
"""
|
||||
# Get video info from YouTube
|
||||
video_info = await self.youtube.get_video_info(youtube_id)
|
||||
if video_info:
|
||||
title = video_info.get("title", title)
|
||||
artist_name = artist_name or video_info.get("artist")
|
||||
duration = video_info.get("duration")
|
||||
thumbnail = video_info.get("thumbnail")
|
||||
else:
|
||||
duration = None
|
||||
thumbnail = None
|
||||
|
||||
# Find or create artist
|
||||
artist = None
|
||||
if artist_name:
|
||||
stmt = select(Artist).where(Artist.name == artist_name)
|
||||
result = await self.db.execute(stmt)
|
||||
artist = result.scalar_one_or_none()
|
||||
|
||||
if not artist:
|
||||
artist = Artist(
|
||||
name=artist_name,
|
||||
image_url=thumbnail,
|
||||
)
|
||||
self.db.add(artist)
|
||||
await self.db.flush()
|
||||
|
||||
# Create track
|
||||
track = Track(
|
||||
title=title,
|
||||
youtube_id=youtube_id,
|
||||
artist_id=artist.id if artist else None,
|
||||
duration=duration,
|
||||
image_url=thumbnail,
|
||||
)
|
||||
|
||||
self.db.add(track)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(track)
|
||||
|
||||
return track
|
||||
|
||||
async def get_recommendations(
|
||||
self,
|
||||
track_id: UUID,
|
||||
limit: int = 10,
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Get recommendations based on a track.
|
||||
|
||||
Args:
|
||||
track_id: Seed track UUID
|
||||
limit: Number of recommendations
|
||||
|
||||
Returns:
|
||||
List of recommended tracks
|
||||
"""
|
||||
track = await self.get_track(track_id)
|
||||
if not track or not track.youtube_id:
|
||||
return []
|
||||
|
||||
# Get related videos from YouTube
|
||||
related = await self.youtube.get_related_videos(track.youtube_id, max_results=limit)
|
||||
|
||||
return related[:limit]
|
||||
@@ -0,0 +1,402 @@
|
||||
"""Playlist service."""
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.playlist import Playlist
|
||||
from app.models.playlist_track import PlaylistTrack
|
||||
from app.models.track import Track
|
||||
|
||||
|
||||
class PlaylistService:
|
||||
"""Service for playlist operations."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def create_playlist(
|
||||
self,
|
||||
user_id: UUID,
|
||||
name: str,
|
||||
description: Optional[str] = None,
|
||||
image_url: Optional[str] = None,
|
||||
is_public: bool = False,
|
||||
is_collaborative: bool = False,
|
||||
) -> Playlist:
|
||||
"""
|
||||
Create a new playlist.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
name: Playlist name
|
||||
description: Optional description
|
||||
image_url: Optional cover image URL
|
||||
is_public: Whether playlist is public
|
||||
is_collaborative: Whether playlist is collaborative
|
||||
|
||||
Returns:
|
||||
Created playlist
|
||||
"""
|
||||
playlist = Playlist(
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
description=description,
|
||||
image_url=image_url,
|
||||
is_public=is_public,
|
||||
is_collaborative=is_collaborative,
|
||||
track_count=0,
|
||||
total_duration=0,
|
||||
)
|
||||
|
||||
self.db.add(playlist)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(playlist)
|
||||
|
||||
return playlist
|
||||
|
||||
async def get_playlist(
|
||||
self,
|
||||
playlist_id: UUID,
|
||||
include_tracks: bool = False,
|
||||
) -> Optional[Playlist]:
|
||||
"""
|
||||
Get playlist by ID.
|
||||
|
||||
Args:
|
||||
playlist_id: Playlist UUID
|
||||
include_tracks: Whether to include tracks
|
||||
|
||||
Returns:
|
||||
Playlist or None
|
||||
"""
|
||||
stmt = select(Playlist).where(Playlist.id == playlist_id)
|
||||
|
||||
if include_tracks:
|
||||
stmt = stmt.options(selectinload(Playlist.playlist_tracks))
|
||||
|
||||
result = await self.db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_user_playlists(
|
||||
self,
|
||||
user_id: UUID,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> List[Playlist]:
|
||||
"""
|
||||
Get all playlists for a user.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
limit: Maximum results
|
||||
offset: Pagination offset
|
||||
|
||||
Returns:
|
||||
List of playlists
|
||||
"""
|
||||
stmt = (
|
||||
select(Playlist)
|
||||
.where(Playlist.user_id == user_id)
|
||||
.order_by(Playlist.updated_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def update_playlist(
|
||||
self,
|
||||
playlist_id: UUID,
|
||||
user_id: UUID,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
image_url: Optional[str] = None,
|
||||
is_public: Optional[bool] = None,
|
||||
) -> Playlist:
|
||||
"""
|
||||
Update playlist.
|
||||
|
||||
Args:
|
||||
playlist_id: Playlist UUID
|
||||
user_id: User UUID (for ownership check)
|
||||
name: New name
|
||||
description: New description
|
||||
image_url: New image URL
|
||||
is_public: New public status
|
||||
|
||||
Returns:
|
||||
Updated playlist
|
||||
|
||||
Raises:
|
||||
ValueError: If playlist not found or user not owner
|
||||
"""
|
||||
playlist = await self.get_playlist(playlist_id)
|
||||
if not playlist:
|
||||
raise ValueError("Playlist not found")
|
||||
|
||||
if playlist.user_id != user_id:
|
||||
raise ValueError("Not authorized to update this playlist")
|
||||
|
||||
if name is not None:
|
||||
playlist.name = name
|
||||
if description is not None:
|
||||
playlist.description = description
|
||||
if image_url is not None:
|
||||
playlist.image_url = image_url
|
||||
if is_public is not None:
|
||||
playlist.is_public = is_public
|
||||
|
||||
playlist.updated_at = datetime.utcnow()
|
||||
await self.db.commit()
|
||||
await self.db.refresh(playlist)
|
||||
|
||||
return playlist
|
||||
|
||||
async def delete_playlist(
|
||||
self,
|
||||
playlist_id: UUID,
|
||||
user_id: UUID,
|
||||
) -> None:
|
||||
"""
|
||||
Delete a playlist.
|
||||
|
||||
Args:
|
||||
playlist_id: Playlist UUID
|
||||
user_id: User UUID (for ownership check)
|
||||
|
||||
Raises:
|
||||
ValueError: If playlist not found or user not owner
|
||||
"""
|
||||
playlist = await self.get_playlist(playlist_id)
|
||||
if not playlist:
|
||||
raise ValueError("Playlist not found")
|
||||
|
||||
if playlist.user_id != user_id:
|
||||
raise ValueError("Not authorized to delete this playlist")
|
||||
|
||||
await self.db.delete(playlist)
|
||||
await self.db.commit()
|
||||
|
||||
async def add_tracks(
|
||||
self,
|
||||
playlist_id: UUID,
|
||||
track_ids: List[UUID],
|
||||
user_id: UUID,
|
||||
position: Optional[int] = None,
|
||||
) -> Playlist:
|
||||
"""
|
||||
Add tracks to a playlist.
|
||||
|
||||
Args:
|
||||
playlist_id: Playlist UUID
|
||||
track_ids: List of track UUIDs
|
||||
user_id: User UUID adding the tracks
|
||||
position: Optional starting position
|
||||
|
||||
Returns:
|
||||
Updated playlist
|
||||
|
||||
Raises:
|
||||
ValueError: If playlist not found
|
||||
"""
|
||||
playlist = await self.get_playlist(playlist_id)
|
||||
if not playlist:
|
||||
raise ValueError("Playlist not found")
|
||||
|
||||
# Get current max position
|
||||
stmt = (
|
||||
select(PlaylistTrack)
|
||||
.where(PlaylistTrack.playlist_id == playlist_id)
|
||||
.order_by(PlaylistTrack.position.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
last_track = result.scalar_one_or_none()
|
||||
max_position = last_track.position if last_track else -1
|
||||
|
||||
# Determine starting position
|
||||
if position is None:
|
||||
position = max_position + 1
|
||||
|
||||
# Add tracks
|
||||
current_position = position
|
||||
for track_id in track_ids:
|
||||
# Verify track exists
|
||||
track_stmt = select(Track).where(Track.id == track_id)
|
||||
track_result = await self.db.execute(track_stmt)
|
||||
track = track_result.scalar_one_or_none()
|
||||
|
||||
if not track:
|
||||
continue
|
||||
|
||||
# Create playlist track
|
||||
playlist_track = PlaylistTrack(
|
||||
playlist_id=playlist_id,
|
||||
track_id=track_id,
|
||||
position=current_position,
|
||||
added_by=user_id,
|
||||
)
|
||||
self.db.add(playlist_track)
|
||||
current_position += 1
|
||||
|
||||
# Update playlist stats
|
||||
playlist.track_count += len(track_ids)
|
||||
playlist.total_duration = await self._calculate_playlist_duration(playlist_id)
|
||||
playlist.updated_at = datetime.utcnow()
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(playlist)
|
||||
|
||||
return playlist
|
||||
|
||||
async def remove_track(
|
||||
self,
|
||||
playlist_id: UUID,
|
||||
track_id: UUID,
|
||||
user_id: UUID,
|
||||
) -> Playlist:
|
||||
"""
|
||||
Remove a track from a playlist.
|
||||
|
||||
Args:
|
||||
playlist_id: Playlist UUID
|
||||
track_id: Track UUID to remove
|
||||
user_id: User UUID (for ownership check)
|
||||
|
||||
Returns:
|
||||
Updated playlist
|
||||
|
||||
Raises:
|
||||
ValueError: If playlist or track not found
|
||||
"""
|
||||
playlist = await self.get_playlist(playlist_id)
|
||||
if not playlist:
|
||||
raise ValueError("Playlist not found")
|
||||
|
||||
if playlist.user_id != user_id:
|
||||
raise ValueError("Not authorized to modify this playlist")
|
||||
|
||||
# Find and remove the track
|
||||
stmt = select(PlaylistTrack).where(
|
||||
PlaylistTrack.playlist_id == playlist_id,
|
||||
PlaylistTrack.track_id == track_id,
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
playlist_track = result.scalar_one_or_none()
|
||||
|
||||
if not playlist_track:
|
||||
raise ValueError("Track not in playlist")
|
||||
|
||||
# Remove track
|
||||
await self.db.delete(playlist_track)
|
||||
|
||||
# Reorder remaining tracks
|
||||
tracks_stmt = (
|
||||
select(PlaylistTrack)
|
||||
.where(PlaylistTrack.playlist_id == playlist_id)
|
||||
.order_by(PlaylistTrack.position)
|
||||
)
|
||||
tracks_result = await self.db.execute(tracks_stmt)
|
||||
tracks = tracks_result.scalars().all()
|
||||
|
||||
for index, track in enumerate(tracks):
|
||||
track.position = index
|
||||
|
||||
# Update playlist stats
|
||||
playlist.track_count -= 1
|
||||
playlist.total_duration = await self._calculate_playlist_duration(playlist_id)
|
||||
playlist.updated_at = datetime.utcnow()
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(playlist)
|
||||
|
||||
return playlist
|
||||
|
||||
async def reorder_track(
|
||||
self,
|
||||
playlist_id: UUID,
|
||||
track_id: UUID,
|
||||
new_position: int,
|
||||
user_id: UUID,
|
||||
) -> Playlist:
|
||||
"""
|
||||
Reorder a track within a playlist.
|
||||
|
||||
Args:
|
||||
playlist_id: Playlist UUID
|
||||
track_id: Track UUID to reorder
|
||||
new_position: New position (0-indexed)
|
||||
user_id: User UUID (for ownership check)
|
||||
|
||||
Returns:
|
||||
Updated playlist
|
||||
|
||||
Raises:
|
||||
ValueError: If playlist or track not found
|
||||
"""
|
||||
playlist = await self.get_playlist(playlist_id)
|
||||
if not playlist:
|
||||
raise ValueError("Playlist not found")
|
||||
|
||||
if playlist.user_id != user_id:
|
||||
raise ValueError("Not authorized to modify this playlist")
|
||||
|
||||
# Get all tracks in playlist
|
||||
stmt = (
|
||||
select(PlaylistTrack)
|
||||
.where(PlaylistTrack.playlist_id == playlist_id)
|
||||
.order_by(PlaylistTrack.position)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
tracks = list(result.scalars().all())
|
||||
|
||||
# Find the track to move
|
||||
track_to_move = None
|
||||
for track in tracks:
|
||||
if track.track_id == track_id:
|
||||
track_to_move = track
|
||||
break
|
||||
|
||||
if not track_to_move:
|
||||
raise ValueError("Track not in playlist")
|
||||
|
||||
# Reorder
|
||||
old_position = track_to_move.position
|
||||
if old_position < new_position:
|
||||
# Moving down: shift tracks between old+1 and new up by 1
|
||||
for track in tracks:
|
||||
if old_position < track.position <= new_position:
|
||||
track.position -= 1
|
||||
else:
|
||||
# Moving up: shift tracks between new and old-1 down by 1
|
||||
for track in tracks:
|
||||
if new_position <= track.position < old_position:
|
||||
track.position += 1
|
||||
|
||||
# Set new position
|
||||
track_to_move.position = new_position
|
||||
|
||||
playlist.updated_at = datetime.utcnow()
|
||||
await self.db.commit()
|
||||
await self.db.refresh(playlist)
|
||||
|
||||
return playlist
|
||||
|
||||
async def _calculate_playlist_duration(self, playlist_id: UUID) -> int:
|
||||
"""Calculate total duration of a playlist in seconds."""
|
||||
stmt = (
|
||||
select(Track)
|
||||
.join(PlaylistTrack, Track.id == PlaylistTrack.track_id)
|
||||
.where(PlaylistTrack.playlist_id == playlist_id)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
tracks = result.scalars().all()
|
||||
|
||||
total_duration = sum(
|
||||
track.duration for track in tracks if track.duration is not None
|
||||
)
|
||||
return total_duration
|
||||
@@ -0,0 +1,295 @@
|
||||
"""YouTube service using yt-dlp."""
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import uuid4
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class YouTubeService:
|
||||
"""Service for YouTube operations using yt-dlp."""
|
||||
|
||||
def __init__(self):
|
||||
self.ytdlp_path = settings.YTDLP_PATH
|
||||
self.cache_dir = Path(settings.AUDIO_CACHE_PATH)
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
max_results: int = 20,
|
||||
search_type: str = "videos",
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search YouTube for videos.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
max_results: Maximum number of results
|
||||
search_type: Type of search (videos, playlists, etc.)
|
||||
|
||||
Returns:
|
||||
List of search results
|
||||
"""
|
||||
cmd = [
|
||||
self.ytdlp_path,
|
||||
"ytsearch" + str(max_results) + ":" + query,
|
||||
"--dump-json",
|
||||
"--flat-playlist",
|
||||
"--skip-download",
|
||||
"--no-warnings",
|
||||
]
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
error_msg = stderr.decode() if stderr else "Unknown error"
|
||||
print(f"yt-dlp search error: {error_msg}")
|
||||
return []
|
||||
|
||||
# Parse JSON output (one line per video)
|
||||
results = []
|
||||
for line in stdout.decode().split("\n"):
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(line)
|
||||
results.append(self._parse_search_result(data))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error searching YouTube: {e}")
|
||||
return []
|
||||
|
||||
def _parse_search_result(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Parse yt-dlp search result."""
|
||||
return {
|
||||
"youtube_id": data.get("id", ""),
|
||||
"title": data.get("title", ""),
|
||||
"artist": data.get("artist", data.get("uploader", "")),
|
||||
"duration": self._parse_duration(data.get("duration")),
|
||||
"thumbnail": data.get("thumbnail"),
|
||||
"url": f"https://www.youtube.com/watch?v={data.get('id', '')}",
|
||||
}
|
||||
|
||||
def _parse_duration(self, duration: Optional[int]) -> Optional[int]:
|
||||
"""Parse duration in seconds."""
|
||||
if duration is None:
|
||||
return None
|
||||
return int(duration)
|
||||
|
||||
async def get_video_info(self, video_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get detailed information about a video.
|
||||
|
||||
Args:
|
||||
video_id: YouTube video ID
|
||||
|
||||
Returns:
|
||||
Video information or None
|
||||
"""
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
|
||||
cmd = [
|
||||
self.ytdlp_path,
|
||||
url,
|
||||
"--dump-json",
|
||||
"--skip-download",
|
||||
"--no-warnings",
|
||||
]
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
return None
|
||||
|
||||
data = json.loads(stdout.decode())
|
||||
return self._parse_video_info(data)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting video info: {e}")
|
||||
return None
|
||||
|
||||
def _parse_video_info(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Parse yt-dlp video info."""
|
||||
return {
|
||||
"youtube_id": data.get("id", ""),
|
||||
"title": data.get("title", ""),
|
||||
"artist": data.get("artist", data.get("uploader", "")),
|
||||
"album": data.get("album", ""),
|
||||
"duration": self._parse_duration(data.get("duration")),
|
||||
"thumbnail": data.get("thumbnail"),
|
||||
"description": data.get("description"),
|
||||
"genres": data.get("genres", []),
|
||||
"upload_date": data.get("upload_date"),
|
||||
}
|
||||
|
||||
async def get_stream_url(self, video_id: str) -> Optional[str]:
|
||||
"""
|
||||
Get direct stream URL for a video.
|
||||
|
||||
Args:
|
||||
video_id: YouTube video ID
|
||||
|
||||
Returns:
|
||||
Stream URL or None
|
||||
"""
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
|
||||
cmd = [
|
||||
self.ytdlp_path,
|
||||
url,
|
||||
"--get-url",
|
||||
"--format",
|
||||
"bestaudio[ext=m4a]/bestaudio/best",
|
||||
"--no-warnings",
|
||||
]
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
return None
|
||||
|
||||
stream_url = stdout.decode().strip()
|
||||
return stream_url if stream_url else None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting stream URL: {e}")
|
||||
return None
|
||||
|
||||
async def download_audio(
|
||||
self,
|
||||
video_id: str,
|
||||
quality: str = "high",
|
||||
) -> Optional[Path]:
|
||||
"""
|
||||
Download audio from YouTube and cache it.
|
||||
|
||||
Args:
|
||||
video_id: YouTube video ID
|
||||
quality: Audio quality (low, medium, high)
|
||||
|
||||
Returns:
|
||||
Path to downloaded file or None
|
||||
"""
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
cache_path = self.cache_dir / f"{video_id}.mp3"
|
||||
|
||||
# Check if already cached
|
||||
if cache_path.exists():
|
||||
return cache_path
|
||||
|
||||
# Determine format based on quality
|
||||
if quality == "high":
|
||||
audio_format = "320"
|
||||
elif quality == "medium":
|
||||
audio_format = "192"
|
||||
else:
|
||||
audio_format = "128"
|
||||
|
||||
cmd = [
|
||||
self.ytdlp_path,
|
||||
url,
|
||||
"--extract-audio",
|
||||
"--audio-format",
|
||||
"mp3",
|
||||
"--audio-quality",
|
||||
audio_format,
|
||||
"--output",
|
||||
str(cache_path),
|
||||
"--no-warnings",
|
||||
]
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
error_msg = stderr.decode() if stderr else "Unknown error"
|
||||
print(f"Error downloading audio: {error_msg}")
|
||||
return None
|
||||
|
||||
return cache_path if cache_path.exists() else None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error downloading audio: {e}")
|
||||
return None
|
||||
|
||||
async def get_related_videos(self, video_id: str, max_results: int = 10) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get related videos for a video.
|
||||
|
||||
Args:
|
||||
video_id: YouTube video ID
|
||||
max_results: Maximum number of results
|
||||
|
||||
Returns:
|
||||
List of related videos
|
||||
"""
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
|
||||
cmd = [
|
||||
self.ytdlp_path,
|
||||
url,
|
||||
"--flat-playlist",
|
||||
"--playlist-end",
|
||||
str(max_results),
|
||||
"--dump-json",
|
||||
"--no-warnings",
|
||||
]
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
return []
|
||||
|
||||
results = []
|
||||
for line in stdout.decode().split("\n"):
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(line)
|
||||
if data.get("id") != video_id: # Exclude the video itself
|
||||
results.append(self._parse_search_result(data))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting related videos: {e}")
|
||||
return []
|
||||
Reference in New Issue
Block a user