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:
root
2026-03-30 21:33:27 -05:00
parent cd88ed2983
commit 58c17498be
6 changed files with 339 additions and 18 deletions

View 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

View File

@@ -2,7 +2,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings 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") 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(auth.router, prefix="/api")
app.include_router(billing.router, prefix="/api")
app.include_router(playlists.router, prefix="/api") app.include_router(playlists.router, prefix="/api")
app.include_router(recommendations.router, prefix="/api") app.include_router(recommendations.router, prefix="/api")
app.include_router(youtube_music.router, prefix="/api")
@app.get("/api/health") @app.get("/api/health")

View 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

View File

@@ -14,4 +14,6 @@ celery==5.4.0
httpx==0.28.1 httpx==0.28.1
anthropic==0.42.0 anthropic==0.42.0
spotipy==2.24.0 spotipy==2.24.0
ytmusicapi==1.8.2
python-dotenv==1.0.1 python-dotenv==1.0.1
stripe==11.4.1

View File

@@ -156,4 +156,35 @@ export const getSavedRecommendations = () =>
export const toggleSaveRecommendation = (id: string) => export const toggleSaveRecommendation = (id: string) =>
api.post<{ saved: boolean }>(`/recommendations/${id}/toggle-save`).then((r) => r.data) api.post<{ saved: boolean }>(`/recommendations/${id}/toggle-save`).then((r) => r.data)
// YouTube Music Import
export interface YouTubeTrackResult {
title: string
artist: string
album: string | null
youtube_id: string | null
image_url: string | null
}
export const importYouTubePlaylist = (url: string) =>
api.post<PlaylistDetailResponse>('/youtube-music/import', { url }).then((r) => r.data)
export const searchYouTubeMusic = (query: string) =>
api.post<YouTubeTrackResult[]>('/youtube-music/search', { query }).then((r) => r.data)
// Billing
export interface BillingStatusResponse {
is_pro: boolean
subscription_status: string | null
current_period_end: number | null
}
export const createCheckout = () =>
api.post<{ url: string }>('/billing/create-checkout').then((r) => r.data)
export const createBillingPortal = () =>
api.post<{ url: string }>('/billing/portal').then((r) => r.data)
export const getBillingStatus = () =>
api.get<BillingStatusResponse>('/billing/status').then((r) => r.data)
export default api export default api

View File

@@ -1,12 +1,15 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { ListMusic, Plus, Loader2, Music, ChevronRight, Download, X } from 'lucide-react' import { ListMusic, Plus, Loader2, Music, ChevronRight, Download, X, Youtube, Link2 } from 'lucide-react'
import { getPlaylists, getSpotifyPlaylists, importSpotifyPlaylist, type PlaylistResponse, type SpotifyPlaylistItem } from '../lib/api' import { getPlaylists, getSpotifyPlaylists, importSpotifyPlaylist, importYouTubePlaylist, type PlaylistResponse, type SpotifyPlaylistItem } from '../lib/api'
export default function Playlists() { export default function Playlists() {
const [playlists, setPlaylists] = useState<PlaylistResponse[]>([]) const [playlists, setPlaylists] = useState<PlaylistResponse[]>([])
const [spotifyPlaylists, setSpotifyPlaylists] = useState<SpotifyPlaylistItem[]>([]) const [spotifyPlaylists, setSpotifyPlaylists] = useState<SpotifyPlaylistItem[]>([])
const [showImport, setShowImport] = useState(false) const [showImport, setShowImport] = useState(false)
const [showYouTubeImport, setShowYouTubeImport] = useState(false)
const [youtubeUrl, setYoutubeUrl] = useState('')
const [importingYouTube, setImportingYouTube] = useState(false)
const [importing, setImporting] = useState<string | null>(null) const [importing, setImporting] = useState<string | null>(null)
const [loadingSpotify, setLoadingSpotify] = useState(false) const [loadingSpotify, setLoadingSpotify] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -40,6 +43,22 @@ export default function Playlists() {
} }
} }
const handleYouTubeImport = async () => {
if (!youtubeUrl.trim()) return
setImportingYouTube(true)
setError('')
try {
const imported = await importYouTubePlaylist(youtubeUrl.trim())
setPlaylists((prev) => [...prev, imported])
setYoutubeUrl('')
setShowYouTubeImport(false)
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to import YouTube Music playlist')
} finally {
setImportingYouTube(false)
}
}
const handleImport = async (playlistId: string) => { const handleImport = async (playlistId: string) => {
setImporting(playlistId) setImporting(playlistId)
try { try {
@@ -68,13 +87,22 @@ export default function Playlists() {
<h1 className="text-3xl font-bold text-charcoal">Playlists</h1> <h1 className="text-3xl font-bold text-charcoal">Playlists</h1>
<p className="text-charcoal-muted mt-1">Manage your imported playlists</p> <p className="text-charcoal-muted mt-1">Manage your imported playlists</p>
</div> </div>
<button <div className="flex items-center gap-2">
onClick={openImportModal} <button
className="flex items-center gap-2 px-5 py-2.5 bg-purple text-white font-medium rounded-xl hover:bg-purple-dark transition-colors cursor-pointer border-none text-sm" onClick={openImportModal}
> className="flex items-center gap-2 px-5 py-2.5 bg-purple text-white font-medium rounded-xl hover:bg-purple-dark transition-colors cursor-pointer border-none text-sm"
<Plus className="w-4 h-4" /> >
Import from Spotify <Plus className="w-4 h-4" />
</button> Import from Spotify
</button>
<button
onClick={() => setShowYouTubeImport(true)}
className="flex items-center gap-2 px-5 py-2.5 bg-red-600 text-white font-medium rounded-xl hover:bg-red-700 transition-colors cursor-pointer border-none text-sm"
>
<Youtube className="w-4 h-4" />
Import from YouTube Music
</button>
</div>
</div> </div>
{error && ( {error && (
@@ -91,15 +119,24 @@ export default function Playlists() {
</div> </div>
<h2 className="text-xl font-semibold text-charcoal mb-2">No playlists yet</h2> <h2 className="text-xl font-semibold text-charcoal mb-2">No playlists yet</h2>
<p className="text-charcoal-muted mb-6 max-w-md mx-auto"> <p className="text-charcoal-muted mb-6 max-w-md mx-auto">
Import your Spotify playlists to start getting personalized music recommendations Import your playlists from Spotify or YouTube Music to start getting personalized music recommendations
</p> </p>
<button <div className="flex items-center justify-center gap-3">
onClick={openImportModal} <button
className="inline-flex items-center gap-2 px-6 py-3 bg-purple text-white font-medium rounded-xl hover:bg-purple-dark transition-colors cursor-pointer border-none text-sm" onClick={openImportModal}
> className="inline-flex items-center gap-2 px-6 py-3 bg-purple text-white font-medium rounded-xl hover:bg-purple-dark transition-colors cursor-pointer border-none text-sm"
<Download className="w-4 h-4" /> >
Import your first playlist <Download className="w-4 h-4" />
</button> Import from Spotify
</button>
<button
onClick={() => setShowYouTubeImport(true)}
className="inline-flex items-center gap-2 px-6 py-3 bg-red-600 text-white font-medium rounded-xl hover:bg-red-700 transition-colors cursor-pointer border-none text-sm"
>
<Youtube className="w-4 h-4" />
Import from YouTube Music
</button>
</div>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
@@ -139,6 +176,58 @@ export default function Playlists() {
</div> </div>
)} )}
{/* YouTube Music Import Modal */}
{showYouTubeImport && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl max-w-lg w-full overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-purple-100">
<h2 className="text-lg font-semibold text-charcoal">Import from YouTube Music</h2>
<button
onClick={() => setShowYouTubeImport(false)}
className="p-1.5 rounded-lg hover:bg-purple-50 transition-colors cursor-pointer bg-transparent border-none"
>
<X className="w-5 h-5 text-charcoal-muted" />
</button>
</div>
<div className="p-6 space-y-4">
<p className="text-sm text-charcoal-muted">
Paste a public YouTube Music playlist URL to import its tracks.
</p>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Link2 className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-charcoal-muted/50" />
<input
type="url"
value={youtubeUrl}
onChange={(e) => setYoutubeUrl(e.target.value)}
placeholder="https://music.youtube.com/playlist?list=..."
className="w-full pl-10 pr-4 py-3 bg-cream/50 border border-purple-100 rounded-xl text-charcoal placeholder:text-charcoal-muted/40 focus:outline-none focus:ring-2 focus:ring-purple/30 focus:border-purple transition-colors text-sm"
/>
</div>
</div>
<button
onClick={handleYouTubeImport}
disabled={importingYouTube || !youtubeUrl.trim()}
className="w-full py-3 bg-red-600 text-white font-medium rounded-xl hover:bg-red-700 transition-colors cursor-pointer border-none text-sm disabled:opacity-50 flex items-center justify-center gap-2"
>
{importingYouTube ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Importing...
</>
) : (
<>
<Download className="w-4 h-4" />
Import Playlist
</>
)}
</button>
</div>
</div>
</div>
)}
{/* Import Modal */} {/* Import Modal */}
{showImport && ( {showImport && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">