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:
@@ -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<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
|
||||
|
||||
@@ -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<PlaylistResponse[]>([])
|
||||
const [spotifyPlaylists, setSpotifyPlaylists] = useState<SpotifyPlaylistItem[]>([])
|
||||
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 [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() {
|
||||
<h1 className="text-3xl font-bold text-charcoal">Playlists</h1>
|
||||
<p className="text-charcoal-muted mt-1">Manage your imported playlists</p>
|
||||
</div>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
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
|
||||
</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>
|
||||
|
||||
{error && (
|
||||
@@ -91,15 +119,24 @@ export default function Playlists() {
|
||||
</div>
|
||||
<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">
|
||||
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>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<button
|
||||
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 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 className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
@@ -139,6 +176,58 @@ export default function Playlists() {
|
||||
</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 */}
|
||||
{showImport && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
|
||||
Reference in New Issue
Block a user