direct download via youtube links
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user