170 lines
5.6 KiB
Python
170 lines
5.6 KiB
Python
|
|
import ipaddress
|
||
|
|
import socket
|
||
|
|
from collections.abc import Mapping
|
||
|
|
from typing import Any
|
||
|
|
from urllib.parse import urlsplit
|
||
|
|
|
||
|
|
import yt_dlp
|
||
|
|
from yt_dlp.utils import DownloadError
|
||
|
|
|
||
|
|
|
||
|
|
class UnsafeUrlError(ValueError):
|
||
|
|
"""Raised when a submitted URL is outside the permitted network boundary."""
|
||
|
|
|
||
|
|
|
||
|
|
class InspectionError(RuntimeError):
|
||
|
|
"""Raised when yt-dlp cannot inspect a permitted URL."""
|
||
|
|
|
||
|
|
|
||
|
|
def validate_public_url(url: str) -> str:
|
||
|
|
"""Validate the initial URL and require every resolved address to be public."""
|
||
|
|
try:
|
||
|
|
parsed = urlsplit(url)
|
||
|
|
port = parsed.port
|
||
|
|
except ValueError as error:
|
||
|
|
raise UnsafeUrlError("The URL is malformed.") from error
|
||
|
|
|
||
|
|
if parsed.scheme not in {"http", "https"}:
|
||
|
|
raise UnsafeUrlError("Only HTTP and HTTPS URLs are allowed.")
|
||
|
|
if not parsed.hostname:
|
||
|
|
raise UnsafeUrlError("The URL must include a hostname.")
|
||
|
|
if parsed.username is not None or parsed.password is not None:
|
||
|
|
raise UnsafeUrlError("Credentials are not allowed in source URLs.")
|
||
|
|
if port is not None and port not in {80, 443}:
|
||
|
|
raise UnsafeUrlError("Only the standard HTTP and HTTPS ports are allowed.")
|
||
|
|
|
||
|
|
try:
|
||
|
|
addresses = {
|
||
|
|
result[4][0]
|
||
|
|
for result in socket.getaddrinfo(
|
||
|
|
parsed.hostname,
|
||
|
|
port or (443 if parsed.scheme == "https" else 80),
|
||
|
|
type=socket.SOCK_STREAM,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
except socket.gaierror as error:
|
||
|
|
raise UnsafeUrlError("The source hostname could not be resolved.") from error
|
||
|
|
|
||
|
|
if not addresses:
|
||
|
|
raise UnsafeUrlError("The source hostname did not resolve to an address.")
|
||
|
|
|
||
|
|
for address in addresses:
|
||
|
|
try:
|
||
|
|
ip = ipaddress.ip_address(address)
|
||
|
|
except ValueError as error:
|
||
|
|
raise UnsafeUrlError("The source hostname returned an invalid address.") from error
|
||
|
|
if not ip.is_global:
|
||
|
|
raise UnsafeUrlError("The source hostname resolves to a non-public address.")
|
||
|
|
|
||
|
|
return url
|
||
|
|
|
||
|
|
|
||
|
|
def _format_summary(media_format: Mapping[str, Any]) -> dict[str, Any]:
|
||
|
|
filesize = media_format.get("filesize") or media_format.get("filesize_approx")
|
||
|
|
|
||
|
|
return {
|
||
|
|
"format_id": str(media_format.get("format_id") or "unknown"),
|
||
|
|
"extension": media_format.get("ext"),
|
||
|
|
"width": media_format.get("width"),
|
||
|
|
"height": media_format.get("height"),
|
||
|
|
"fps": media_format.get("fps"),
|
||
|
|
"video_codec": media_format.get("vcodec"),
|
||
|
|
"audio_codec": media_format.get("acodec"),
|
||
|
|
"filesize": filesize,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _normalized_formats(raw_formats: object) -> list[dict[str, Any]]:
|
||
|
|
formats: list[dict[str, Any]] = []
|
||
|
|
seen: set[tuple[object, ...]] = set()
|
||
|
|
|
||
|
|
if not isinstance(raw_formats, list):
|
||
|
|
return formats
|
||
|
|
|
||
|
|
for media_format in raw_formats:
|
||
|
|
if not isinstance(media_format, dict):
|
||
|
|
continue
|
||
|
|
if (
|
||
|
|
media_format.get("vcodec") in {None, "none"}
|
||
|
|
and media_format.get("acodec") in {None, "none"}
|
||
|
|
):
|
||
|
|
continue
|
||
|
|
|
||
|
|
summary = _format_summary(media_format)
|
||
|
|
identity = (
|
||
|
|
summary["extension"],
|
||
|
|
summary["width"],
|
||
|
|
summary["height"],
|
||
|
|
summary["fps"],
|
||
|
|
summary["video_codec"],
|
||
|
|
summary["audio_codec"],
|
||
|
|
)
|
||
|
|
if identity in seen:
|
||
|
|
continue
|
||
|
|
seen.add(identity)
|
||
|
|
formats.append(summary)
|
||
|
|
|
||
|
|
return formats
|
||
|
|
|
||
|
|
|
||
|
|
def _select_single_video(info: dict[str, Any]) -> dict[str, Any]:
|
||
|
|
entries = info.get("entries")
|
||
|
|
if entries is None and info.get("_type") not in {"playlist", "multi_video"}:
|
||
|
|
return info
|
||
|
|
|
||
|
|
videos = [entry for entry in entries or [] if isinstance(entry, dict)]
|
||
|
|
if len(videos) != 1:
|
||
|
|
raise InspectionError("Playlists and multi-video sources are not supported.")
|
||
|
|
|
||
|
|
video = dict(videos[0])
|
||
|
|
for field in ("title", "uploader", "channel", "thumbnail", "webpage_url"):
|
||
|
|
if not video.get(field) and info.get(field):
|
||
|
|
video[field] = info[field]
|
||
|
|
return video
|
||
|
|
|
||
|
|
|
||
|
|
def inspect_url(url: str) -> dict[str, Any]:
|
||
|
|
"""Inspect one public video URL without downloading its media."""
|
||
|
|
safe_url = validate_public_url(url)
|
||
|
|
options: dict[str, Any] = {
|
||
|
|
"extract_flat": False,
|
||
|
|
"extractor_retries": 1,
|
||
|
|
"ignoreconfig": True,
|
||
|
|
"noplaylist": True,
|
||
|
|
"quiet": True,
|
||
|
|
"retries": 1,
|
||
|
|
"skip_download": True,
|
||
|
|
"socket_timeout": 15,
|
||
|
|
"no_warnings": True,
|
||
|
|
}
|
||
|
|
|
||
|
|
try:
|
||
|
|
with yt_dlp.YoutubeDL(options) as downloader:
|
||
|
|
raw_info = downloader.extract_info(safe_url, download=False)
|
||
|
|
info = downloader.sanitize_info(raw_info)
|
||
|
|
except DownloadError as error:
|
||
|
|
raise InspectionError("yt-dlp could not inspect this URL.") from error
|
||
|
|
except Exception as error:
|
||
|
|
raise InspectionError("The source inspection failed unexpectedly.") from error
|
||
|
|
|
||
|
|
if not isinstance(info, dict):
|
||
|
|
raise InspectionError("The source returned an unsupported response.")
|
||
|
|
|
||
|
|
info = _select_single_video(info)
|
||
|
|
|
||
|
|
formats = _normalized_formats(info.get("formats"))
|
||
|
|
|
||
|
|
return {
|
||
|
|
"id": str(info.get("id") or "unknown"),
|
||
|
|
"title": info.get("title") or "Untitled",
|
||
|
|
"extractor": info.get("extractor_key") or info.get("extractor"),
|
||
|
|
"uploader": info.get("uploader") or info.get("channel"),
|
||
|
|
"duration": info.get("duration"),
|
||
|
|
"upload_date": info.get("upload_date"),
|
||
|
|
"source_url": safe_url,
|
||
|
|
"webpage_url": info.get("webpage_url") or safe_url,
|
||
|
|
"thumbnail": info.get("thumbnail"),
|
||
|
|
"is_live": bool(info.get("is_live")),
|
||
|
|
"formats": formats,
|
||
|
|
}
|