Download2 / app.py
Agon Agent
Enhance extract_video_id to support shorts and implement DNS-over-HTTPS fallback with socket-level DNS cache override
590641c
Raw
History Blame Contribute Delete
13.1 kB
import os
import tempfile
import uuid
import json
import subprocess
import urllib.request
import urllib.parse
import re
import socket
from fastapi import FastAPI, HTTPException, Query, BackgroundTasks
from fastapi.responses import FileResponse
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(title="END Downloader API", version="1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
JAR_PATH = "extractor-cli.jar"
# --- Global DoH DNS Cache Override ---
dns_cache = {}
original_getaddrinfo = socket.getaddrinfo
def custom_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):
if host in dns_cache:
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (dns_cache[host], port))]
return original_getaddrinfo(host, port, family, type, proto, flags)
socket.getaddrinfo = custom_getaddrinfo
def resolve_hostname_doh(hostname: str) -> str:
if hostname in ["google.com", "huggingface.co", "github.com", "noembed.com", "dns.google"]:
return None
try:
url = f"https://dns.google/resolve?name={hostname}&type=A"
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=5) as response:
data = json.loads(response.read().decode("utf-8"))
answers = data.get("Answer", [])
for ans in answers:
if ans.get("type") == 1: # A record
ip = ans.get("data")
dns_cache[hostname] = ip
print(f"DoH Resolved: {hostname} -> {ip}")
return ip
except Exception as e:
print(f"DoH failed for {hostname}: {e}")
return None
def cleanup_file(filepath: str):
try:
if os.path.exists(filepath):
os.remove(filepath)
print(f"Successfully deleted temp file: {filepath}")
except Exception as e:
print(f"Error removing file {filepath}: {e}")
@app.get("/")
def read_root():
return {"message": "END Downloader API is running", "status": "ok", "extractor": "NewPipeExtractor v0.26.2"}
def extract_video_id(url: str) -> str:
pattern = r'(?:https?://)?(?:www\.)?(?:youtube\.com/(?:watch\?v=|shorts/|embed/|v/)|youtu\.be/)([a-zA-Z0-9_-]{11})'
match = re.search(pattern, url)
if match:
return match.group(1)
return ""
def fetch_from_noembed(url: str, video_id: str) -> dict:
"""Guaranteed fallback for basic video details"""
try:
api_url = f"https://noembed.com/embed?url=https://www.youtube.com/watch?v={video_id}"
req = urllib.request.Request(api_url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=10) as response:
data = json.loads(response.read().decode("utf-8"))
title = data.get("title", "YouTube Video")
uploader = data.get("author_name", "Unknown Uploader")
thumbnail = data.get("thumbnail_url", f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg")
# Generate a basic format as fallback
formats = [
{
"format_id": "fallback_mp4",
"resolution": "720p (Fallback)",
"ext": "mp4",
"filesize": None,
"vcodec": "avc1",
"acodec": "aac",
"hdr": "SDR",
"type": "muxed",
"url": f"https://youtube.com/watch?v={video_id}", # fallback URL
"fps": 30.0
}
]
return {
"id": video_id,
"title": title,
"thumbnail": thumbnail,
"duration": None,
"uploader": uploader,
"description": f"Video Title: {title}. Stream formats could not be extracted directly because of YouTube rate limits on cloud servers.",
"formats": formats
}
except Exception as e:
print(f"Noembed fallback failed: {e}")
return None
def fetch_from_invidious_backup(video_id: str) -> dict:
"""Backup extractor using public Invidious instances with DoH resolution"""
instances = [
"https://invidious.nerdvpn.de",
"https://invidious.f5.si",
"https://yt.chocolatemoo53.com",
"https://inv.thepixora.com",
"https://invidious.tiekoetter.com"
]
for inst in instances:
parsed = urllib.parse.urlparse(inst)
hostname = parsed.hostname
if hostname:
resolve_hostname_doh(hostname) # Pre-resolve to bypass local DNS failures!
try:
api_url = f"{inst}/api/v1/videos/{video_id}"
req = urllib.request.Request(api_url, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'})
with urllib.request.urlopen(req, timeout=10) as response:
data = json.loads(response.read().decode('utf-8'))
# Format into our standard response
formats = []
# Muxed streams
for fmt in data.get("formatStreams", []):
formats.append({
"format_id": str(fmt.get("itag", "18")),
"resolution": fmt.get("resolution", "360p"),
"ext": fmt.get("container", "mp4"),
"filesize": None,
"vcodec": "avc1",
"acodec": "aac",
"hdr": "SDR",
"type": "muxed",
"url": fmt.get("url"),
"fps": 30.0
})
# Video only streams
for fmt in data.get("adaptiveFormats", []):
mime = fmt.get("type", "")
if "video" in mime:
is_hdr = "hdr" in mime.lower() or "hdr" in fmt.get("qualityLabel", "").lower()
formats.append({
"format_id": str(fmt.get("itag", "137")),
"resolution": fmt.get("qualityLabel", "1080p"),
"ext": "mp4" if "mp4" in mime else "webm",
"filesize": int(fmt.get("contentLength", 0)) if fmt.get("contentLength") else None,
"vcodec": fmt.get("encoding", "avc1"),
"acodec": "none",
"hdr": "HDR" if is_hdr else "SDR",
"type": "video_only",
"url": fmt.get("url"),
"fps": float(fmt.get("fps", 30))
})
elif "audio" in mime:
formats.append({
"format_id": str(fmt.get("itag", "140")),
"resolution": f"{int(fmt.get('bitrate', 128000) / 1000)}kbps",
"ext": "m4a" if "mp4" in mime else "webm",
"filesize": int(fmt.get("contentLength", 0)) if fmt.get("contentLength") else None,
"vcodec": "none",
"acodec": fmt.get("encoding", "aac"),
"hdr": "SDR",
"type": "audio_only",
"url": fmt.get("url"),
"fps": None
})
return {
"id": data.get("videoId"),
"title": data.get("title"),
"thumbnail": data.get("videoThumbnails", [{}])[-1].get("url") if data.get("videoThumbnails") else f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg",
"duration": data.get("lengthSeconds"),
"uploader": data.get("author"),
"description": data.get("description"),
"formats": formats
}
except Exception as e:
print(f"Invidious backup instance {inst} failed: {e}")
continue
raise Exception("All backup instances failed")
@app.get("/info")
def get_info(url: str = Query(..., description="YouTube video URL")):
# 1. Primary Extractor: NewPipeExtractor v0.26.2
try:
res = subprocess.run(
["java", "-jar", JAR_PATH, url],
capture_output=True,
text=True,
timeout=30
)
if res.returncode == 0:
details = json.loads(res.stdout)
if "error" not in details:
return details
else:
print(f"NewPipeExtractor returned error: {details['error']}")
else:
print(f"NewPipeExtractor process failed: {res.stderr}")
except Exception as e:
print(f"Failed to run NewPipeExtractor: {e}")
# 2. Backup Extractor: Public Invidious APIs with DoH
print("Falling back to backup extractor APIs...")
video_id = extract_video_id(url)
if video_id:
try:
return fetch_from_invidious_backup(video_id)
except Exception as e:
print(f"Backup extractor failed: {e}")
# 3. Ultimate Fallback: oEmbed via noembed.com
noembed_data = fetch_from_noembed(url, video_id)
if noembed_data:
return noembed_data
raise HTTPException(status_code=400, detail=f"All extractors failed. NewPipeExtractor error, Backup error: {str(e)}")
else:
raise HTTPException(status_code=400, detail="Invalid YouTube URL and NewPipeExtractor failed.")
@app.get("/download")
def download_video(
url: str = Query(..., description="YouTube video URL"),
format_id: str = Query(..., description="Format ID (e.g., 137 or 137+140)"),
background_tasks: BackgroundTasks = BackgroundTasks()
):
temp_dir = tempfile.gettempdir()
unique_id = str(uuid.uuid4())
# Fetch details first to get URLs and title
try:
details = get_info(url)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to fetch video details: {e}")
title = details.get("title", "video").replace("/", "_").replace("\\", "_")
formats = details.get("formats", [])
# Check if we need to merge video and audio (format_id like '137+140')
if "+" in format_id:
v_id, a_id = format_id.split("+")
v_fmt = next((f for f in formats if f["format_id"] == v_id), None)
a_fmt = next((f for f in formats if f["format_id"] == a_id), None)
if not v_fmt or not a_fmt:
raise HTTPException(status_code=400, detail="Requested format IDs not found")
v_url = v_fmt["url"]
a_url = a_fmt["url"]
ext = v_fmt["ext"]
output_file = os.path.join(temp_dir, f"end_{unique_id}_{title}.{ext}")
# Run ffmpeg to merge streams on-the-fly!
ffmpeg_cmd = [
"ffmpeg", "-y",
"-ss", "00:00:00", "-i", v_url,
"-ss", "00:00:00", "-i", a_url,
"-map", "0:v:0", "-map", "1:a:0",
"-c:v", "copy", "-c:a", "aac",
"-shortest", output_file
]
try:
res = subprocess.run(ffmpeg_cmd, capture_output=True, text=True, timeout=180)
if res.returncode != 0:
print(f"ffmpeg error: {res.stderr}")
raise Exception("ffmpeg merge failed")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to merge streams: {e}")
background_tasks.add_task(cleanup_file, output_file)
return FileResponse(
path=output_file,
filename=f"{title}.{ext}",
media_type="application/octet-stream"
)
else:
# Single stream download (muxed or audio only)
fmt = next((f for f in formats if f["format_id"] == format_id), None)
if not fmt:
raise HTTPException(status_code=400, detail="Requested format ID not found")
stream_url = fmt["url"]
ext = fmt["ext"]
# Download the single stream
output_file = os.path.join(temp_dir, f"end_{unique_id}_{title}.{ext}")
try:
# Stream download using urllib
req = urllib.request.Request(stream_url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req) as response, open(output_file, 'wb') as out_file:
out_file.write(response.read())
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to download stream: {e}")
background_tasks.add_task(cleanup_file, output_file)
return FileResponse(
path=output_file,
filename=f"{title}.{ext}",
media_type="application/octet-stream"
)