From e5643590ee04e4280e37c12e71f7aae11515f626 Mon Sep 17 00:00:00 2001 From: jerick Date: Wed, 22 Jul 2026 09:53:27 -0400 Subject: [PATCH] direct download via youtube links --- backend/main.py | 11 ++++++ backend/youtube.py | 44 ++++++++++++++++++++++ frontend/src/api/client.ts | 6 +++ frontend/src/components/YouTubePanel.tsx | 47 +++++++++++++++++++++++- 4 files changed, 106 insertions(+), 2 deletions(-) diff --git a/backend/main.py b/backend/main.py index 89f6e96..fac4330 100644 --- a/backend/main.py +++ b/backend/main.py @@ -54,6 +54,17 @@ async def search_youtube(q: str = Query(..., min_length=1)) -> list[dict[str, An raise HTTPException(status_code=502, detail=f"YouTube search failed: {exc}") +@app.get("/api/youtube/resolve") +async def resolve_youtube(url: str = Query(..., min_length=1)) -> dict[str, Any]: + """Resolve a pasted YouTube URL to video metadata (no download).""" + try: + return await youtube.resolve_youtube_url(url) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"Couldn't load that video: {exc}") + + # ── Download ────────────────────────────────────────────────────────────────── @app.post("/api/download") diff --git a/backend/youtube.py b/backend/youtube.py index 4bc9e73..dc25cf8 100644 --- a/backend/youtube.py +++ b/backend/youtube.py @@ -65,6 +65,50 @@ def _format_duration(seconds: int | None) -> str: return f"{m}:{s:02d}" +# ── Resolve pasted URL ─────────────────────────────────────────────────────── + +_VIDEO_ID_RE = re.compile( + r"(?:youtube(?:-nocookie)?\.com/(?:watch\?(?:.*&)?v=|embed/|shorts/|v/)|youtu\.be/)" + r"([A-Za-z0-9_-]{11})" +) +_BARE_ID_RE = re.compile(r"^[A-Za-z0-9_-]{11}$") + + +def extract_video_id(url: str) -> str | None: + """Pull an 11-char YouTube video ID out of a pasted URL (or bare ID).""" + url = url.strip() + if _BARE_ID_RE.match(url): + return url + match = _VIDEO_ID_RE.search(url) + return match.group(1) if match else None + + +async def resolve_youtube_url(url: str) -> dict[str, Any]: + """Fetch metadata for a single pasted YouTube URL, without downloading it.""" + video_id = extract_video_id(url) + if not video_id: + raise ValueError("Couldn't find a YouTube video ID in that URL.") + + watch_url = f"https://www.youtube.com/watch?v={video_id}" + ydl_opts = {"quiet": True, "no_warnings": True, "skip_download": True} + loop = asyncio.get_event_loop() + entry = await loop.run_in_executor(None, _yt_resolve, watch_url, ydl_opts) + + return { + "videoId": video_id, + "title": entry.get("title", ""), + "channel": entry.get("uploader") or entry.get("channel", ""), + "duration": _format_duration(entry.get("duration")), + "thumbnailUrl": f"https://i.ytimg.com/vi/{video_id}/mqdefault.jpg", + "watchUrl": watch_url, + } + + +def _yt_resolve(url: str, opts: dict) -> dict[str, Any]: + with yt_dlp.YoutubeDL(opts) as ydl: + return ydl.extract_info(url, download=False) + + # ── Download ────────────────────────────────────────────────────────────────── async def start_download( diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e785cfa..2615cf7 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -17,6 +17,12 @@ export async function searchYouTube(query: string): Promise { return res.json(); } +export async function resolveYoutubeUrl(url: string): Promise { + const res = await fetch(`${BASE}/youtube/resolve?url=${encodeURIComponent(url)}`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + export async function startDownload(req: DownloadRequest): Promise<{ jobId: string }> { const res = await fetch(`${BASE}/download`, { method: 'POST', diff --git a/frontend/src/components/YouTubePanel.tsx b/frontend/src/components/YouTubePanel.tsx index 541865d..edf3079 100644 --- a/frontend/src/components/YouTubePanel.tsx +++ b/frontend/src/components/YouTubePanel.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -import { Loader2, Play, CheckCircle2, Youtube } from 'lucide-react'; -import { searchYouTube, startDownload, getDownloadStatus } from '../api/client'; +import { Loader2, Play, CheckCircle2, Youtube, Link2 } from 'lucide-react'; +import { searchYouTube, resolveYoutubeUrl, startDownload, getDownloadStatus } from '../api/client'; import type { MusicResult, YouTubeResult, DownloadStatus } from '../api/types'; interface Props { @@ -17,6 +17,10 @@ export default function YouTubePanel({ track, onDownloadDone }: Props) { const [jobStatus, setJobStatus] = useState(null); const [downloading, setDownloading] = useState(false); + const [urlInput, setUrlInput] = useState(''); + const [resolving, setResolving] = useState(false); + const [urlError, setUrlError] = useState(''); + // Search YouTube when track changes useEffect(() => { setResults([]); @@ -25,6 +29,8 @@ export default function YouTubePanel({ track, onDownloadDone }: Props) { setJobStatus(null); setLoading(true); setError(''); + setUrlInput(''); + setUrlError(''); const query = `${track.artistName} ${track.trackName} official audio`; searchYouTube(query) @@ -54,6 +60,24 @@ export default function YouTubePanel({ track, onDownloadDone }: Props) { return () => clearInterval(interval); }, [jobId]); + async function handleResolveUrl(e: React.FormEvent) { + e.preventDefault(); + if (!urlInput.trim()) return; + setResolving(true); + setUrlError(''); + try { + const video = await resolveYoutubeUrl(urlInput.trim()); + setSelectedVideo(video); + setJobId(null); + setJobStatus(null); + setUrlInput(''); + } catch { + setUrlError("Couldn't load that URL — check it's a valid YouTube link."); + } finally { + setResolving(false); + } + } + async function handleDownload() { if (!selectedVideo) return; setDownloading(true); @@ -127,6 +151,25 @@ export default function YouTubePanel({ track, onDownloadDone }: Props) { ))} + {/* Paste a YouTube URL directly */} +

Or paste a YouTube URL

+
+
+ + setUrlInput(e.target.value)} + /> +
+ +
+ {urlError &&

{urlError}

} + {/* Embedded player */} {selectedVideo && (