From 58c17498bee145f7b3a3e8fb9b34c1d41f42f4ba Mon Sep 17 00:00:00 2001 From: root Date: Mon, 30 Mar 2026 21:33:27 -0500 Subject: [PATCH] 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 --- backend/app/api/endpoints/youtube_music.py | 110 ++++++++++++++++++ backend/app/main.py | 4 +- backend/app/services/youtube_music.py | 87 +++++++++++++++ backend/requirements.txt | 2 + frontend/src/lib/api.ts | 31 ++++++ frontend/src/pages/Playlists.tsx | 123 ++++++++++++++++++--- 6 files changed, 339 insertions(+), 18 deletions(-) create mode 100644 backend/app/api/endpoints/youtube_music.py create mode 100644 backend/app/services/youtube_music.py diff --git a/backend/app/api/endpoints/youtube_music.py b/backend/app/api/endpoints/youtube_music.py new file mode 100644 index 0000000..44e7889 --- /dev/null +++ b/backend/app/api/endpoints/youtube_music.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index c38df20..e97a8dc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/services/youtube_music.py b/backend/app/services/youtube_music.py new file mode 100644 index 0000000..344d77e --- /dev/null +++ b/backend/app/services/youtube_music.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt index 513c02b..74fd460 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index aa1cebb..4613e04 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -156,4 +156,35 @@ export const getSavedRecommendations = () => export const toggleSaveRecommendation = (id: string) => 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('/youtube-music/import', { url }).then((r) => r.data) + +export const searchYouTubeMusic = (query: string) => + api.post('/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('/billing/status').then((r) => r.data) + export default api diff --git a/frontend/src/pages/Playlists.tsx b/frontend/src/pages/Playlists.tsx index 1b910d9..9d53f39 100644 --- a/frontend/src/pages/Playlists.tsx +++ b/frontend/src/pages/Playlists.tsx @@ -1,12 +1,15 @@ import { useState, useEffect } from 'react' import { Link } from 'react-router-dom' -import { ListMusic, Plus, Loader2, Music, ChevronRight, Download, X } from 'lucide-react' -import { getPlaylists, getSpotifyPlaylists, importSpotifyPlaylist, type PlaylistResponse, type SpotifyPlaylistItem } from '../lib/api' +import { ListMusic, Plus, Loader2, Music, ChevronRight, Download, X, Youtube, Link2 } from 'lucide-react' +import { getPlaylists, getSpotifyPlaylists, importSpotifyPlaylist, importYouTubePlaylist, type PlaylistResponse, type SpotifyPlaylistItem } from '../lib/api' export default function Playlists() { const [playlists, setPlaylists] = useState([]) const [spotifyPlaylists, setSpotifyPlaylists] = useState([]) const [showImport, setShowImport] = useState(false) + const [showYouTubeImport, setShowYouTubeImport] = useState(false) + const [youtubeUrl, setYoutubeUrl] = useState('') + const [importingYouTube, setImportingYouTube] = useState(false) const [importing, setImporting] = useState(null) const [loadingSpotify, setLoadingSpotify] = useState(false) 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) => { setImporting(playlistId) try { @@ -68,13 +87,22 @@ export default function Playlists() {

Playlists

Manage your imported playlists

- +
+ + +
{error && ( @@ -91,15 +119,24 @@ export default function Playlists() {

No playlists yet

- Import your Spotify playlists to start getting personalized music recommendations + Import your playlists from Spotify or YouTube Music to start getting personalized music recommendations

- +
+ + +
) : (
@@ -139,6 +176,58 @@ export default function Playlists() {
)} + {/* YouTube Music Import Modal */} + {showYouTubeImport && ( +
+
+
+

Import from YouTube Music

+ +
+ +
+

+ Paste a public YouTube Music playlist URL to import its tracks. +

+
+
+ + 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" + /> +
+
+ +
+
+
+ )} + {/* Import Modal */} {showImport && (