initial commit
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
.direnv/
|
||||
.vscode/
|
||||
.env
|
||||
.venv/
|
||||
out/
|
203
app/main.py
Normal file
203
app/main.py
Normal file
@@ -0,0 +1,203 @@
|
||||
import asyncio
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from discord import Intents, Client, Interaction, SelectOption, Message, app_commands, ui
|
||||
|
||||
from misc import extract_url_from_string, render_progress_bar
|
||||
import ytdlp
|
||||
|
||||
load_dotenv()
|
||||
|
||||
discord_token = os.getenv("DISCORD_TOKEN")
|
||||
out_path = os.getenv("OUT_PATH")
|
||||
temp_path = os.getenv("TEMP_PATH")
|
||||
|
||||
class DownloadVideo(ui.View):
|
||||
def __init__(self, url: str):
|
||||
super().__init__()
|
||||
self.url = url
|
||||
self.video_options = []
|
||||
self.audio_options = []
|
||||
self.selected_video = None
|
||||
self.selected_audio = None
|
||||
self._populate_format_options()
|
||||
|
||||
self.video_select = ui.Select(
|
||||
placeholder="Choose a video format...",
|
||||
options=self.video_options,
|
||||
min_values=1,
|
||||
max_values=1,
|
||||
custom_id="video_select"
|
||||
)
|
||||
self.video_select.callback = self.on_video_select
|
||||
self.add_item(self.video_select)
|
||||
|
||||
self.audio_select = ui.Select(
|
||||
placeholder="Choose an audio format...",
|
||||
options=self.audio_options,
|
||||
min_values=1,
|
||||
max_values=1,
|
||||
custom_id="audio_select",
|
||||
disabled=True # Start disabled
|
||||
)
|
||||
self.audio_select.callback = self.on_audio_select
|
||||
self.add_item(self.audio_select)
|
||||
|
||||
def _populate_format_options(self):
|
||||
try:
|
||||
formats = ytdlp.get_formats(self.url)
|
||||
|
||||
# Sort video options by height and bitrate
|
||||
sorted_videos = sorted(
|
||||
formats['video_options'],
|
||||
key=lambda v: (v.get('height', 0), v.get('tbr', 0)),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
self.video_options = [
|
||||
SelectOption(
|
||||
label=f"{v['resolution']} {v['height']}p ({v['ext']}, {int(v['tbr'] or 0)}kbps)",
|
||||
value=v['format_id']
|
||||
)
|
||||
for v in sorted_videos[:25] # limit
|
||||
]
|
||||
self.audio_options = [
|
||||
SelectOption(
|
||||
label=f"{a['format']} ({a['ext']}, {int(a['tbr'] or 0)}kbps"
|
||||
+ (f", {a['language']}" if a.get('language') else "") + ")",
|
||||
value=a['format_id']
|
||||
)
|
||||
for a in formats['audio_options'][:25] # limit
|
||||
]
|
||||
# Ensure at least one option for each select
|
||||
if not self.video_options:
|
||||
self.video_options = [SelectOption(label="No video formats found", value="none", default=True, description="No video formats available", disabled=True)]
|
||||
if not self.audio_options:
|
||||
self.audio_options = [SelectOption(label="No audio formats found", value="none", default=True, description="No audio formats available", disabled=True)]
|
||||
except Exception as e:
|
||||
print(f"Error fetching formats: {e}")
|
||||
self.video_options = [SelectOption(label="Error loading formats", value="none", default=True, description="Error loading formats", disabled=True)]
|
||||
self.audio_options = [SelectOption(label="Error loading formats", value="none", default=True, description="Error loading formats", disabled=True)]
|
||||
|
||||
async def on_video_select(self, interaction: Interaction):
|
||||
self.selected_video = self.video_select.values[0]
|
||||
# Disable video select, enable audio select
|
||||
self.video_select.disabled = True
|
||||
self.audio_select.disabled = False
|
||||
await interaction.response.edit_message(
|
||||
content="Now select an audio format:",
|
||||
view=self
|
||||
)
|
||||
|
||||
async def on_audio_select(self, interaction: Interaction):
|
||||
self.selected_audio = self.audio_select.values[0]
|
||||
format_string = f"{self.selected_video}+{self.selected_audio}"
|
||||
# Optionally, disable both selects after selection
|
||||
self.audio_select.disabled = True
|
||||
|
||||
# Get labels for summary
|
||||
video_label = next((o.label for o in self.video_options if o.value == self.selected_video), self.selected_video)
|
||||
audio_label = next((o.label for o in self.audio_options if o.value == self.selected_audio), self.selected_audio)
|
||||
format_string = f"{self.selected_video}+{self.selected_audio}"
|
||||
|
||||
# Show progress bar
|
||||
await interaction.response.edit_message(
|
||||
content=f"Downloading video: {render_progress_bar(0)}",
|
||||
view=self
|
||||
)
|
||||
|
||||
progress_queue = asyncio.Queue()
|
||||
stop_event = asyncio.Event()
|
||||
|
||||
async def progress_worker(msg):
|
||||
last_percent = 0
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
percent = await asyncio.wait_for(progress_queue.get(), timeout=2)
|
||||
# Only update every 10% or when at 100%
|
||||
if percent // 10 > last_percent // 10 or percent == 100:
|
||||
await msg.edit(content=f"Downloading video: {render_progress_bar(percent)}")
|
||||
last_percent = percent
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
|
||||
# Get message to edit
|
||||
msg = await interaction.original_response()
|
||||
|
||||
worker_task = asyncio.create_task(progress_worker(msg))
|
||||
|
||||
async def progress_callback(percent):
|
||||
await progress_queue.put(percent)
|
||||
|
||||
await asyncio.to_thread(
|
||||
ytdlp.download_video, self.url, format_string, out_path, temp_path, progress_callback, asyncio.get_event_loop()
|
||||
)
|
||||
|
||||
stop_event.set()
|
||||
await worker_task
|
||||
|
||||
# Show completion and summary
|
||||
await msg.edit(content="✅ Download complete!\n"
|
||||
)
|
||||
|
||||
# Send public message
|
||||
await interaction.followup.send(
|
||||
f"## Video Downloaded\n"
|
||||
f"**URL**: {self.url}\n"
|
||||
f"**Quality**: {video_label} + {audio_label}\n"
|
||||
f"**Requested By**: <@{interaction.user.id}>"
|
||||
)
|
||||
|
||||
|
||||
# Bot setup
|
||||
intents = Intents.default()
|
||||
intents.message_content = True
|
||||
client = Client(intents=intents)
|
||||
tree = app_commands.CommandTree(client)
|
||||
|
||||
@client.event
|
||||
async def on_ready() -> None:
|
||||
"""Called when the bot is ready."""
|
||||
print(f"Logged in as {client.user}")
|
||||
await tree.sync()
|
||||
|
||||
# /download command
|
||||
@app_commands.allowed_contexts(dms=True, private_channels=True)
|
||||
@app_commands.allowed_installs(users=True)
|
||||
@tree.command(name="download", description="Download video and save it to 'youtube-vids'")
|
||||
async def download_command(interaction: Interaction, url: str) -> None:
|
||||
"""Handles the /download command."""
|
||||
await interaction.response.defer(ephemeral=True, thinking=True)
|
||||
|
||||
view = DownloadVideo(url)
|
||||
await interaction.followup.send(
|
||||
content="Select a video format:",
|
||||
view=view,
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
# direct URL download from message context
|
||||
@app_commands.allowed_contexts(dms=True, private_channels=True)
|
||||
@app_commands.allowed_installs(users=True)
|
||||
@tree.context_menu(name="download video")
|
||||
async def download_context(interaction: Interaction, message: Message) -> None:
|
||||
"""Handles requests to download a URL from an already posted message"""
|
||||
await interaction.response.defer(ephemeral=True, thinking=True)
|
||||
|
||||
url = extract_url_from_string(message.clean_content)
|
||||
if url is not None:
|
||||
view = DownloadVideo(url)
|
||||
await interaction.followup.send(
|
||||
content="Select a video format:",
|
||||
view=view,
|
||||
ephemeral=True
|
||||
)
|
||||
else:
|
||||
await interaction.followup.send(
|
||||
content="No URL was found in message, sorry :-(",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
client.run(discord_token)
|
23
app/misc.py
Normal file
23
app/misc.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import re
|
||||
|
||||
# Constants
|
||||
PROGRESS_BAR_LENGTH = 10
|
||||
|
||||
def extract_url_from_string(text: str):
|
||||
"""
|
||||
Extracts the first URL from a string
|
||||
"""
|
||||
|
||||
url_pattern = re.compile(r'https?://\S+')
|
||||
match = url_pattern.search(text)
|
||||
|
||||
if match:
|
||||
return match.group(0)
|
||||
else:
|
||||
return None
|
||||
|
||||
def render_progress_bar(percent: int, length: int = PROGRESS_BAR_LENGTH) -> str:
|
||||
"""Renders a progress bar with the given percentage."""
|
||||
filled = int(length * percent // 100)
|
||||
bar = "█" * filled + " " * (length - filled)
|
||||
return f"[{bar}] {percent}%"
|
3
app/requirements.txt
Normal file
3
app/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
discord.py==2.5.2
|
||||
python-dotenv==1.1.1
|
||||
yt-dlp==2025.6.30
|
63
app/ytdlp.py
Normal file
63
app/ytdlp.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import yt_dlp
|
||||
import asyncio
|
||||
|
||||
def get_formats(url: str):
|
||||
ydl = yt_dlp.YoutubeDL()
|
||||
info = ydl.extract_info(url, download=False)
|
||||
|
||||
video_options = []
|
||||
audio_options = []
|
||||
|
||||
for fmt in info['formats']:
|
||||
# Video-only
|
||||
if fmt.get('vcodec') != 'none' and fmt.get('acodec') == 'none':
|
||||
video_options.append({
|
||||
'height': fmt.get('height'),
|
||||
'resolution': fmt.get('resolution'),
|
||||
'format_id': fmt.get('format_id'),
|
||||
'ext': fmt.get('ext'),
|
||||
'tbr': fmt.get('tbr'),
|
||||
})
|
||||
# Audio-only
|
||||
elif fmt.get('acodec') != 'none' and fmt.get('vcodec') == 'none':
|
||||
audio_options.append({
|
||||
'format': fmt.get('format'),
|
||||
'format_id': fmt.get('format_id'),
|
||||
'ext': fmt.get('ext'),
|
||||
'tbr': fmt.get('tbr'),
|
||||
'language': fmt.get('language') or fmt.get('language_preference'),
|
||||
})
|
||||
|
||||
return {
|
||||
'video_options': video_options,
|
||||
'audio_options': audio_options,
|
||||
}
|
||||
|
||||
def download_video(url: str, format: str, out_path: str, temp_path: str, progress_callback, loop):
|
||||
"""
|
||||
Downloads a video and sends a progress callback as it downloads
|
||||
Returns the format used
|
||||
"""
|
||||
progress = {'percent': 0}
|
||||
|
||||
def hook(d):
|
||||
if d['status'] == 'downloading':
|
||||
total = d.get('total_bytes') or d.get('total_bytes_estimate')
|
||||
downloaded = d.get('downloaded_bytes')
|
||||
if total and downloaded:
|
||||
percent = int(downloaded / total * 100)
|
||||
if percent != progress['percent']:
|
||||
progress['percent'] = percent
|
||||
asyncio.run_coroutine_threadsafe(progress_callback(percent), loop)
|
||||
elif d['status'] == 'finished':
|
||||
asyncio.run_coroutine_threadsafe(progress_callback(100), loop)
|
||||
|
||||
opts = {
|
||||
'format': format,
|
||||
'paths': {'home': out_path, 'temp': temp_path},
|
||||
'progress_hooks': [hook]
|
||||
}
|
||||
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||
ydl.download([url])
|
||||
|
||||
return format
|
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1751498133,
|
||||
"narHash": "sha256-QWJ+NQbMU+NcU2xiyo7SNox1fAuwksGlQhpzBl76g1I=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "d55716bb59b91ae9d1ced4b1ccdea7a442ecbfdb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
40
flake.nix
Normal file
40
flake.nix
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
description = "Dev Environment";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs }:
|
||||
let
|
||||
lastModifiedDate = self.lastModifiedDate or self.lastModified or "19700101";
|
||||
version = builtins.substring 0 8 lastModifiedDate;
|
||||
supportedSystems = [ "x86_64-linux" "aarch64-linux" ];
|
||||
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
|
||||
nixpkgsFor = forAllSystems (system: import nixpkgs { inherit system; });
|
||||
in
|
||||
{
|
||||
devShells = forAllSystems (system:
|
||||
let
|
||||
pkgs = nixpkgsFor.${system};
|
||||
in
|
||||
{
|
||||
default = pkgs.mkShell {
|
||||
buildInputs = [
|
||||
pkgs.bashInteractive
|
||||
pkgs.python314
|
||||
pkgs.virtualenv
|
||||
pkgs.ffmpeg_6
|
||||
];
|
||||
shellHook = ''
|
||||
if [ ! -d .venv ]; then
|
||||
echo "Creating Python virtual environment in .venv"
|
||||
python3 -m venv .venv
|
||||
fi
|
||||
.venv/bin/pip install -r ./app/requirements.txt
|
||||
source .venv/bin/activate
|
||||
'';
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
Reference in New Issue
Block a user