Fix YouTube Music import: isolate sync ytmusicapi from async DB session
This commit is contained in:
@@ -1,14 +1,14 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from functools import partial
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from jose import JWTError, jwt
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import get_db
|
from app.core.security import ALGORITHM
|
||||||
from app.core.security import get_current_user
|
from app.core.database import async_session
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.playlist import Playlist
|
from app.models.playlist import Playlist
|
||||||
from app.models.track import Track
|
from app.models.track import Track
|
||||||
@@ -17,6 +17,7 @@ from app.services.recommender import build_taste_profile
|
|||||||
from app.schemas.playlist import PlaylistDetailResponse
|
from app.schemas.playlist import PlaylistDetailResponse
|
||||||
|
|
||||||
router = APIRouter(prefix="/youtube-music", tags=["youtube-music"])
|
router = APIRouter(prefix="/youtube-music", tags=["youtube-music"])
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||||
|
|
||||||
|
|
||||||
class ImportYouTubeRequest(BaseModel):
|
class ImportYouTubeRequest(BaseModel):
|
||||||
@@ -35,29 +36,29 @@ class YouTubeTrackResult(BaseModel):
|
|||||||
image_url: 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)
|
@router.post("/import", response_model=PlaylistDetailResponse)
|
||||||
async def import_youtube_playlist(
|
async def import_youtube_playlist(
|
||||||
data: ImportYouTubeRequest,
|
data: ImportYouTubeRequest,
|
||||||
user: User = Depends(get_current_user),
|
token: str = Depends(oauth2_scheme),
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
):
|
||||||
# Free tier limit
|
user_id = _get_user_id_from_token(token)
|
||||||
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 Pro for unlimited.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fetch tracks from YouTube Music (run sync ytmusicapi in thread)
|
# Run sync ytmusicapi in a thread — NO DB connection open during this
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
try:
|
try:
|
||||||
playlist_name, playlist_image, raw_tracks = await loop.run_in_executor(
|
playlist_name, playlist_image, raw_tracks = await asyncio.to_thread(
|
||||||
None, partial(get_playlist_tracks, data.url)
|
get_playlist_tracks, data.url
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
@@ -67,52 +68,85 @@ async def import_youtube_playlist(
|
|||||||
if not raw_tracks:
|
if not raw_tracks:
|
||||||
raise HTTPException(status_code=400, detail="Playlist is empty or could not be read.")
|
raise HTTPException(status_code=400, detail="Playlist is empty or could not be read.")
|
||||||
|
|
||||||
# Create playlist
|
# Now do all DB work in a fresh session
|
||||||
playlist = Playlist(
|
async with async_session() as db:
|
||||||
user_id=user.id,
|
try:
|
||||||
name=playlist_name,
|
# Verify user exists
|
||||||
platform_source="youtube_music",
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
external_id=data.url,
|
user = result.scalar_one_or_none()
|
||||||
track_count=len(raw_tracks),
|
if not user:
|
||||||
)
|
raise HTTPException(status_code=401, detail="User not found")
|
||||||
db.add(playlist)
|
|
||||||
await db.flush()
|
|
||||||
|
|
||||||
# Create tracks (no audio features available from YouTube Music)
|
# Free tier limit
|
||||||
tracks = []
|
if not user.is_pro:
|
||||||
for rt in raw_tracks:
|
result = await db.execute(
|
||||||
track = Track(
|
select(Playlist).where(Playlist.user_id == user.id)
|
||||||
playlist_id=playlist.id,
|
)
|
||||||
title=rt["title"],
|
existing = list(result.scalars().all())
|
||||||
artist=rt["artist"],
|
if len(existing) >= settings.FREE_MAX_PLAYLISTS:
|
||||||
album=rt.get("album"),
|
raise HTTPException(
|
||||||
image_url=rt.get("image_url"),
|
status_code=403,
|
||||||
)
|
detail="Free tier limited to 1 playlist. Upgrade to Premium for unlimited.",
|
||||||
db.add(track)
|
)
|
||||||
tracks.append(track)
|
|
||||||
|
|
||||||
await db.flush()
|
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()
|
||||||
|
|
||||||
# Build taste profile (without audio features, will be limited)
|
tracks = []
|
||||||
playlist.taste_profile = build_taste_profile(tracks)
|
for rt in raw_tracks:
|
||||||
playlist.tracks = 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)
|
||||||
|
|
||||||
return playlist
|
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])
|
@router.post("/search", response_model=list[YouTubeTrackResult])
|
||||||
async def search_youtube_music(
|
async def search_youtube_music(
|
||||||
data: SearchYouTubeRequest,
|
data: SearchYouTubeRequest,
|
||||||
user: User = Depends(get_current_user),
|
token: str = Depends(oauth2_scheme),
|
||||||
):
|
):
|
||||||
|
_get_user_id_from_token(token) # Just verify auth
|
||||||
|
|
||||||
if not data.query.strip():
|
if not data.query.strip():
|
||||||
raise HTTPException(status_code=400, detail="Query cannot be empty")
|
raise HTTPException(status_code=400, detail="Query cannot be empty")
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
try:
|
try:
|
||||||
results = await loop.run_in_executor(
|
results = await asyncio.to_thread(search_track, data.query.strip())
|
||||||
None, partial(search_track, data.query.strip())
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
raise HTTPException(status_code=500, detail="Failed to search YouTube Music")
|
raise HTTPException(status_code=500, detail="Failed to search YouTube Music")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user