108 lines
3.7 KiB
Python
108 lines
3.7 KiB
Python
import json
|
|
import re
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
VIDEO_ID_PATTERN = re.compile(r"^[\w-]{11}$")
|
|
YOUTUBE_HOSTS = {"youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com", "youtu.be"}
|
|
PATH_PREFIXES = ("/shorts/", "/embed/", "/live/")
|
|
PLAYER_RESPONSE_MARKER = "ytInitialPlayerResponse = "
|
|
TOPIC_SUFFIX = " - Topic"
|
|
REQUEST_HEADERS = {
|
|
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0",
|
|
"Accept-Language": "en-US,en;q=0.9",
|
|
"Cookie": "CONSENT=YES+1",
|
|
}
|
|
|
|
|
|
def video_id(text: str) -> str | None:
|
|
text = text.strip()
|
|
if VIDEO_ID_PATTERN.match(text):
|
|
return text
|
|
|
|
url = urllib.parse.urlparse(text if "://" in text else f"https://{text}")
|
|
if url.hostname not in YOUTUBE_HOSTS:
|
|
return None
|
|
if url.hostname == "youtu.be":
|
|
candidate = url.path.strip("/").split("/")[0]
|
|
elif url.path == "/watch":
|
|
candidate = urllib.parse.parse_qs(url.query).get("v", [""])[0]
|
|
else:
|
|
prefix = next((prefix for prefix in PATH_PREFIXES if url.path.startswith(prefix)), None)
|
|
candidate = url.path[len(prefix):].split("/")[0] if prefix else ""
|
|
return candidate if VIDEO_ID_PATTERN.match(candidate) else None
|
|
|
|
|
|
def watch_url(identifier: str) -> str:
|
|
return f"https://music.youtube.com/watch?v={identifier}"
|
|
|
|
|
|
def cover_url(identifier: str) -> str:
|
|
return f"https://i.ytimg.com/vi/{identifier}/hqdefault.jpg"
|
|
|
|
|
|
def oembed(identifier: str) -> dict:
|
|
query = urllib.parse.urlencode({"url": f"https://www.youtube.com/watch?v={identifier}", "format": "json"})
|
|
try:
|
|
with urllib.request.urlopen(f"https://www.youtube.com/oembed?{query}", timeout=10) as response:
|
|
return json.load(response)
|
|
except (OSError, ValueError):
|
|
return {}
|
|
|
|
|
|
def player_response(identifier: str) -> dict:
|
|
request = urllib.request.Request(f"https://www.youtube.com/watch?v={identifier}", headers=REQUEST_HEADERS)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=15) as response:
|
|
page = response.read().decode("utf-8", errors="replace")
|
|
except OSError:
|
|
return {}
|
|
start = page.find(PLAYER_RESPONSE_MARKER)
|
|
if start == -1:
|
|
return {}
|
|
try:
|
|
data, _ = json.JSONDecoder().raw_decode(page, start + len(PLAYER_RESPONSE_MARKER))
|
|
except ValueError:
|
|
return {}
|
|
return data if isinstance(data, dict) else {}
|
|
|
|
|
|
def clean_artist(name: str | None) -> str | None:
|
|
if name and name.endswith(TOPIC_SUFFIX):
|
|
return name[: -len(TOPIC_SUFFIX)]
|
|
return name
|
|
|
|
|
|
def as_number(value) -> int | None:
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def video_details(identifier: str) -> dict:
|
|
response = player_response(identifier)
|
|
details = response.get("videoDetails") or {}
|
|
microformat = (response.get("microformat") or {}).get("playerMicroformatRenderer") or {}
|
|
fallback = oembed(identifier) if not details.get("title") else {}
|
|
|
|
return {
|
|
"id": identifier,
|
|
"title": details.get("title") or fallback.get("title"),
|
|
"artist": clean_artist(details.get("author") or fallback.get("author_name")),
|
|
"durationSeconds": as_number(details.get("lengthSeconds")),
|
|
"views": as_number(details.get("viewCount")),
|
|
"published": (microformat.get("publishDate") or "")[:10] or None,
|
|
"description": details.get("shortDescription") or None,
|
|
"cover": cover_url(identifier),
|
|
"url": watch_url(identifier),
|
|
}
|
|
|
|
|
|
def format_duration(seconds: int | None) -> str:
|
|
if seconds is None:
|
|
return "-"
|
|
hours, remainder = divmod(seconds, 3600)
|
|
minutes, seconds = divmod(remainder, 60)
|
|
return f"{hours}:{minutes:02}:{seconds:02}" if hours else f"{minutes}:{seconds:02}"
|