MusicUtils / youtube.py
sathvik77's picture
feat(backend): yt-dlp metadata + audio download wrappers
53e89b8
Raw
History Blame Contribute Delete
3.47 kB
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]