Add YouTube Music playlist import support
- Add ytmusicapi dependency for fetching public YouTube Music playlists - Create youtube_music service with playlist fetching and track search - Add /api/youtube-music/import and /api/youtube-music/search endpoints - Add importYouTubePlaylist and searchYouTubeMusic API client functions - Update Playlists page with YouTube Music import button and URL input modal
This commit is contained in:
110
backend/app/api/endpoints/youtube_music.py
Normal file
110
backend/app/api/endpoints/youtube_music.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
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"])
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@router.post("/import", response_model=PlaylistDetailResponse)
|
||||
async def import_youtube_playlist(
|
||||
data: ImportYouTubeRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# 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 Pro for unlimited.",
|
||||
)
|
||||
|
||||
# Fetch tracks from YouTube Music
|
||||
try:
|
||||
playlist_name, playlist_image, raw_tracks = 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.")
|
||||
|
||||
# Create playlist
|
||||
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()
|
||||
|
||||
# Create tracks (no audio features available from YouTube Music)
|
||||
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()
|
||||
|
||||
# Build taste profile (without audio features, will be limited)
|
||||
playlist.taste_profile = build_taste_profile(tracks)
|
||||
playlist.tracks = tracks
|
||||
|
||||
return playlist
|
||||
|
||||
|
||||
@router.post("/search", response_model=list[YouTubeTrackResult])
|
||||
async def search_youtube_music(
|
||||
data: SearchYouTubeRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
if not data.query.strip():
|
||||
raise HTTPException(status_code=400, detail="Query cannot be empty")
|
||||
|
||||
try:
|
||||
results = search_track(data.query.strip())
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Failed to search YouTube Music")
|
||||
|
||||
return results
|
||||
@@ -2,7 +2,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.core.config import settings
|
||||
from app.api.endpoints import auth, playlists, recommendations
|
||||
from app.api.endpoints import auth, billing, playlists, recommendations, youtube_music
|
||||
|
||||
app = FastAPI(title="Vynl API", version="1.0.0")
|
||||
|
||||
@@ -15,8 +15,10 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
app.include_router(auth.router, prefix="/api")
|
||||
app.include_router(billing.router, prefix="/api")
|
||||
app.include_router(playlists.router, prefix="/api")
|
||||
app.include_router(recommendations.router, prefix="/api")
|
||||
app.include_router(youtube_music.router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
87
backend/app/services/youtube_music.py
Normal file
87
backend/app/services/youtube_music.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import re
|
||||
|
||||
from ytmusicapi import YTMusic
|
||||
|
||||
ytmusic = YTMusic()
|
||||
|
||||
|
||||
def extract_playlist_id(url: str) -> str:
|
||||
"""Extract playlist ID from a YouTube Music playlist URL."""
|
||||
match = re.search(r"[?&]list=([A-Za-z0-9_-]+)", url)
|
||||
if not match:
|
||||
raise ValueError("Invalid YouTube Music playlist URL")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def get_playlist_tracks(playlist_url: str) -> tuple[str, str | None, list[dict]]:
|
||||
"""Fetch playlist info and tracks from a public YouTube Music playlist.
|
||||
|
||||
Returns (playlist_name, playlist_image_url, tracks).
|
||||
Each track dict has: title, artist, album, youtube_id, image_url.
|
||||
"""
|
||||
playlist_id = extract_playlist_id(playlist_url)
|
||||
playlist = ytmusic.get_playlist(playlist_id, limit=None)
|
||||
|
||||
name = playlist.get("title", playlist_id)
|
||||
image_url = None
|
||||
thumbnails = playlist.get("thumbnails")
|
||||
if thumbnails:
|
||||
image_url = thumbnails[-1].get("url")
|
||||
|
||||
tracks = []
|
||||
for item in playlist.get("tracks", []):
|
||||
artists = item.get("artists") or []
|
||||
artist_name = ", ".join(a.get("name", "") for a in artists if a.get("name"))
|
||||
|
||||
album_name = None
|
||||
album = item.get("album")
|
||||
if album:
|
||||
album_name = album.get("name")
|
||||
|
||||
thumb_url = None
|
||||
thumbs = item.get("thumbnails")
|
||||
if thumbs:
|
||||
thumb_url = thumbs[-1].get("url")
|
||||
|
||||
tracks.append({
|
||||
"title": item.get("title", "Unknown"),
|
||||
"artist": artist_name or "Unknown",
|
||||
"album": album_name,
|
||||
"youtube_id": item.get("videoId"),
|
||||
"image_url": thumb_url,
|
||||
})
|
||||
|
||||
return name, image_url, tracks
|
||||
|
||||
|
||||
def search_track(query: str) -> list[dict]:
|
||||
"""Search YouTube Music for tracks matching the query.
|
||||
|
||||
Returns a list of result dicts with: title, artist, album, youtube_id, image_url.
|
||||
"""
|
||||
results = ytmusic.search(query, filter="songs", limit=10)
|
||||
|
||||
tracks = []
|
||||
for item in results:
|
||||
artists = item.get("artists") or []
|
||||
artist_name = ", ".join(a.get("name", "") for a in artists if a.get("name"))
|
||||
|
||||
album_name = None
|
||||
album = item.get("album")
|
||||
if album:
|
||||
album_name = album.get("name")
|
||||
|
||||
thumb_url = None
|
||||
thumbs = item.get("thumbnails")
|
||||
if thumbs:
|
||||
thumb_url = thumbs[-1].get("url")
|
||||
|
||||
tracks.append({
|
||||
"title": item.get("title", "Unknown"),
|
||||
"artist": artist_name or "Unknown",
|
||||
"album": album_name,
|
||||
"youtube_id": item.get("videoId"),
|
||||
"image_url": thumb_url,
|
||||
})
|
||||
|
||||
return tracks
|
||||
@@ -14,4 +14,6 @@ celery==5.4.0
|
||||
httpx==0.28.1
|
||||
anthropic==0.42.0
|
||||
spotipy==2.24.0
|
||||
ytmusicapi==1.8.2
|
||||
python-dotenv==1.0.1
|
||||
stripe==11.4.1
|
||||
|
||||
Reference in New Issue
Block a user