commit 1781b3b8b2a51b69d75aabdb3c9cb0f380cd8555 Author: William P Date: Fri Jul 11 20:22:56 2025 -0400 initial commit diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c551f68 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +.direnv/ +.vscode/ +.env +.venv/ +out/ \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..e6ba3ff --- /dev/null +++ b/app/main.py @@ -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) \ No newline at end of file diff --git a/app/misc.py b/app/misc.py new file mode 100644 index 0000000..da7164f --- /dev/null +++ b/app/misc.py @@ -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}%" \ No newline at end of file diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..3bb3c12 --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,3 @@ +discord.py==2.5.2 +python-dotenv==1.1.1 +yt-dlp==2025.6.30 \ No newline at end of file diff --git a/app/ytdlp.py b/app/ytdlp.py new file mode 100644 index 0000000..7c2d4ce --- /dev/null +++ b/app/ytdlp.py @@ -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 \ No newline at end of file diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..98be868 --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..4849ff7 --- /dev/null +++ b/flake.nix @@ -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 + ''; + }; + }); + }; +} \ No newline at end of file