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:
root
2026-01-18 20:08:36 +00:00
commit a89c7894cf
132 changed files with 23178 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Services module."""
+182
View File
@@ -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
+273
View File
@@ -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]
+402
View File
@@ -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
+295
View File
@@ -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 []