Add inline YouTube player on recommendation cards, fetch real video IDs via ytmusicapi
This commit is contained in:
@@ -237,17 +237,44 @@ Return ONLY the JSON array, no other text."""
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return [], remaining
|
return [], remaining
|
||||||
|
|
||||||
|
# Look up YouTube video IDs for embeddable players
|
||||||
|
import asyncio
|
||||||
|
from app.services.youtube_music import search_track as yt_search
|
||||||
|
|
||||||
|
def get_youtube_links(tracks: list[dict]) -> list[dict]:
|
||||||
|
"""Get YouTube video IDs for a batch of tracks (sync, runs in thread)."""
|
||||||
|
results = []
|
||||||
|
for t in tracks:
|
||||||
|
artist = t.get("artist", "")
|
||||||
|
title = t.get("title", "")
|
||||||
|
try:
|
||||||
|
yt_results = yt_search(f"{artist} {title}")
|
||||||
|
if yt_results and yt_results[0].get("youtube_id"):
|
||||||
|
yt = yt_results[0]
|
||||||
|
results.append({
|
||||||
|
"youtube_url": f"https://music.youtube.com/watch?v={yt['youtube_id']}",
|
||||||
|
"image_url": yt.get("image_url"),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
results.append({
|
||||||
|
"youtube_url": f"https://music.youtube.com/search?q={quote_plus(f'{artist} {title}')}",
|
||||||
|
"image_url": None,
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
# Get YouTube data BEFORE any DB operations (avoids greenlet conflict)
|
||||||
|
tracks_to_lookup = [{"artist": r.get("artist", ""), "title": r.get("title", "")} for r in recs_data[:count]]
|
||||||
|
yt_data = await asyncio.to_thread(get_youtube_links, tracks_to_lookup)
|
||||||
|
|
||||||
# Save to DB with YouTube Music links
|
# Save to DB with YouTube Music links
|
||||||
recommendations = []
|
recommendations = []
|
||||||
for rec in recs_data:
|
for i, rec in enumerate(recs_data[:count]):
|
||||||
if len(recommendations) >= count:
|
|
||||||
break
|
|
||||||
|
|
||||||
artist = rec.get("artist", "Unknown")
|
artist = rec.get("artist", "Unknown")
|
||||||
title = rec.get("title", "Unknown")
|
title = rec.get("title", "Unknown")
|
||||||
reason = rec.get("reason", "")
|
reason = rec.get("reason", "")
|
||||||
|
yt = yt_data[i] if i < len(yt_data) else {}
|
||||||
youtube_url = f"https://music.youtube.com/search?q={quote_plus(f'{artist} {title}')}"
|
|
||||||
|
|
||||||
r = Recommendation(
|
r = Recommendation(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
@@ -255,10 +282,11 @@ Return ONLY the JSON array, no other text."""
|
|||||||
title=title,
|
title=title,
|
||||||
artist=artist,
|
artist=artist,
|
||||||
album=rec.get("album"),
|
album=rec.get("album"),
|
||||||
|
image_url=yt.get("image_url"),
|
||||||
reason=reason,
|
reason=reason,
|
||||||
score=rec.get("score"),
|
score=rec.get("score"),
|
||||||
query=query,
|
query=query,
|
||||||
youtube_url=youtube_url,
|
youtube_url=yt.get("youtube_url", f"https://music.youtube.com/search?q={quote_plus(f'{artist} {title}')}"),
|
||||||
)
|
)
|
||||||
db.add(r)
|
db.add(r)
|
||||||
recommendations.append(r)
|
recommendations.append(r)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Heart, ExternalLink, Music, ThumbsDown, Repeat, Share2, Check, Calendar, MapPin, Ticket, ChevronDown, ChevronUp } from 'lucide-react'
|
import { Heart, ExternalLink, Music, ThumbsDown, Repeat, Share2, Check, Calendar, MapPin, Ticket, ChevronUp, Play, X } from 'lucide-react'
|
||||||
import type { RecommendationItem, ConcertEvent } from '../lib/api'
|
import type { RecommendationItem, ConcertEvent } from '../lib/api'
|
||||||
import { shareRecommendation, findConcerts } from '../lib/api'
|
import { shareRecommendation, findConcerts } from '../lib/api'
|
||||||
|
|
||||||
@@ -19,6 +19,16 @@ export default function RecommendationCard({ recommendation, onToggleSave, onDis
|
|||||||
const [concertsOpen, setConcertsOpen] = useState(false)
|
const [concertsOpen, setConcertsOpen] = useState(false)
|
||||||
const [concerts, setConcerts] = useState<ConcertEvent[] | null>(null)
|
const [concerts, setConcerts] = useState<ConcertEvent[] | null>(null)
|
||||||
const [concertsLoading, setConcertsLoading] = useState(false)
|
const [concertsLoading, setConcertsLoading] = useState(false)
|
||||||
|
const [playerOpen, setPlayerOpen] = useState(false)
|
||||||
|
|
||||||
|
// Extract YouTube video ID from URL if it's a direct link
|
||||||
|
const getYouTubeVideoId = (url: string | null): string | null => {
|
||||||
|
if (!url) return null
|
||||||
|
const match = url.match(/[?&]v=([a-zA-Z0-9_-]{11})/)
|
||||||
|
return match ? match[1] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoId = getYouTubeVideoId(recommendation.youtube_url)
|
||||||
|
|
||||||
const handleMoreLikeThis = () => {
|
const handleMoreLikeThis = () => {
|
||||||
const q = `find songs similar to ${recommendation.artist} - ${recommendation.title}`
|
const q = `find songs similar to ${recommendation.artist} - ${recommendation.title}`
|
||||||
@@ -191,19 +201,49 @@ export default function RecommendationCard({ recommendation, onToggleSave, onDis
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{recommendation.youtube_url && (
|
{recommendation.youtube_url && (
|
||||||
<a
|
videoId ? (
|
||||||
href={recommendation.youtube_url}
|
<button
|
||||||
target="_blank"
|
onClick={() => setPlayerOpen(!playerOpen)}
|
||||||
rel="noopener noreferrer"
|
className={`p-2 rounded-full transition-colors cursor-pointer border-none ${
|
||||||
className="p-2 rounded-full bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
|
playerOpen
|
||||||
title="Watch on YouTube"
|
? 'bg-red-100 text-red-700'
|
||||||
>
|
: 'bg-red-50 text-red-600 hover:bg-red-100'
|
||||||
<ExternalLink className="w-4 h-4" />
|
}`}
|
||||||
</a>
|
title={playerOpen ? 'Close player' : 'Play'}
|
||||||
|
>
|
||||||
|
{playerOpen ? <X className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={recommendation.youtube_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="p-2 rounded-full bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
|
||||||
|
title="Search on YouTube Music"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* YouTube Player */}
|
||||||
|
{playerOpen && videoId && (
|
||||||
|
<div className="border-t border-purple-50 bg-charcoal">
|
||||||
|
<iframe
|
||||||
|
width="100%"
|
||||||
|
height="152"
|
||||||
|
src={`https://www.youtube.com/embed/${videoId}?autoplay=1`}
|
||||||
|
title={`${recommendation.artist} - ${recommendation.title}`}
|
||||||
|
frameBorder="0"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowFullScreen
|
||||||
|
className="block"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Concert section */}
|
{/* Concert section */}
|
||||||
{concertsOpen && (
|
{concertsOpen && (
|
||||||
<div className="border-t border-purple-50 px-5 py-3 bg-orange-50/30">
|
<div className="border-t border-purple-50 px-5 py-3 bg-orange-50/30">
|
||||||
|
|||||||
Reference in New Issue
Block a user