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]