Upload folder using huggingface_hub
Browse files- .gitattributes +35 -35
- Dockerfile +16 -0
- README.md +11 -0
- __pycache__/video_dl.cpython-313.pyc +0 -0
- __pycache__/video_dl.cpython-314.pyc +0 -0
- app.py +100 -0
- main.py +37 -0
- requirements.txt +7 -0
- video_dl.py +525 -0
.gitattributes
CHANGED
|
@@ -1,35 +1,35 @@
|
|
| 1 |
-
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Read the doc: https://huggingface.co/docs/hub/spaces-sdks-docker
|
| 2 |
+
# you will also find guides on how best to write your Dockerfile
|
| 3 |
+
|
| 4 |
+
FROM python:3.13
|
| 5 |
+
|
| 6 |
+
RUN useradd -m -u 1000 user
|
| 7 |
+
USER user
|
| 8 |
+
ENV PATH="/home/user/.local/bin:$PATH"
|
| 9 |
+
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
|
| 12 |
+
COPY --chown=user ./requirements.txt requirements.txt
|
| 13 |
+
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
| 14 |
+
|
| 15 |
+
COPY --chown=user . /app
|
| 16 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: VaslNistim
|
| 3 |
+
emoji: 🦀
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: red
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
__pycache__/video_dl.cpython-313.pyc
ADDED
|
Binary file (19.2 kB). View file
|
|
|
__pycache__/video_dl.cpython-314.pyc
ADDED
|
Binary file (11.4 kB). View file
|
|
|
app.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from threading import Thread
|
| 2 |
+
import gradio as gr
|
| 3 |
+
import speedtest
|
| 4 |
+
import platform
|
| 5 |
+
import psutil
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def starter():
|
| 10 |
+
os.system("python3 Kmain.py")
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
thread = Thread(target=starter)
|
| 14 |
+
thread.start()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def get_system_usage():
|
| 18 |
+
# Get CPU usage as a percentage
|
| 19 |
+
cpu_usage = psutil.cpu_percent(interval=1)
|
| 20 |
+
|
| 21 |
+
# Get memory usage
|
| 22 |
+
memory = psutil.virtual_memory()
|
| 23 |
+
total_memory = memory.total
|
| 24 |
+
available_memory = memory.available
|
| 25 |
+
memory_usage = total_memory - available_memory
|
| 26 |
+
|
| 27 |
+
# Get disk usage
|
| 28 |
+
disk = psutil.disk_usage('/')
|
| 29 |
+
total_disk = disk.total
|
| 30 |
+
used_disk = disk.used
|
| 31 |
+
disk_usage = used_disk
|
| 32 |
+
|
| 33 |
+
# Prepare the system usage information as a string
|
| 34 |
+
system_usage = f"CPU Usage: {cpu_usage}%\n"
|
| 35 |
+
system_usage += f"Memory Usage: {memory_usage} bytes\n"
|
| 36 |
+
system_usage += f"Disk Usage: {disk_usage} bytes"
|
| 37 |
+
|
| 38 |
+
return system_usage
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def get_internet_speed():
|
| 42 |
+
st = speedtest.Speedtest()
|
| 43 |
+
st.get_best_server()
|
| 44 |
+
download_speed = st.download() / 1_000_000 / 8 # Convert to MBps
|
| 45 |
+
upload_speed = st.upload() / 1_000_000 / 8 # Convert to MBps
|
| 46 |
+
return download_speed, upload_speed
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def get_system_information():
|
| 50 |
+
system = platform.system()
|
| 51 |
+
release = platform.release()
|
| 52 |
+
version = platform.version()
|
| 53 |
+
machine = platform.machine()
|
| 54 |
+
processor = platform.processor()
|
| 55 |
+
|
| 56 |
+
memory = psutil.virtual_memory()
|
| 57 |
+
memory_total = memory.total
|
| 58 |
+
memory_used = memory.used
|
| 59 |
+
memory_percent = memory.percent
|
| 60 |
+
|
| 61 |
+
disk = psutil.disk_usage('/')
|
| 62 |
+
disk_total = disk.total
|
| 63 |
+
disk_used = disk.used
|
| 64 |
+
disk_percent = disk.percent
|
| 65 |
+
|
| 66 |
+
cpu_percent = psutil.cpu_percent()
|
| 67 |
+
|
| 68 |
+
download_speed, upload_speed = get_internet_speed()
|
| 69 |
+
|
| 70 |
+
information = (f"System Information:\n"
|
| 71 |
+
f"- System: {system}\n"
|
| 72 |
+
f"- Release: {release}\n"
|
| 73 |
+
f"- Version: {version}\n"
|
| 74 |
+
f"- Machine: {machine}\n"
|
| 75 |
+
f"- Processor: {processor}\n"
|
| 76 |
+
f"\n"
|
| 77 |
+
f"Memory Usage:\n"
|
| 78 |
+
f"- Memory Total: {memory_total} bytes\n"
|
| 79 |
+
f"- Memory Used: {memory_used} bytes\n"
|
| 80 |
+
f"- Memory Percent: {memory_percent}%\n"
|
| 81 |
+
f"\n"
|
| 82 |
+
f"Disk Usage:\n"
|
| 83 |
+
f"- Disk Total: {disk_total} bytes\n"
|
| 84 |
+
f"- Disk Used: {disk_used} bytes\n"
|
| 85 |
+
f"- Disk Percent: {disk_percent}%\n"
|
| 86 |
+
f"\n"
|
| 87 |
+
f"CPU Usage:\n"
|
| 88 |
+
f"- CPU Percent: {cpu_percent}%\n"
|
| 89 |
+
f"\n"
|
| 90 |
+
f"- Internet Usage:\n"
|
| 91 |
+
f"Download Speed: {download_speed:.2f} MBps\n"
|
| 92 |
+
f"Upload Speed: {upload_speed:.2f} MBps"
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
return information
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
iface = gr.Interface(fn=get_system_information, inputs=None, outputs="text")
|
| 100 |
+
iface.launch(server_name="0.0.0.0", server_port=7860)
|
main.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from video_dl import get_video_formats, download_video
|
| 3 |
+
from balethon import Client, conditions
|
| 4 |
+
from balethon.objects.message import Message
|
| 5 |
+
import json
|
| 6 |
+
import re
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
url = "https://www.youtube.com/watch?v=GVPhBSmMuq8&pp=ugUEEgJlbg%3D%3D"
|
| 10 |
+
data = get_video_formats(url=url)
|
| 11 |
+
# print(json.dumps(data, indent=2))
|
| 12 |
+
# download_video(url=url, format_id="278", output="video.mp4", ffmpeg_location=r"C:\ffmpeg-master-latest-win64-gpl\bin\ffmpeg.exe")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
bot = Client(os.environ["BotToken"])
|
| 17 |
+
|
| 18 |
+
def is_youtube_link(event: str) -> bool:
|
| 19 |
+
link = False
|
| 20 |
+
if event.text:
|
| 21 |
+
# Regular expression to match various YouTube URL formats
|
| 22 |
+
youtube_regex = r'^(https?://)?(www\.|m\.)?(youtube\.com|youtu\.be)/'
|
| 23 |
+
if re.match(youtube_regex, event.text):
|
| 24 |
+
link = True
|
| 25 |
+
return link
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@bot.on_message(condition=conditions.Condition(is_youtube_link))
|
| 30 |
+
async def greet(message: Message):
|
| 31 |
+
user_message = message.text
|
| 32 |
+
formats = get_video_formats(url=user_message)
|
| 33 |
+
output_path = download_video(url=user_message, format_id=formats[-1]["format_id"])
|
| 34 |
+
# await message.reply("Hello")
|
| 35 |
+
await message.reply_video(output_path)
|
| 36 |
+
|
| 37 |
+
bot.run()
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
h2
|
| 2 |
+
gradio-client
|
| 3 |
+
gradio
|
| 4 |
+
speedtest
|
| 5 |
+
cryptography
|
| 6 |
+
psutil
|
| 7 |
+
yt-dlp
|
video_dl.py
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Video format extraction and download utilities using yt-dlp.
|
| 3 |
+
|
| 4 |
+
Provides two core functions:
|
| 5 |
+
- get_video_formats: Extract available video format metadata, deduplicated
|
| 6 |
+
by resolution+FPS with largest file size per combination
|
| 7 |
+
- download_video: Download a specific video format by ID with automatic
|
| 8 |
+
best audio merging for video-only formats.
|
| 9 |
+
|
| 10 |
+
Both functions support custom ffmpeg location for format merging.
|
| 11 |
+
|
| 12 |
+
Requires yt-dlp to be installed: pip install yt-dlp
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import logging
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Any, TypedDict
|
| 20 |
+
|
| 21 |
+
import yt_dlp
|
| 22 |
+
from yt_dlp.utils import DownloadError, ExtractorError
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class VideoFormatInfo(TypedDict):
|
| 28 |
+
"""Structure for video format metadata returned by get_video_formats."""
|
| 29 |
+
format_id: str
|
| 30 |
+
width: int | None
|
| 31 |
+
height: int | None
|
| 32 |
+
size: int | None
|
| 33 |
+
fps: float | None
|
| 34 |
+
duration: float | None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class VideoDownloadError(Exception):
|
| 38 |
+
"""Base exception for video download operations."""
|
| 39 |
+
pass
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class FormatExtractionError(VideoDownloadError):
|
| 43 |
+
"""Raised when format extraction fails."""
|
| 44 |
+
pass
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class DownloadFailedError(VideoDownloadError):
|
| 48 |
+
"""Raised when the actual download fails."""
|
| 49 |
+
pass
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class InvalidURLError(VideoDownloadError):
|
| 53 |
+
"""Raised when the provided URL is invalid or unsupported."""
|
| 54 |
+
pass
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class FormatNotFoundError(VideoDownloadError):
|
| 58 |
+
"""Raised when the requested format ID does not exist."""
|
| 59 |
+
pass
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class FFmpegNotFoundError(VideoDownloadError):
|
| 63 |
+
"""Raised when the specified ffmpeg binary does not exist."""
|
| 64 |
+
pass
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _validate_url(url: str) -> None:
|
| 68 |
+
"""Validate that the URL has an acceptable scheme.
|
| 69 |
+
|
| 70 |
+
Args:
|
| 71 |
+
url: The URL string to validate.
|
| 72 |
+
|
| 73 |
+
Raises:
|
| 74 |
+
InvalidURLError: If the URL scheme is not http or https.
|
| 75 |
+
"""
|
| 76 |
+
from urllib.parse import urlparse
|
| 77 |
+
|
| 78 |
+
parsed = urlparse(url)
|
| 79 |
+
if parsed.scheme not in ("http", "https"):
|
| 80 |
+
raise InvalidURLError(
|
| 81 |
+
f"Unsupported URL scheme '{parsed.scheme}'. "
|
| 82 |
+
f"Only http and https are allowed. Got: {url}"
|
| 83 |
+
)
|
| 84 |
+
if not parsed.netloc:
|
| 85 |
+
raise InvalidURLError(f"URL has no hostname. Got: {url}")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _sanitize_output_path(output: str | Path | None) -> Path | None:
|
| 89 |
+
"""Sanitize and resolve the output path.
|
| 90 |
+
|
| 91 |
+
Args:
|
| 92 |
+
output: User-provided output path string or Path object, or None.
|
| 93 |
+
|
| 94 |
+
Returns:
|
| 95 |
+
Resolved absolute Path object, or None if output is None.
|
| 96 |
+
|
| 97 |
+
Raises:
|
| 98 |
+
ValueError: If the path contains directory traversal attempts
|
| 99 |
+
or is otherwise invalid.
|
| 100 |
+
"""
|
| 101 |
+
if output is None:
|
| 102 |
+
return None
|
| 103 |
+
|
| 104 |
+
path = Path(output).expanduser().resolve()
|
| 105 |
+
|
| 106 |
+
# Basic path traversal protection — resolve should handle .. but
|
| 107 |
+
# ensure the resolved path is still within reasonable bounds
|
| 108 |
+
try:
|
| 109 |
+
path.relative_to(path.anchor or "/")
|
| 110 |
+
except ValueError:
|
| 111 |
+
raise ValueError(f"Invalid output path: {output}")
|
| 112 |
+
|
| 113 |
+
# Create parent directories if they don't exist
|
| 114 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 115 |
+
|
| 116 |
+
return path
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _validate_ffmpeg_path(ffmpeg_location: str | Path | None) -> str | None:
|
| 120 |
+
"""Validate and resolve the ffmpeg binary path.
|
| 121 |
+
|
| 122 |
+
Args:
|
| 123 |
+
ffmpeg_location: Path to ffmpeg executable, or None to use
|
| 124 |
+
system default.
|
| 125 |
+
|
| 126 |
+
Returns:
|
| 127 |
+
Resolved absolute path as string, or None for system default.
|
| 128 |
+
|
| 129 |
+
Raises:
|
| 130 |
+
FFmpegNotFoundError: If the specified path does not exist or
|
| 131 |
+
is not a file.
|
| 132 |
+
"""
|
| 133 |
+
if ffmpeg_location is None:
|
| 134 |
+
return None
|
| 135 |
+
|
| 136 |
+
ffmpeg_path = Path(ffmpeg_location).expanduser().resolve()
|
| 137 |
+
|
| 138 |
+
if not ffmpeg_path.exists():
|
| 139 |
+
raise FFmpegNotFoundError(
|
| 140 |
+
f"ffmpeg binary not found at: {ffmpeg_path}"
|
| 141 |
+
)
|
| 142 |
+
if not ffmpeg_path.is_file():
|
| 143 |
+
raise FFmpegNotFoundError(
|
| 144 |
+
f"ffmpeg path exists but is not a file: {ffmpeg_path}"
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
logger.debug("Using custom ffmpeg at: %s", ffmpeg_path)
|
| 148 |
+
return str(ffmpeg_path)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def _build_base_ydl_opts(
|
| 152 |
+
ffmpeg_location: str | None = None,
|
| 153 |
+
) -> dict[str, Any]:
|
| 154 |
+
"""Build the base yt-dlp options dictionary.
|
| 155 |
+
|
| 156 |
+
Args:
|
| 157 |
+
ffmpeg_location: Optional path to ffmpeg binary. If None, yt-dlp
|
| 158 |
+
will search the system PATH.
|
| 159 |
+
|
| 160 |
+
Returns:
|
| 161 |
+
Base options dictionary for yt-dlp configuration.
|
| 162 |
+
"""
|
| 163 |
+
opts: dict[str, Any] = {
|
| 164 |
+
"quiet": True,
|
| 165 |
+
"no_warnings": True,
|
| 166 |
+
"noplaylist": True,
|
| 167 |
+
"compat_opts": ["no-certifi"],
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
if ffmpeg_location is not None:
|
| 171 |
+
opts["ffmpeg_location"] = ffmpeg_location
|
| 172 |
+
|
| 173 |
+
return opts
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _get_deduplication_key(
|
| 177 |
+
fmt: dict[str, Any],
|
| 178 |
+
) -> tuple[int | None, int | None, float | None]:
|
| 179 |
+
"""Extract the deduplication key (width, height, fps) from a format dict.
|
| 180 |
+
|
| 181 |
+
Args:
|
| 182 |
+
fmt: A raw format dictionary from yt-dlp.
|
| 183 |
+
|
| 184 |
+
Returns:
|
| 185 |
+
A tuple of (width, height, fps) used for grouping similar formats.
|
| 186 |
+
"""
|
| 187 |
+
return (
|
| 188 |
+
fmt.get("width"),
|
| 189 |
+
fmt.get("height"),
|
| 190 |
+
fmt.get("fps"),
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def _deduplicate_formats_by_largest_size(
|
| 195 |
+
formats: list[dict[str, Any]],
|
| 196 |
+
) -> list[dict[str, Any]]:
|
| 197 |
+
"""Deduplicate formats by resolution+FPS, keeping the largest size variant.
|
| 198 |
+
|
| 199 |
+
Audio-only formats (null width and height) are removed entirely.
|
| 200 |
+
Within each (width, height, fps) group, the format with the largest
|
| 201 |
+
non-null size is kept. If all formats in a group have null size,
|
| 202 |
+
the first one encountered is retained.
|
| 203 |
+
|
| 204 |
+
Args:
|
| 205 |
+
formats: List of raw format dictionaries from yt-dlp.
|
| 206 |
+
|
| 207 |
+
Returns:
|
| 208 |
+
Deduplicated list of format dictionaries, sorted by resolution
|
| 209 |
+
descending, then by size descending.
|
| 210 |
+
"""
|
| 211 |
+
# Step 1: Remove audio-only formats and unavailable formats
|
| 212 |
+
video_formats = [
|
| 213 |
+
fmt for fmt in formats
|
| 214 |
+
if fmt.get("width") is not None
|
| 215 |
+
and fmt.get("height") is not None
|
| 216 |
+
and fmt.get("availability") != "unavailable"
|
| 217 |
+
]
|
| 218 |
+
|
| 219 |
+
if not video_formats:
|
| 220 |
+
return []
|
| 221 |
+
|
| 222 |
+
# Step 2: Group by (width, height, fps) and keep largest size
|
| 223 |
+
grouped: dict[
|
| 224 |
+
tuple[int | None, int | None, float | None], dict[str, Any]
|
| 225 |
+
] = {}
|
| 226 |
+
|
| 227 |
+
for fmt in video_formats:
|
| 228 |
+
key = _get_deduplication_key(fmt)
|
| 229 |
+
current_size = fmt.get("filesize") or fmt.get("filesize_approx")
|
| 230 |
+
|
| 231 |
+
if key not in grouped:
|
| 232 |
+
grouped[key] = fmt
|
| 233 |
+
else:
|
| 234 |
+
existing_size = (
|
| 235 |
+
grouped[key].get("filesize")
|
| 236 |
+
or grouped[key].get("filesize_approx")
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
# If current format has a known size and existing doesn't,
|
| 240 |
+
# or current size is larger — replace
|
| 241 |
+
if current_size is not None:
|
| 242 |
+
if existing_size is None or current_size > existing_size:
|
| 243 |
+
grouped[key] = fmt
|
| 244 |
+
# If both are None, keep the first one (already in grouped)
|
| 245 |
+
|
| 246 |
+
# Step 3: Sort by resolution descending, then size descending
|
| 247 |
+
def sort_key(
|
| 248 |
+
item: tuple[
|
| 249 |
+
tuple[int | None, int | None, float | None], dict[str, Any]
|
| 250 |
+
],
|
| 251 |
+
) -> tuple[int, int, int]:
|
| 252 |
+
(width, height, __), fmt = item
|
| 253 |
+
# Use 0 for None values to place them at the end
|
| 254 |
+
resolved_width = width if width is not None else 0
|
| 255 |
+
resolved_height = height if height is not None else 0
|
| 256 |
+
size = fmt.get("filesize") or fmt.get("filesize_approx") or 0
|
| 257 |
+
# Sort by width descending, then height descending, then size descending
|
| 258 |
+
return (-resolved_width, -resolved_height, -size)
|
| 259 |
+
|
| 260 |
+
sorted_grouped = sorted(grouped.items(), key=sort_key)
|
| 261 |
+
|
| 262 |
+
return [fmt for _, fmt in sorted_grouped]
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def get_video_formats(
|
| 266 |
+
url: str,
|
| 267 |
+
ffmpeg_location: str | Path | None = None,
|
| 268 |
+
) -> list[VideoFormatInfo]:
|
| 269 |
+
"""Extract available video format metadata from a URL using yt-dlp.
|
| 270 |
+
|
| 271 |
+
Fetches format information without downloading the video. Formats are
|
| 272 |
+
deduplicated by unique (width, height, fps) combinations, keeping only
|
| 273 |
+
the variant with the largest file size for each combination. Audio-only
|
| 274 |
+
formats are excluded. Results are sorted by resolution descending.
|
| 275 |
+
|
| 276 |
+
Args:
|
| 277 |
+
url: The video URL to extract formats from. Must use http or https.
|
| 278 |
+
ffmpeg_location: Optional path to ffmpeg executable. If provided,
|
| 279 |
+
will be used for any format merging operations.
|
| 280 |
+
If None, yt-dlp searches the system PATH.
|
| 281 |
+
Example: "C:\\ffmpeg-master-latest-win64-gpl\\bin\\ffmpeg.exe"
|
| 282 |
+
|
| 283 |
+
Returns:
|
| 284 |
+
A list of VideoFormatInfo dictionaries, one per unique
|
| 285 |
+
(width, height, fps) combination, sorted by resolution descending.
|
| 286 |
+
Returns an empty list if no video formats are found.
|
| 287 |
+
|
| 288 |
+
Raises:
|
| 289 |
+
InvalidURLError: If the URL scheme is not http/https.
|
| 290 |
+
FFmpegNotFoundError: If the specified ffmpeg_location does not exist.
|
| 291 |
+
FormatExtractionError: If extraction fails for any reason
|
| 292 |
+
(network error, unsupported site, etc).
|
| 293 |
+
|
| 294 |
+
Example:
|
| 295 |
+
>>> formats = get_video_formats(
|
| 296 |
+
... "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
| 297 |
+
... ffmpeg_location="C:\\\\ffmpeg\\\\bin\\\\ffmpeg.exe"
|
| 298 |
+
... )
|
| 299 |
+
>>> for fmt in formats:
|
| 300 |
+
... print(f"{fmt['width']}x{fmt['height']} @ {fmt['fps']}fps")
|
| 301 |
+
"""
|
| 302 |
+
_validate_url(url)
|
| 303 |
+
resolved_ffmpeg = _validate_ffmpeg_path(ffmpeg_location)
|
| 304 |
+
|
| 305 |
+
ydl_opts = _build_base_ydl_opts(ffmpeg_location=resolved_ffmpeg)
|
| 306 |
+
ydl_opts["extract_flat"] = False
|
| 307 |
+
ydl_opts["skip_download"] = True
|
| 308 |
+
ydl_opts["cookiefile"] = None
|
| 309 |
+
|
| 310 |
+
try:
|
| 311 |
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
| 312 |
+
info = ydl.extract_info(url, download=False)
|
| 313 |
+
except (DownloadError, ExtractorError) as exc:
|
| 314 |
+
raise FormatExtractionError(
|
| 315 |
+
f"Failed to extract formats from URL: {exc}"
|
| 316 |
+
) from exc
|
| 317 |
+
except Exception as exc:
|
| 318 |
+
raise FormatExtractionError(
|
| 319 |
+
f"Unexpected error during format extraction: {exc}"
|
| 320 |
+
) from exc
|
| 321 |
+
|
| 322 |
+
if info is None:
|
| 323 |
+
raise FormatExtractionError(
|
| 324 |
+
f"No information could be extracted from: {url}"
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
formats_raw: list[dict[str, Any]] = info.get("formats") or []
|
| 328 |
+
|
| 329 |
+
# Deduplicate: keep only largest size per (width, height, fps) group
|
| 330 |
+
deduplicated = _deduplicate_formats_by_largest_size(formats_raw)
|
| 331 |
+
|
| 332 |
+
result: list[VideoFormatInfo] = []
|
| 333 |
+
for fmt in deduplicated:
|
| 334 |
+
if type(fmt.get("fps")) == float:
|
| 335 |
+
continue
|
| 336 |
+
format_info: VideoFormatInfo = {
|
| 337 |
+
"format_id": str(fmt.get("format_id", "")),
|
| 338 |
+
"width": fmt.get("width"),
|
| 339 |
+
"height": fmt.get("height"),
|
| 340 |
+
"size": fmt.get("filesize") or fmt.get("filesize_approx"),
|
| 341 |
+
"fps": fmt.get("fps"),
|
| 342 |
+
"duration": info.get("duration"),
|
| 343 |
+
}
|
| 344 |
+
result.append(format_info)
|
| 345 |
+
|
| 346 |
+
return result
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def _format_has_audio(format_info: dict[str, Any]) -> bool:
|
| 350 |
+
"""Determine if a format entry already contains an audio stream.
|
| 351 |
+
|
| 352 |
+
Checks audio codec presence and video codec absence to identify
|
| 353 |
+
whether the format includes audio. Formats with no video codec
|
| 354 |
+
are audio-only, and formats with an audio codec are muxed.
|
| 355 |
+
|
| 356 |
+
Args:
|
| 357 |
+
format_info: A single format dictionary from yt-dlp extraction.
|
| 358 |
+
|
| 359 |
+
Returns:
|
| 360 |
+
True if the format contains audio, False if it is video-only.
|
| 361 |
+
"""
|
| 362 |
+
# Audio-only formats have no video codec
|
| 363 |
+
if format_info.get("vcodec") == "none":
|
| 364 |
+
return True
|
| 365 |
+
|
| 366 |
+
# Muxed formats have an audio codec present
|
| 367 |
+
if format_info.get("acodec") and format_info["acodec"] != "none":
|
| 368 |
+
return True
|
| 369 |
+
|
| 370 |
+
# Video-only formats have vcodec but no acodec (or acodec="none")
|
| 371 |
+
return False
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def download_video(
|
| 375 |
+
url: str,
|
| 376 |
+
format_id: str,
|
| 377 |
+
output: str | Path | None = None,
|
| 378 |
+
ffmpeg_location: str | Path | None = None,
|
| 379 |
+
) -> str:
|
| 380 |
+
"""Download a video in the specified format using yt-dlp.
|
| 381 |
+
|
| 382 |
+
Downloads the video with the given format ID. If the requested format
|
| 383 |
+
is video-only (no audio track), the best available audio is
|
| 384 |
+
automatically selected and merged into the final output using ffmpeg.
|
| 385 |
+
|
| 386 |
+
If an output path is provided, the file will be saved to that exact
|
| 387 |
+
location. Otherwise, yt-dlp's default naming template is used.
|
| 388 |
+
|
| 389 |
+
Args:
|
| 390 |
+
url: The video URL to download. Must use http or https.
|
| 391 |
+
format_id: The yt-dlp format ID string (e.g., "137", "22").
|
| 392 |
+
Video-only formats will automatically have audio
|
| 393 |
+
merged in.
|
| 394 |
+
output: Optional output file path. Directories will be created
|
| 395 |
+
if they don't exist. Should include the file extension.
|
| 396 |
+
ffmpeg_location: Optional path to ffmpeg executable. Required
|
| 397 |
+
if system doesn't have ffmpeg in PATH. Used for
|
| 398 |
+
merging video and audio streams.
|
| 399 |
+
Example: "C:\\\\ffmpeg-master-latest-win64-gpl\\\\bin\\\\ffmpeg.exe"
|
| 400 |
+
|
| 401 |
+
Returns:
|
| 402 |
+
The absolute path (as a string) where the video was saved.
|
| 403 |
+
|
| 404 |
+
Raises:
|
| 405 |
+
InvalidURLError: If the URL scheme is not http/https.
|
| 406 |
+
FFmpegNotFoundError: If the specified ffmpeg_location does not exist.
|
| 407 |
+
FormatNotFoundError: If the specified format_id is not found.
|
| 408 |
+
DownloadFailedError: If the download fails for any reason.
|
| 409 |
+
ValueError: If the output path is invalid.
|
| 410 |
+
|
| 411 |
+
Example:
|
| 412 |
+
>>> path = download_video(
|
| 413 |
+
... "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
| 414 |
+
... format_id="137",
|
| 415 |
+
... output="/tmp/video.mp4",
|
| 416 |
+
... ffmpeg_location="C:\\\\ffmpeg\\\\bin\\\\ffmpeg.exe"
|
| 417 |
+
... )
|
| 418 |
+
>>> print(f"Saved to: {path}")
|
| 419 |
+
"""
|
| 420 |
+
_validate_url(url)
|
| 421 |
+
sanitized_output = _sanitize_output_path(output)
|
| 422 |
+
resolved_ffmpeg = _validate_ffmpeg_path(ffmpeg_location)
|
| 423 |
+
|
| 424 |
+
# Extract format metadata to check for audio and validate format_id
|
| 425 |
+
try:
|
| 426 |
+
ydl_opts = _build_base_ydl_opts(ffmpeg_location=resolved_ffmpeg)
|
| 427 |
+
ydl_opts["extract_flat"] = False
|
| 428 |
+
ydl_opts["skip_download"] = True
|
| 429 |
+
|
| 430 |
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
| 431 |
+
info = ydl.extract_info(url, download=False)
|
| 432 |
+
except (DownloadError, ExtractorError) as exc:
|
| 433 |
+
raise DownloadFailedError(
|
| 434 |
+
f"Cannot verify format availability: {exc}"
|
| 435 |
+
) from exc
|
| 436 |
+
except Exception as exc:
|
| 437 |
+
raise DownloadFailedError(
|
| 438 |
+
f"Unexpected error during format check: {exc}"
|
| 439 |
+
) from exc
|
| 440 |
+
|
| 441 |
+
if info is None:
|
| 442 |
+
raise DownloadFailedError(
|
| 443 |
+
"No information could be extracted from the URL."
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
# Find the requested format in available formats
|
| 447 |
+
formats_raw: list[dict[str, Any]] = info.get("formats") or []
|
| 448 |
+
available_ids = {str(fmt.get("format_id", "")) for fmt in formats_raw}
|
| 449 |
+
|
| 450 |
+
if format_id not in available_ids:
|
| 451 |
+
raise FormatNotFoundError(
|
| 452 |
+
f"Format ID '{format_id}' not found. "
|
| 453 |
+
f"Available IDs: {sorted(available_ids)}"
|
| 454 |
+
)
|
| 455 |
+
|
| 456 |
+
# Determine if we need to add audio
|
| 457 |
+
selected_format = next(
|
| 458 |
+
fmt for fmt in formats_raw
|
| 459 |
+
if str(fmt.get("format_id", "")) == format_id
|
| 460 |
+
)
|
| 461 |
+
|
| 462 |
+
if _format_has_audio(selected_format):
|
| 463 |
+
# Format already has audio — use as-is
|
| 464 |
+
final_format = format_id
|
| 465 |
+
logger.info(
|
| 466 |
+
"Format '%s' already includes audio. Downloading as-is.",
|
| 467 |
+
format_id,
|
| 468 |
+
)
|
| 469 |
+
else:
|
| 470 |
+
# Video-only format — merge with best audio
|
| 471 |
+
final_format = f"{format_id}+bestaudio"
|
| 472 |
+
logger.info(
|
| 473 |
+
"Format '%s' is video-only. Merging with best audio → '%s'.",
|
| 474 |
+
format_id,
|
| 475 |
+
final_format,
|
| 476 |
+
)
|
| 477 |
+
|
| 478 |
+
# Build yt-dlp options for download
|
| 479 |
+
ydl_opts = _build_base_ydl_opts(ffmpeg_location=resolved_ffmpeg)
|
| 480 |
+
ydl_opts["format"] = final_format
|
| 481 |
+
ydl_opts["merge_output_format"] = "mp4"
|
| 482 |
+
ydl_opts["overwrites"] = True
|
| 483 |
+
|
| 484 |
+
# If user specifies an output path, use it exactly
|
| 485 |
+
if sanitized_output is not None:
|
| 486 |
+
ydl_opts["outtmpl"] = str(sanitized_output)
|
| 487 |
+
ydl_opts["paths"] = {}
|
| 488 |
+
|
| 489 |
+
try:
|
| 490 |
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
| 491 |
+
info = ydl.extract_info(url, download=True)
|
| 492 |
+
except (DownloadError, ExtractorError) as exc:
|
| 493 |
+
raise DownloadFailedError(
|
| 494 |
+
f"Download failed for format '{final_format}': {exc}"
|
| 495 |
+
) from exc
|
| 496 |
+
except Exception as exc:
|
| 497 |
+
raise DownloadFailedError(
|
| 498 |
+
f"Unexpected error during download: {exc}"
|
| 499 |
+
) from exc
|
| 500 |
+
|
| 501 |
+
if info is None:
|
| 502 |
+
raise DownloadFailedError(
|
| 503 |
+
"Download completed but no information was returned."
|
| 504 |
+
)
|
| 505 |
+
|
| 506 |
+
# Determine the actual output path
|
| 507 |
+
if sanitized_output is not None:
|
| 508 |
+
final_path = sanitized_output
|
| 509 |
+
else:
|
| 510 |
+
requested_downloads = info.get("requested_downloads") or []
|
| 511 |
+
if requested_downloads:
|
| 512 |
+
final_path = Path(requested_downloads[0].get("filepath", ""))
|
| 513 |
+
else:
|
| 514 |
+
final_path = Path(ydl.prepare_filename(info))
|
| 515 |
+
|
| 516 |
+
final_path = final_path.resolve()
|
| 517 |
+
|
| 518 |
+
if not final_path.exists():
|
| 519 |
+
raise DownloadFailedError(
|
| 520 |
+
f"Download appeared to succeed but file not found at: "
|
| 521 |
+
f"{final_path}"
|
| 522 |
+
)
|
| 523 |
+
|
| 524 |
+
logger.info("Video downloaded successfully to: %s", final_path)
|
| 525 |
+
return str(final_path)
|