direct download via youtube links

This commit is contained in:
2026-07-22 09:53:27 -04:00
parent 518b48e395
commit e5643590ee
4 changed files with 106 additions and 2 deletions

View File

@@ -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")

View File

@@ -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(

View File

@@ -17,6 +17,12 @@ export async function searchYouTube(query: string): Promise<YouTubeResult[]> {
return res.json();
}
export async function resolveYoutubeUrl(url: string): Promise<YouTubeResult> {
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',

View File

@@ -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<DownloadStatus | null>(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) {
))}
</ul>
{/* Paste a YouTube URL directly */}
<p className="section-label">Or paste a YouTube URL</p>
<form onSubmit={handleResolveUrl} className="search-form">
<div className="search-input-wrap">
<Link2 size={18} className="search-icon" />
<input
className="search-input"
type="text"
placeholder="https://www.youtube.com/watch?v=…"
value={urlInput}
onChange={e => setUrlInput(e.target.value)}
/>
</div>
<button className="btn-primary" type="submit" disabled={resolving}>
{resolving ? <Loader2 size={16} className="spin" /> : 'Load'}
</button>
</form>
{urlError && <p className="error-msg">{urlError}</p>}
{/* Embedded player */}
{selectedVideo && (
<div className="yt-player-wrap">