Spaces:
Running
Running
| import re | |
| from urllib.parse import urlparse, parse_qs | |
| from pathlib import Path | |
| from typing import TypedDict | |
| import yt_dlp | |
| from yt_dlp.utils import DownloadError | |
| class InvalidYoutubeUrl(ValueError): | |
| pass | |
| class YoutubeFetchError(RuntimeError): | |
| pass | |
| class VideoTooLong(ValueError): | |
| pass | |
| class YoutubeMetadata(TypedDict): | |
| youtube_id: str | |
| title: str | |
| artist: str | |
| duration_sec: int | |
| thumbnail_url: str | |
| _ID_RE = re.compile(r"^[A-Za-z0-9_-]{11}$") | |
| def extract_youtube_id(url: str) -> str: | |
| if not url or not isinstance(url, str): | |
| raise InvalidYoutubeUrl(url) | |
| try: | |
| parsed = urlparse(url.strip()) | |
| except ValueError as e: | |
| raise InvalidYoutubeUrl(url) from e | |
| host = (parsed.hostname or "").lower() | |
| if host == "youtu.be": | |
| candidate = parsed.path.lstrip("/").split("/")[0] | |
| elif host in {"www.youtube.com", "youtube.com", "m.youtube.com", "music.youtube.com"}: | |
| if parsed.path == "/watch": | |
| qs = parse_qs(parsed.query) | |
| candidate = (qs.get("v") or [""])[0] | |
| elif parsed.path.startswith("/shorts/") or parsed.path.startswith("/embed/"): | |
| candidate = parsed.path.split("/")[2] if len(parsed.path.split("/")) > 2 else "" | |
| else: | |
| candidate = "" | |
| else: | |
| raise InvalidYoutubeUrl(url) | |
| if not _ID_RE.match(candidate): | |
| raise InvalidYoutubeUrl(url) | |
| return candidate | |
| def _parse_artist_title(info: dict) -> tuple[str, str]: | |
| artist = info.get("artist") or info.get("uploader") or "Unknown" | |
| title = info.get("title") or "Untitled" | |
| # If title looks like "Artist - Song", prefer that artist over uploader. | |
| if " - " in title and not info.get("artist"): | |
| head, tail = title.split(" - ", 1) | |
| if len(head) < 60 and len(tail) < 120: | |
| return head.strip(), tail.strip() | |
| return artist, title | |
| def fetch_metadata(url: str, max_duration_sec: int = 600) -> YoutubeMetadata: | |
| opts = {"quiet": True, "no_warnings": True, "skip_download": True} | |
| try: | |
| with yt_dlp.YoutubeDL(opts) as ydl: | |
| info = ydl.extract_info(url, download=False) | |
| except DownloadError as e: | |
| raise YoutubeFetchError(str(e)) from e | |
| duration = int(info.get("duration") or 0) | |
| if duration <= 0: | |
| raise YoutubeFetchError("missing duration") | |
| if duration > max_duration_sec: | |
| raise VideoTooLong(f"{duration}s exceeds {max_duration_sec}s") | |
| artist, title = _parse_artist_title(info) | |
| return { | |
| "youtube_id": info["id"], | |
| "title": title, | |
| "artist": artist, | |
| "duration_sec": duration, | |
| "thumbnail_url": info.get("thumbnail") or f"https://i.ytimg.com/vi/{info['id']}/hqdefault.jpg", | |
| } | |
| def download_audio(url: str, youtube_id: str, out_dir: Path) -> Path: | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| out_tmpl = str(out_dir / f"{youtube_id}.%(ext)s") | |
| opts = { | |
| "format": "bestaudio[ext=m4a]/bestaudio", | |
| "outtmpl": out_tmpl, | |
| "quiet": True, | |
| "no_warnings": True, | |
| "noplaylist": True, | |
| } | |
| try: | |
| with yt_dlp.YoutubeDL(opts) as ydl: | |
| ydl.download([url]) | |
| except DownloadError as e: | |
| raise YoutubeFetchError(str(e)) from e | |
| # yt-dlp picks the actual extension; find what landed. | |
| matches = list(out_dir.glob(f"{youtube_id}.*")) | |
| if not matches: | |
| raise YoutubeFetchError("download produced no file") | |
| return matches[0] | |