Files
vynl/backend/app/api/endpoints/youtube_music.py

154 lines
5.1 KiB
Python

import asyncio
from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from pydantic import BaseModel
from sqlalchemy import select
from app.core.config import settings
from app.core.security import ALGORITHM
from app.core.database import async_session
from app.models.user import User
from app.models.playlist import Playlist
from app.models.track import Track
from app.services.youtube_music import get_playlist_tracks, search_track
from app.services.recommender import build_taste_profile
from app.schemas.playlist import PlaylistDetailResponse
router = APIRouter(prefix="/youtube-music", tags=["youtube-music"])
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
class ImportYouTubeRequest(BaseModel):
url: str
class SearchYouTubeRequest(BaseModel):
query: str
class YouTubeTrackResult(BaseModel):
title: str
artist: str
album: str | None = None
youtube_id: str | None = None
image_url: str | None = None
def _get_user_id_from_token(token: str) -> int:
"""Extract user ID from JWT without hitting the database."""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(status_code=401, detail="Invalid credentials")
return int(user_id)
except JWTError:
raise HTTPException(status_code=401, detail="Invalid credentials")
@router.post("/import", response_model=PlaylistDetailResponse)
async def import_youtube_playlist(
data: ImportYouTubeRequest,
token: str = Depends(oauth2_scheme),
):
user_id = _get_user_id_from_token(token)
# Run sync ytmusicapi in a thread — NO DB connection open during this
try:
playlist_name, playlist_image, raw_tracks = await asyncio.to_thread(
get_playlist_tracks, data.url
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception:
raise HTTPException(status_code=400, detail="Failed to fetch playlist from YouTube Music. Make sure the URL is valid and the playlist is public.")
if not raw_tracks:
raise HTTPException(status_code=400, detail="Playlist is empty or could not be read.")
# Now do all DB work in a fresh session
async with async_session() as db:
try:
# Verify user exists
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=401, detail="User not found")
# Free tier limit
if not user.is_pro:
result = await db.execute(
select(Playlist).where(Playlist.user_id == user.id)
)
existing = list(result.scalars().all())
if len(existing) >= settings.FREE_MAX_PLAYLISTS:
raise HTTPException(
status_code=403,
detail="Free tier limited to 1 playlist. Upgrade to Premium for unlimited.",
)
playlist = Playlist(
user_id=user.id,
name=playlist_name,
platform_source="youtube_music",
external_id=data.url,
track_count=len(raw_tracks),
)
db.add(playlist)
await db.flush()
tracks = []
for rt in raw_tracks:
track = Track(
playlist_id=playlist.id,
title=rt["title"],
artist=rt["artist"],
album=rt.get("album"),
image_url=rt.get("image_url"),
)
db.add(track)
tracks.append(track)
await db.flush()
playlist.taste_profile = build_taste_profile(tracks)
await db.commit()
# Return response manually to avoid lazy-load issues
return PlaylistDetailResponse(
id=playlist.id,
name=playlist.name,
platform_source=playlist.platform_source,
track_count=playlist.track_count,
taste_profile=playlist.taste_profile,
imported_at=playlist.imported_at,
tracks=[],
)
except HTTPException:
await db.rollback()
raise
except Exception:
await db.rollback()
raise
@router.post("/search", response_model=list[YouTubeTrackResult])
async def search_youtube_music(
data: SearchYouTubeRequest,
token: str = Depends(oauth2_scheme),
):
_get_user_id_from_token(token) # Just verify auth
if not data.query.strip():
raise HTTPException(status_code=400, detail="Query cannot be empty")
try:
results = await asyncio.to_thread(search_track, data.query.strip())
except Exception:
raise HTTPException(status_code=500, detail="Failed to search YouTube Music")
return results