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 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")
|
||||||
|
|||||||
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
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user