From 39dcad6bcc2c9bc8423bdafb4ef4750a1976bac7 Mon Sep 17 00:00:00 2001 From: William P Date: Wed, 21 Jan 2026 14:07:58 -0500 Subject: [PATCH 01/16] nix dev environment: update flake and add deno --- flake.lock | 6 +++--- flake.nix | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 98be868..862c7ab 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1751498133, - "narHash": "sha256-QWJ+NQbMU+NcU2xiyo7SNox1fAuwksGlQhpzBl76g1I=", + "lastModified": 1768875095, + "narHash": "sha256-dYP3DjiL7oIiiq3H65tGIXXIT1Waiadmv93JS0sS+8A=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "d55716bb59b91ae9d1ced4b1ccdea7a442ecbfdb", + "rev": "ed142ab1b3a092c4d149245d0c4126a5d7ea00b0", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 4849ff7..cc3b79b 100644 --- a/flake.nix +++ b/flake.nix @@ -25,6 +25,7 @@ pkgs.python314 pkgs.virtualenv pkgs.ffmpeg_6 + pkgs.deno ]; shellHook = '' if [ ! -d .venv ]; then -- 2.49.1 From 1b9437fea993330648b0a0b87c5d51a97ec341dc Mon Sep 17 00:00:00 2001 From: William P Date: Wed, 21 Jan 2026 14:15:59 -0500 Subject: [PATCH 02/16] blank slate go app --- app/go.mod | 3 + app/main.go | 7 ++ app/main.py | 216 ------------------------------------------- app/misc.py | 23 ----- app/requirements.txt | 3 - app/ytdlp.py | 65 ------------- 6 files changed, 10 insertions(+), 307 deletions(-) create mode 100644 app/go.mod create mode 100644 app/main.go delete mode 100644 app/main.py delete mode 100644 app/misc.py delete mode 100644 app/requirements.txt delete mode 100644 app/ytdlp.py diff --git a/app/go.mod b/app/go.mod new file mode 100644 index 0000000..2fe3137 --- /dev/null +++ b/app/go.mod @@ -0,0 +1,3 @@ +module git.dubyatp.xyz/williamp/yt-dlp-bot + +go 1.25.2 diff --git a/app/main.go b/app/main.go new file mode 100644 index 0000000..e373dd8 --- /dev/null +++ b/app/main.go @@ -0,0 +1,7 @@ +package main + +import "fmt" + +func main() { + fmt.Println("Hello bitches") +} \ No newline at end of file diff --git a/app/main.py b/app/main.py deleted file mode 100644 index ba05390..0000000 --- a/app/main.py +++ /dev/null @@ -1,216 +0,0 @@ -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)) - - error = None - - async def progress_callback(percent): - await progress_queue.put(percent) - - loop = asyncio.get_running_loop() - def threaded_download(): - nonlocal error - try: - ytdlp.download_video( - self.url, format_string, out_path, temp_path, progress_callback, loop - ) - except Exception as e: - error = e - await asyncio.to_thread(threaded_download) - - stop_event.set() - await worker_task - - if error: - await msg.edit(content=f"āŒ Download failed: {error}") - return - - # 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 deleted file mode 100644 index da7164f..0000000 --- a/app/misc.py +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index d314c41..0000000 --- a/app/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -discord.py==2.6.4 -python-dotenv==1.2.1 -yt-dlp==2025.12.8 \ No newline at end of file diff --git a/app/ytdlp.py b/app/ytdlp.py deleted file mode 100644 index a111568..0000000 --- a/app/ytdlp.py +++ /dev/null @@ -1,65 +0,0 @@ -import yt_dlp -import asyncio - -def get_formats(url: str): - ydl = yt_dlp.YoutubeDL(params={ - 'remote_components': ['ejs:github'] - }) - 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' and fmt.get('__needs_testing') == 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' and fmt.get('__needs_testing') == 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 -- 2.49.1 From 9345a941fbab775ba864b0d9545cab13afc09e7d Mon Sep 17 00:00:00 2001 From: William P Date: Wed, 21 Jan 2026 15:40:40 -0500 Subject: [PATCH 03/16] slight dl-only PoC --- .gitignore | 26 +++++++++++++++++++++++++- app/go.mod | 9 +++++++++ app/go.sum | 12 ++++++++++++ app/main.go | 15 +++++++++++++-- app/ytdlp.go | 29 +++++++++++++++++++++++++++++ flake.nix | 10 ++-------- 6 files changed, 90 insertions(+), 11 deletions(-) create mode 100644 app/go.sum create mode 100644 app/ytdlp.go diff --git a/.gitignore b/.gitignore index c551f68..a3624ac 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,28 @@ __pycache__/ .vscode/ .env .venv/ -out/ \ No newline at end of file +out/ +app_legacy/ + +# Test binary, built with `go test -c` +*.test + +# Code coverage profiles and other test artifacts +*.out +coverage.* +*.coverprofile +profile.cov + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib \ No newline at end of file diff --git a/app/go.mod b/app/go.mod index 2fe3137..3c59237 100644 --- a/app/go.mod +++ b/app/go.mod @@ -1,3 +1,12 @@ module git.dubyatp.xyz/williamp/yt-dlp-bot go 1.25.2 + +require ( + github.com/ProtonMail/go-crypto v1.3.0 // indirect + github.com/cloudflare/circl v1.6.1 // indirect + github.com/lrstanley/go-ytdlp v1.2.7 // indirect + github.com/ulikunitz/xz v0.5.13 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/sys v0.35.0 // indirect +) diff --git a/app/go.sum b/app/go.sum new file mode 100644 index 0000000..7f2816e --- /dev/null +++ b/app/go.sum @@ -0,0 +1,12 @@ +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/lrstanley/go-ytdlp v1.2.7 h1:YNDvKkd0OCJSZLZePZvJwcirBCfL8Yw3eCwrTCE5w7Q= +github.com/lrstanley/go-ytdlp v1.2.7/go.mod h1:38IL64XM6gULrWtKTiR0+TTNCVbxesNSbTyaFG2CGTI= +github.com/ulikunitz/xz v0.5.13 h1:ar98gWrjf4H1ev05fYP/o29PDZw9DrI3niHtnEqyuXA= +github.com/ulikunitz/xz v0.5.13/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/app/main.go b/app/main.go index e373dd8..54f8724 100644 --- a/app/main.go +++ b/app/main.go @@ -1,7 +1,18 @@ package main -import "fmt" +import ( + "fmt" + "os" +) func main() { - fmt.Println("Hello bitches") + + out_dir := os.Getenv("OUT_DIR") + var url string = "https://www.youtube.com/watch?v=e_isb3YDUDA" + + if out_dir == "" { + panic("No output dir specified") + } + + downloadVideo(out_dir, url) } \ No newline at end of file diff --git a/app/ytdlp.go b/app/ytdlp.go new file mode 100644 index 0000000..1a26bb2 --- /dev/null +++ b/app/ytdlp.go @@ -0,0 +1,29 @@ +package main + +import ( + "time" + "fmt" + "context" + "github.com/lrstanley/go-ytdlp" +) +func downloadVideo(out_dir, url string) { + dl := ytdlp.New(). + SetWorkDir(out_dir). + FormatSort("res,ext:mp4:m4a"). + RecodeVideo("mp4"). + ProgressFunc(100*time.Millisecond, func(prog ytdlp.ProgressUpdate) { + fmt.Printf( + "%s @ %s [eta: %s] :: %s\n", + prog.Status, + prog.PercentString(), + prog.ETA(), + prog.Filename, + ) + }). + Output("%(extractor)s - %(title)s.%(ext)s") + + _, err := dl.Run(context.TODO(), url) + if err != nil { + panic(err) + } +} diff --git a/flake.nix b/flake.nix index cc3b79b..2a3a051 100644 --- a/flake.nix +++ b/flake.nix @@ -26,15 +26,9 @@ pkgs.virtualenv pkgs.ffmpeg_6 pkgs.deno + pkgs.go + pkgs.yt-dlp ]; - 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 - ''; }; }); }; -- 2.49.1 From f569f382f5ce258909a7c5a5802e379335d8aea9 Mon Sep 17 00:00:00 2001 From: William P Date: Wed, 21 Jan 2026 20:21:27 -0500 Subject: [PATCH 04/16] add .env shellhook --- flake.nix | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/flake.nix b/flake.nix index 2a3a051..1f9e9e5 100644 --- a/flake.nix +++ b/flake.nix @@ -29,6 +29,14 @@ pkgs.go pkgs.yt-dlp ]; + + shellHook = '' + if [ -f .env ]; then + set -a + source .env + set +a + fi + ''; }; }); }; -- 2.49.1 From b5642456c2c51f2fbc2ae741b9fc281242a9b2c1 Mon Sep 17 00:00:00 2001 From: William P Date: Thu, 22 Jan 2026 23:11:59 -0500 Subject: [PATCH 05/16] add options for downloads --- app/ytdlp.go | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/app/ytdlp.go b/app/ytdlp.go index 1a26bb2..6b641c8 100644 --- a/app/ytdlp.go +++ b/app/ytdlp.go @@ -1,12 +1,19 @@ package main import ( - "time" - "fmt" "context" + "fmt" + "time" + "github.com/lrstanley/go-ytdlp" ) -func downloadVideo(out_dir, url string) { + +type DownloadOptions struct { + EmbedThumbnail bool + IncludeSubtitles bool +} + +func downloadVideo(out_dir, url string, opts DownloadOptions) { dl := ytdlp.New(). SetWorkDir(out_dir). FormatSort("res,ext:mp4:m4a"). @@ -20,10 +27,22 @@ func downloadVideo(out_dir, url string) { prog.Filename, ) }). - Output("%(extractor)s - %(title)s.%(ext)s") - - _, err := dl.Run(context.TODO(), url) - if err != nil { - panic(err) - } + Output("%(title)s.%(ext)s") + + if opts.EmbedThumbnail { + dl = dl.EmbedThumbnail() + } + + if opts.IncludeSubtitles { + dl = dl.CompatOptions("no-keep-subs") + dl = dl.EmbedSubs() + dl = dl.SubLangs("en,en*") + dl = dl.WriteAutoSubs() + dl = dl.WriteSubs() + } + + _, err := dl.Run(context.TODO(), url) + if err != nil { + panic(err) + } } -- 2.49.1 From 99c6bd379a1ea465bcd8c769190e83a5b8d2398f Mon Sep 17 00:00:00 2001 From: William P Date: Thu, 22 Jan 2026 23:12:19 -0500 Subject: [PATCH 06/16] start discord-side of things --- app/go.mod | 2 ++ app/go.sum | 10 ++++++ app/main.go | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 99 insertions(+), 5 deletions(-) diff --git a/app/go.mod b/app/go.mod index 3c59237..5fbb7f8 100644 --- a/app/go.mod +++ b/app/go.mod @@ -4,7 +4,9 @@ go 1.25.2 require ( github.com/ProtonMail/go-crypto v1.3.0 // indirect + github.com/bwmarrin/discordgo v0.29.0 // indirect github.com/cloudflare/circl v1.6.1 // indirect + github.com/gorilla/websocket v1.4.2 // indirect github.com/lrstanley/go-ytdlp v1.2.7 // indirect github.com/ulikunitz/xz v0.5.13 // indirect golang.org/x/crypto v0.41.0 // indirect diff --git a/app/go.sum b/app/go.sum index 7f2816e..8bfee9d 100644 --- a/app/go.sum +++ b/app/go.sum @@ -1,12 +1,22 @@ github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= +github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/lrstanley/go-ytdlp v1.2.7 h1:YNDvKkd0OCJSZLZePZvJwcirBCfL8Yw3eCwrTCE5w7Q= github.com/lrstanley/go-ytdlp v1.2.7/go.mod h1:38IL64XM6gULrWtKTiR0+TTNCVbxesNSbTyaFG2CGTI= github.com/ulikunitz/xz v0.5.13 h1:ar98gWrjf4H1ev05fYP/o29PDZw9DrI3niHtnEqyuXA= github.com/ulikunitz/xz v0.5.13/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/app/main.go b/app/main.go index 54f8724..02c05cb 100644 --- a/app/main.go +++ b/app/main.go @@ -1,18 +1,100 @@ package main import ( - "fmt" + "log" "os" + "os/signal" + "syscall" + + "github.com/bwmarrin/discordgo" ) func main() { - out_dir := os.Getenv("OUT_DIR") - var url string = "https://www.youtube.com/watch?v=e_isb3YDUDA" + out_dir := os.Getenv("OUT_PATH") + bot_token := os.Getenv("DISCORD_TOKEN") if out_dir == "" { panic("No output dir specified") } - downloadVideo(out_dir, url) -} \ No newline at end of file + s, err := discordgo.New("Bot " + bot_token) + if err != nil { + log.Fatalf("Invalid bot parameters: %v", err) + } + + err = s.Open() + if err != nil { + log.Fatalf("Error opening connection: %v", err) + } + + var defaultMemberPermissions int64 = discordgo.PermissionAllText + var interactionPrivateChannel = discordgo.InteractionContextPrivateChannel + + var commands = []*discordgo.ApplicationCommand{ + { + Name: "download", + Description: "Download video and save it to 'youtube-vids", + DefaultMemberPermissions: &defaultMemberPermissions, + Contexts: &[]discordgo.InteractionContextType{interactionPrivateChannel}, + Options: []*discordgo.ApplicationCommandOption{ + { + Name: "url", + Description: "URL", + Type: discordgo.ApplicationCommandOptionString, + Required: true, + }, + }, + }, + } + + var commandHandlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){ + "download": func(s *discordgo.Session, i *discordgo.InteractionCreate) { + options := i.ApplicationCommandData().Options + optionMap := make(map[string]*discordgo.ApplicationCommandInteractionDataOption, len(options)) + for _, opt := range options { + optionMap[opt.Name] = opt + } + response := "" + if option, ok := optionMap["url"]; ok { + response = "It works! Your URL is: " + option.StringValue() + } else { + response = "It works!" + } + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: response, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + }, + } + + s.AddHandler(func(s *discordgo.Session, i *discordgo.InteractionCreate) { + if h, ok := commandHandlers[i.ApplicationCommandData().Name]; ok { + h(s, i) + } + }) + + log.Println("Adding commands") + registeredCommands := make([]*discordgo.ApplicationCommand, len(commands)) + for i, v := range commands { + cmd, err := s.ApplicationCommandCreate(s.State.User.ID, "", v) + if err != nil { + log.Panicf("Cannot create '%v' command: %v", v.Name, err) + } + registeredCommands[i] = cmd + } + + log.Println("Bot is now running. Press CTRL+C to exit") + sc := make(chan os.Signal, 1) + signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt) + <-sc + + s.Close() + + //var url string = "https://www.youtube.com/watch?v=WpBWSFF03eI" + + //downloadVideo(out_dir, url, DownloadOptions{EmbedThumbnail: true, IncludeSubtitles: true}) +} -- 2.49.1 From 4bad5f72dae2633e6549b23baabd87356e1014ce Mon Sep 17 00:00:00 2001 From: William P Date: Sun, 25 Jan 2026 20:16:58 -0500 Subject: [PATCH 07/16] interaction state management, call ytdlp through /download function --- app/main.go | 341 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 334 insertions(+), 7 deletions(-) diff --git a/app/main.go b/app/main.go index 02c05cb..1a4c463 100644 --- a/app/main.go +++ b/app/main.go @@ -1,14 +1,133 @@ package main import ( + "fmt" "log" "os" "os/signal" + "sync" "syscall" + "time" "github.com/bwmarrin/discordgo" ) +// InteractionState holds the state for a specific interaction +type InteractionState struct { + URL string +} + +// DownloadResult represents the result of an async download operation +type DownloadResult struct { + Success bool + Message string + URL string + Format string + Error error +} + +// startAsyncDownload initiates a download in a goroutine and handles progress updates +func startAsyncDownload(s *discordgo.Session, i *discordgo.InteractionCreate, url, audioFormat, outputDir string) { + resultChan := make(chan DownloadResult, 1) + + // Start download in goroutine + go func() { + defer close(resultChan) + defer func() { + if r := recover(); r != nil { + // Handle panic from downloadVideo + resultChan <- DownloadResult{ + Success: false, + Message: fmt.Sprintf("Download failed: %v", r), + URL: url, + Format: audioFormat, + Error: fmt.Errorf("%v", r), + } + } + }() + + // Call downloadVideo (it panics on error instead of returning error) + downloadVideo(outputDir, url, DownloadOptions{EmbedThumbnail: true, IncludeSubtitles: true}) + + // If we reach here, download was successful + resultChan <- DownloadResult{ + Success: true, + Message: "Video Downloaded Successfully!", + URL: url, + Format: audioFormat, + Error: nil, + } + }() + + // Handle results asynchronously + go func() { + // First update the original ephemeral message with "Processing..." + _, err := s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr("šŸ”„ Processing download...\nURL: " + url + "\nAudio: " + audioFormat), + }) + if err != nil { + log.Printf("Error updating interaction: %v", err) + } + + result := <-resultChan + + if result.Success { + // Update ephemeral message with completion status + _, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr("āœ… Download completed successfully!\nURL: " + result.URL + "\nAudio: " + result.Format), + }) + if err != nil { + log.Printf("Error updating interaction: %v", err) + } + + // Send non-ephemeral completion message + _, err = s.FollowupMessageCreate(i.Interaction, false, &discordgo.WebhookParams{ + Content: "šŸ“„ Video downloaded: " + result.URL, + }) + if err != nil { + log.Printf("Error sending public completion message: %v", err) + } + } else { + // Update ephemeral message with error + _, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr("āŒ Download failed: " + result.Message + "\nURL: " + result.URL + "\nAudio: " + result.Format), + }) + if err != nil { + log.Printf("Error updating interaction: %v", err) + } + } + }() +} + +// Helper function to create string pointer +func ptr(s string) *string { + return &s +} + +// Global state management +var ( + interactionStates = make(map[string]*InteractionState) + interactionStatesMutex = sync.RWMutex{} +) + +func getInteractionState(token string) *InteractionState { + interactionStatesMutex.RLock() + defer interactionStatesMutex.RUnlock() + return interactionStates[token] +} + +func setInteractionState(token string, state *InteractionState) { + interactionStatesMutex.Lock() + defer interactionStatesMutex.Unlock() + interactionStates[token] = state +} + +func deleteInteractionState(token string) { + interactionStatesMutex.Lock() + defer interactionStatesMutex.Unlock() + delete(interactionStates, token) +} + func main() { out_dir := os.Getenv("OUT_PATH") @@ -48,6 +167,140 @@ func main() { }, } + var componentHandlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){ + "video_select": func(s *discordgo.Session, i *discordgo.InteractionCreate) { + // Update components + updatedComponents := []discordgo.MessageComponent{ + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.SelectMenu{ + CustomID: "video_select", + Placeholder: "Choose a video format...", + MaxValues: 1, + Disabled: true, + Options: []discordgo.SelectMenuOption{ + { + Label: "1920x1080 1080p (mp4, 1910kbps)", + Value: "1080p", + }, + { + Label: "1280x720 720p (mp4, 700kbps)", + Value: "720p", + }, + { + Label: "640x480 480p (mp4, 300kbps)", + Value: "480p", + }, + }, + }, + }, + }, + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.SelectMenu{ + CustomID: "audio_select", + Placeholder: "Choose an audio format...", + MaxValues: 1, + Disabled: false, + Options: []discordgo.SelectMenuOption{ + { + Label: "Medium", + Value: "medium", + }, + { + Label: "Low", + Value: "low", + }, + }, + }, + }, + }, + } + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseUpdateMessage, + Data: &discordgo.InteractionResponseData{ + Content: "Now select an audio format:", + Components: updatedComponents, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + if err != nil { + log.Printf("Error: %v", err) + } + }, + "audio_select": func(s *discordgo.Session, i *discordgo.InteractionCreate) { + // Get URL from interaction state with fallback search + var state *InteractionState + + // First try current token + state = getInteractionState(i.Interaction.Token) + + // If not found, try to find state associated with this interaction context + if state == nil { + interactionStatesMutex.RLock() + for token, s := range interactionStates { + if s != nil && s.URL != "" { + // Found a state, transfer it to current token + state = s + // Copy to current token and clean up old one + interactionStatesMutex.RUnlock() + setInteractionState(i.Interaction.Token, state) + deleteInteractionState(token) + break + } + } + if state == nil { + interactionStatesMutex.RUnlock() + } + } + if state == nil { + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Error: No video URL found. Please start over with /download.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Get selected audio format + selectedValues := i.MessageComponentData().Values + audioFormat := "medium" // default + if len(selectedValues) > 0 { + audioFormat = selectedValues[0] + } + + response := "" + if state.URL != "" { + // Respond immediately to prevent timeout + response = "šŸš€ Starting download...\nURL: " + state.URL + "\nAudio: " + audioFormat + "\n\nYou'll receive an update when the download completes!" + + // Start async download after responding + go func() { + // Small delay to ensure response is sent first + time.Sleep(100 * time.Millisecond) + startAsyncDownload(s, i, state.URL, audioFormat, out_dir) + }() + + // Clean up state after starting download + deleteInteractionState(i.Interaction.Token) + } else { + response = "I don't see a video here :(" + } + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: response, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + if err != nil { + log.Printf("Error: %v", err) + } + }, + } + var commandHandlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){ "download": func(s *discordgo.Session, i *discordgo.InteractionCreate) { options := i.ApplicationCommandData().Options @@ -55,25 +308,99 @@ func main() { for _, opt := range options { optionMap[opt.Name] = opt } - response := "" + + var url string if option, ok := optionMap["url"]; ok { - response = "It works! Your URL is: " + option.StringValue() + url = option.StringValue() } else { - response = "It works!" + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Error: No URL provided", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return } - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + + // Store URL in interaction state (we'll use the response message ID later) + // For now, store with the current token, then update after getting message ID + setInteractionState(i.Interaction.Token, &InteractionState{URL: url}) + + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ - Content: response, + Content: "Select a video format:", Flags: discordgo.MessageFlagsEphemeral, + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.SelectMenu{ + CustomID: "video_select", + Placeholder: "Choose a video format...", + MaxValues: 1, + Options: []discordgo.SelectMenuOption{ + { + Label: "1920x1080 1080p (mp4, 1910kbps)", + Value: "1080p", + }, + { + Label: "1280x720 720p (mp4, 700kbps)", + Value: "720p", + }, + { + Label: "640x480 480p (mp4, 300kbps)", + Value: "480p", + }, + }, + }, + }, + }, + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.SelectMenu{ + CustomID: "audio_select", + Placeholder: "Choose an audio format...", + MaxValues: 1, + Disabled: true, + Options: []discordgo.SelectMenuOption{ + { + Label: "Medium", + Value: "medium", + }, + { + Label: "Low", + Value: "low", + }, + }, + }, + }, + }, + }, }, }) + if err != nil { + log.Printf("Error: %v", err) + } }, } s.AddHandler(func(s *discordgo.Session, i *discordgo.InteractionCreate) { - if h, ok := commandHandlers[i.ApplicationCommandData().Name]; ok { - h(s, i) + switch i.Type { + case discordgo.InteractionApplicationCommand: + if h, ok := commandHandlers[i.ApplicationCommandData().Name]; ok { + h(s, i) + } + if h, ok := componentHandlers[i.ApplicationCommandData().Name]; ok { + h(s, i) + } + case discordgo.InteractionMessageComponent: + if h, ok := commandHandlers[i.MessageComponentData().CustomID]; ok { + h(s, i) + } + if h, ok := componentHandlers[i.MessageComponentData().CustomID]; ok { + h(s, i) + } } }) -- 2.49.1 From aa9dc58259746b84d153d6f87afdb3499602580d Mon Sep 17 00:00:00 2001 From: William P Date: Mon, 26 Jan 2026 20:16:13 -0500 Subject: [PATCH 08/16] pass download status to discord --- app/main.go | 73 +++++++++++++++++++++++++++++++++------------------- app/ytdlp.go | 43 ++++++++++++++++++++++++------- 2 files changed, 80 insertions(+), 36 deletions(-) diff --git a/app/main.go b/app/main.go index 1a4c463..58bc138 100644 --- a/app/main.go +++ b/app/main.go @@ -28,6 +28,7 @@ type DownloadResult struct { // startAsyncDownload initiates a download in a goroutine and handles progress updates func startAsyncDownload(s *discordgo.Session, i *discordgo.InteractionCreate, url, audioFormat, outputDir string) { + progressChan := make(chan ProgressUpdate, 1) resultChan := make(chan DownloadResult, 1) // Start download in goroutine @@ -47,7 +48,7 @@ func startAsyncDownload(s *discordgo.Session, i *discordgo.InteractionCreate, ur }() // Call downloadVideo (it panics on error instead of returning error) - downloadVideo(outputDir, url, DownloadOptions{EmbedThumbnail: true, IncludeSubtitles: true}) + downloadVideo(outputDir, url, DownloadOptions{EmbedThumbnail: true, IncludeSubtitles: true}, progressChan) // If we reach here, download was successful resultChan <- DownloadResult{ @@ -59,7 +60,7 @@ func startAsyncDownload(s *discordgo.Session, i *discordgo.InteractionCreate, ur } }() - // Handle results asynchronously + // Handle progress and results asynchronously go func() { // First update the original ephemeral message with "Processing..." _, err := s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ @@ -69,31 +70,49 @@ func startAsyncDownload(s *discordgo.Session, i *discordgo.InteractionCreate, ur log.Printf("Error updating interaction: %v", err) } - result := <-resultChan - - if result.Success { - // Update ephemeral message with completion status - _, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ - Content: ptr("āœ… Download completed successfully!\nURL: " + result.URL + "\nAudio: " + result.Format), - }) - if err != nil { - log.Printf("Error updating interaction: %v", err) - } - - // Send non-ephemeral completion message - _, err = s.FollowupMessageCreate(i.Interaction, false, &discordgo.WebhookParams{ - Content: "šŸ“„ Video downloaded: " + result.URL, - }) - if err != nil { - log.Printf("Error sending public completion message: %v", err) - } - } else { - // Update ephemeral message with error - _, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ - Content: ptr("āŒ Download failed: " + result.Message + "\nURL: " + result.URL + "\nAudio: " + result.Format), - }) - if err != nil { - log.Printf("Error updating interaction: %v", err) + for { + select { + case prog, ok := <-progressChan: + if !ok { + progressChan = nil + continue + } + // Update message w/ phase and real time progress + phaseEmoji := "ā¬" + if prog.Phase == "post-processing" { + phaseEmoji = "āš™ļø" + } + content := fmt.Sprintf("%s %s\n%s @ %s [eta: %s]\nšŸ“„ %s", + phaseEmoji, + prog.Phase, + prog.Status, + prog.Percent, + prog.ETA, + prog.Filename) + _, err := s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr(content), + }) + if err != nil { + log.Printf("Error updating progress: %v", err) + } + case result := <-resultChan: + // Handle completion + if result.Success { + _, err = s.FollowupMessageCreate(i.Interaction, false, &discordgo.WebhookParams{ + Content: "šŸ“„ Video downloaded: " + result.URL, + }) + if err != nil { + log.Printf("Error updating interaction: %v", err) + } + } else { + _, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr("āŒ Download failed: " + result.Message), + }) + if err != nil { + log.Printf("Error updating interaction: %v", err) + } + } + return } } }() diff --git a/app/ytdlp.go b/app/ytdlp.go index 6b641c8..41c1fd5 100644 --- a/app/ytdlp.go +++ b/app/ytdlp.go @@ -2,7 +2,6 @@ package main import ( "context" - "fmt" "time" "github.com/lrstanley/go-ytdlp" @@ -13,19 +12,45 @@ type DownloadOptions struct { IncludeSubtitles bool } -func downloadVideo(out_dir, url string, opts DownloadOptions) { +type ProgressUpdate struct { + Status ytdlp.ProgressStatus + Percent string + ETA time.Duration + Filename string + Phase string +} + +func downloadVideo(out_dir, url string, opts DownloadOptions, progressChan chan<- ProgressUpdate) { + defer close(progressChan) + + var lastPhase string + dl := ytdlp.New(). SetWorkDir(out_dir). FormatSort("res,ext:mp4:m4a"). RecodeVideo("mp4"). ProgressFunc(100*time.Millisecond, func(prog ytdlp.ProgressUpdate) { - fmt.Printf( - "%s @ %s [eta: %s] :: %s\n", - prog.Status, - prog.PercentString(), - prog.ETA(), - prog.Filename, - ) + // Detect phase transition -- differentiate "downloading" as the main download + // and "post processing" when the file name changes, preventing it from appearing "reset" + phase := "downloading" + if prog.Status == ytdlp.ProgressStatusDownloading && prog.Percent() == 0.0 { + // If we already had progress, it's likely post-processing + if lastPhase == "downloading" { + phase = "post-processing" + } + } else if prog.Status != ytdlp.ProgressStatusDownloading { + phase = "post-processing" + } + + lastPhase = phase + + progressChan <- ProgressUpdate{ + Status: prog.Status, + Percent: prog.PercentString(), + ETA: prog.ETA(), + Filename: prog.Filename, + Phase: phase, + } }). Output("%(title)s.%(ext)s") -- 2.49.1 From 7bd9ef12302171251efba3745532fefc3de7ebf8 Mon Sep 17 00:00:00 2001 From: William P Date: Mon, 26 Jan 2026 20:28:23 -0500 Subject: [PATCH 09/16] flake: check for existance of python project --- flake.nix | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/flake.nix b/flake.nix index 1f9e9e5..6877dfa 100644 --- a/flake.nix +++ b/flake.nix @@ -36,6 +36,16 @@ source .env set +a fi + + if [ ! -d .venv ]; then + echo "Creating Python virtual environment in .venv" + python3 -m venv .venv + fi + + if compgen -G "app/*.py" > /dev/null; then + .venv/bin/pip install -r ./app/requirements.txt + source .venv/bin/activate + fi ''; }; }); -- 2.49.1 From 725c650debd92cc91552e615b91e4d951382d24e Mon Sep 17 00:00:00 2001 From: William P Date: Tue, 10 Feb 2026 21:52:24 -0500 Subject: [PATCH 10/16] update flake for latest yt-dlp --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 862c7ab..ef570ba 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1768875095, - "narHash": "sha256-dYP3DjiL7oIiiq3H65tGIXXIT1Waiadmv93JS0sS+8A=", + "lastModified": 1770537093, + "narHash": "sha256-pF1quXG5wsgtyuPOHcLfYg/ft/QMr8NnX0i6tW2187s=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "ed142ab1b3a092c4d149245d0c4126a5d7ea00b0", + "rev": "fef9403a3e4d31b0a23f0bacebbec52c248fbb51", "type": "github" }, "original": { -- 2.49.1 From 6ae35ec636e3ca12ba0b9f66cb3b9da3b85025d6 Mon Sep 17 00:00:00 2001 From: William P Date: Tue, 10 Feb 2026 21:52:39 -0500 Subject: [PATCH 11/16] list formats and provide them for download --- app/main.go | 332 ++++++++++++++++++++++++++++++++++++++++----------- app/ytdlp.go | 120 ++++++++++++++++++- 2 files changed, 381 insertions(+), 71 deletions(-) diff --git a/app/main.go b/app/main.go index 58bc138..a40de2e 100644 --- a/app/main.go +++ b/app/main.go @@ -5,6 +5,7 @@ import ( "log" "os" "os/signal" + "sort" "sync" "syscall" "time" @@ -14,7 +15,10 @@ import ( // InteractionState holds the state for a specific interaction type InteractionState struct { - URL string + URL string + FormatOptions *FormatOptions + VideoFormatID string + AudioFormatID string } // DownloadResult represents the result of an async download operation @@ -27,7 +31,7 @@ type DownloadResult struct { } // startAsyncDownload initiates a download in a goroutine and handles progress updates -func startAsyncDownload(s *discordgo.Session, i *discordgo.InteractionCreate, url, audioFormat, outputDir string) { +func startAsyncDownload(s *discordgo.Session, i *discordgo.InteractionCreate, url, videoFormatID, audioFormatID, outputDir string) { progressChan := make(chan ProgressUpdate, 1) resultChan := make(chan DownloadResult, 1) @@ -41,21 +45,26 @@ func startAsyncDownload(s *discordgo.Session, i *discordgo.InteractionCreate, ur Success: false, Message: fmt.Sprintf("Download failed: %v", r), URL: url, - Format: audioFormat, + Format: fmt.Sprintf("video: %s, audio: %s", videoFormatID, audioFormatID), Error: fmt.Errorf("%v", r), } } }() // Call downloadVideo (it panics on error instead of returning error) - downloadVideo(outputDir, url, DownloadOptions{EmbedThumbnail: true, IncludeSubtitles: true}, progressChan) + downloadVideo(outputDir, url, DownloadOptions{ + EmbedThumbnail: true, + IncludeSubtitles: true, + VideoFormatID: videoFormatID, + AudioFormatID: audioFormatID, + }, progressChan) // If we reach here, download was successful resultChan <- DownloadResult{ Success: true, Message: "Video Downloaded Successfully!", URL: url, - Format: audioFormat, + Format: fmt.Sprintf("video: %s, audio: %s", videoFormatID, audioFormatID), Error: nil, } }() @@ -64,7 +73,7 @@ func startAsyncDownload(s *discordgo.Session, i *discordgo.InteractionCreate, ur go func() { // First update the original ephemeral message with "Processing..." _, err := s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ - Content: ptr("šŸ”„ Processing download...\nURL: " + url + "\nAudio: " + audioFormat), + Content: ptr(fmt.Sprintf("šŸ”„ Processing download...\nURL: %s\nVideo: %s\nAudio: %s", url, videoFormatID, audioFormatID)), }) if err != nil { log.Printf("Error updating interaction: %v", err) @@ -188,7 +197,109 @@ func main() { var componentHandlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){ "video_select": func(s *discordgo.Session, i *discordgo.InteractionCreate) { - // Update components + // Get interaction state with fallback search + var state *InteractionState + + // First try current token + state = getInteractionState(i.Interaction.Token) + + // If not found, try to find state associated with this interaction context + if state == nil { + interactionStatesMutex.RLock() + for token, s := range interactionStates { + if s != nil && s.FormatOptions != nil { + // Found a state, transfer it to current token + state = s + // Copy to current token and clean up old one + interactionStatesMutex.RUnlock() + setInteractionState(i.Interaction.Token, state) + deleteInteractionState(token) + break + } + } + if state == nil { + interactionStatesMutex.RUnlock() + } + } + if state == nil || state.FormatOptions == nil { + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Error: Session expired. Please start over with /download.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Get selected video format + selectedValues := i.MessageComponentData().Values + if len(selectedValues) == 0 { + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Error: No video format selected", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Store selected video format ID + state.VideoFormatID = selectedValues[0] + setInteractionState(i.Interaction.Token, state) + + // Build audio format options + audioMenuOptions := []discordgo.SelectMenuOption{} + for _, aOpt := range state.FormatOptions.AudioOptions { + label := aOpt.Format + if aOpt.Language != nil { + label += fmt.Sprintf(" [%s]", *aOpt.Language) + } + if aOpt.TBR != nil { + label += fmt.Sprintf(" (%.0fkbps)", *aOpt.TBR) + } + + // Discord has a 100 char limit on labels + if len(label) > 100 { + label = label[:97] + "..." + } + + audioMenuOptions = append(audioMenuOptions, discordgo.SelectMenuOption{ + Label: label, + Value: aOpt.FormatID, + }) + + // Discord has a limit of 25 options per select menu + if len(audioMenuOptions) >= 25 { + break + } + } + + // Build video format options (to keep them visible but disabled) + videoMenuOptions := []discordgo.SelectMenuOption{} + for _, vOpt := range state.FormatOptions.VideoOptions { + label := fmt.Sprintf("%s (%s", vOpt.Resolution, vOpt.Ext) + if vOpt.TBR != nil { + label += fmt.Sprintf(", %.0fkbps", *vOpt.TBR) + } + label += ")" + + if len(label) > 100 { + label = label[:97] + "..." + } + + videoMenuOptions = append(videoMenuOptions, discordgo.SelectMenuOption{ + Label: label, + Value: vOpt.FormatID, + }) + + if len(videoMenuOptions) >= 25 { + break + } + } + + // Update components - disable video select, enable audio select updatedComponents := []discordgo.MessageComponent{ discordgo.ActionsRow{ Components: []discordgo.MessageComponent{ @@ -197,20 +308,7 @@ func main() { Placeholder: "Choose a video format...", MaxValues: 1, Disabled: true, - Options: []discordgo.SelectMenuOption{ - { - Label: "1920x1080 1080p (mp4, 1910kbps)", - Value: "1080p", - }, - { - Label: "1280x720 720p (mp4, 700kbps)", - Value: "720p", - }, - { - Label: "640x480 480p (mp4, 300kbps)", - Value: "480p", - }, - }, + Options: videoMenuOptions, }, }, }, @@ -221,16 +319,7 @@ func main() { Placeholder: "Choose an audio format...", MaxValues: 1, Disabled: false, - Options: []discordgo.SelectMenuOption{ - { - Label: "Medium", - Value: "medium", - }, - { - Label: "Low", - Value: "low", - }, - }, + Options: audioMenuOptions, }, }, }, @@ -285,21 +374,25 @@ func main() { // Get selected audio format selectedValues := i.MessageComponentData().Values - audioFormat := "medium" // default + audioFormatID := "" if len(selectedValues) > 0 { - audioFormat = selectedValues[0] + audioFormatID = selectedValues[0] } + // Store selected audio format ID + state.AudioFormatID = audioFormatID + response := "" if state.URL != "" { // Respond immediately to prevent timeout - response = "šŸš€ Starting download...\nURL: " + state.URL + "\nAudio: " + audioFormat + "\n\nYou'll receive an update when the download completes!" + response = fmt.Sprintf("šŸš€ Starting download...\nURL: %s\nVideo: %s\nAudio: %s\n\nYou'll receive an update when the download completes!", + state.URL, state.VideoFormatID, state.AudioFormatID) // Start async download after responding go func() { // Small delay to ensure response is sent first time.Sleep(100 * time.Millisecond) - startAsyncDownload(s, i, state.URL, audioFormat, out_dir) + startAsyncDownload(s, i, state.URL, state.VideoFormatID, state.AudioFormatID, out_dir) }() // Clean up state after starting download @@ -342,36 +435,144 @@ func main() { return } - // Store URL in interaction state (we'll use the response message ID later) - // For now, store with the current token, then update after getting message ID - setInteractionState(i.Interaction.Token, &InteractionState{URL: url}) - + // Send initial "fetching formats" response err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ - Content: "Select a video format:", + Content: "šŸ” Fetching available formats...", Flags: discordgo.MessageFlagsEphemeral, - Components: []discordgo.MessageComponent{ + }, + }) + if err != nil { + log.Printf("Error: %v", err) + return + } + + // Fetch formats asynchronously + go func() { + formatOptions, err := GetFormats(url) + if err != nil { + _, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr("āŒ Error fetching formats: " + err.Error()), + }) + if err != nil { + log.Printf("Error updating interaction: %v", err) + } + return + } + + // Sort video formats: highest resolution first, then by bitrate + sort.Slice(formatOptions.VideoOptions, func(i, j int) bool { + // Compare by height (descending) + heightI := 0 + if formatOptions.VideoOptions[i].Height != nil { + heightI = *formatOptions.VideoOptions[i].Height + } + heightJ := 0 + if formatOptions.VideoOptions[j].Height != nil { + heightJ = *formatOptions.VideoOptions[j].Height + } + + if heightI != heightJ { + return heightI > heightJ + } + + // If heights are equal, compare by TBR (descending) + tbrI := 0.0 + if formatOptions.VideoOptions[i].TBR != nil { + tbrI = *formatOptions.VideoOptions[i].TBR + } + tbrJ := 0.0 + if formatOptions.VideoOptions[j].TBR != nil { + tbrJ = *formatOptions.VideoOptions[j].TBR + } + + return tbrI > tbrJ + }) + + // Sort audio formats: highest bitrate first + sort.Slice(formatOptions.AudioOptions, func(i, j int) bool { + tbrI := 0.0 + if formatOptions.AudioOptions[i].TBR != nil { + tbrI = *formatOptions.AudioOptions[i].TBR + } + tbrJ := 0.0 + if formatOptions.AudioOptions[j].TBR != nil { + tbrJ = *formatOptions.AudioOptions[j].TBR + } + + return tbrI > tbrJ + }) + + // Build video format options for Discord select menu + videoMenuOptions := []discordgo.SelectMenuOption{} + for _, vOpt := range formatOptions.VideoOptions { + label := fmt.Sprintf("%s (%s", vOpt.Resolution, vOpt.Ext) + if vOpt.TBR != nil { + label += fmt.Sprintf(", %.0fkbps", *vOpt.TBR) + } + label += ")" + + // Discord has a 100 char limit on labels + if len(label) > 100 { + label = label[:97] + "..." + } + + videoMenuOptions = append(videoMenuOptions, discordgo.SelectMenuOption{ + Label: label, + Value: vOpt.FormatID, + }) + + // Discord has a limit of 25 options per select menu + if len(videoMenuOptions) >= 25 { + break + } + } + + // Build audio format options for Discord select menu + audioMenuOptions := []discordgo.SelectMenuOption{} + for _, aOpt := range formatOptions.AudioOptions { + label := aOpt.Format + if aOpt.Language != nil { + label += fmt.Sprintf(" [%s]", *aOpt.Language) + } + if aOpt.TBR != nil { + label += fmt.Sprintf(" (%.0fkbps)", *aOpt.TBR) + } + + // Discord has a 100 char limit on labels + if len(label) > 100 { + label = label[:97] + "..." + } + + audioMenuOptions = append(audioMenuOptions, discordgo.SelectMenuOption{ + Label: label, + Value: aOpt.FormatID, + }) + + // Discord has a limit of 25 options per select menu + if len(audioMenuOptions) >= 25 { + break + } + } + + // Store format options in interaction state + setInteractionState(i.Interaction.Token, &InteractionState{ + URL: url, + FormatOptions: formatOptions, + }) + + // Update message with format selection menus + _, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr("Select a video format:"), + Components: &[]discordgo.MessageComponent{ discordgo.ActionsRow{ Components: []discordgo.MessageComponent{ discordgo.SelectMenu{ CustomID: "video_select", Placeholder: "Choose a video format...", MaxValues: 1, - Options: []discordgo.SelectMenuOption{ - { - Label: "1920x1080 1080p (mp4, 1910kbps)", - Value: "1080p", - }, - { - Label: "1280x720 720p (mp4, 700kbps)", - Value: "720p", - }, - { - Label: "640x480 480p (mp4, 300kbps)", - Value: "480p", - }, - }, + Options: videoMenuOptions, }, }, }, @@ -382,25 +583,16 @@ func main() { Placeholder: "Choose an audio format...", MaxValues: 1, Disabled: true, - Options: []discordgo.SelectMenuOption{ - { - Label: "Medium", - Value: "medium", - }, - { - Label: "Low", - Value: "low", - }, - }, + Options: audioMenuOptions, }, }, }, }, - }, - }) - if err != nil { - log.Printf("Error: %v", err) - } + }) + if err != nil { + log.Printf("Error updating interaction: %v", err) + } + }() }, } diff --git a/app/ytdlp.go b/app/ytdlp.go index 41c1fd5..3d3a5ee 100644 --- a/app/ytdlp.go +++ b/app/ytdlp.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "time" "github.com/lrstanley/go-ytdlp" @@ -10,6 +11,29 @@ import ( type DownloadOptions struct { EmbedThumbnail bool IncludeSubtitles bool + VideoFormatID string + AudioFormatID string +} + +type VideoOption struct { + Height *int `json:"height,omitempty"` + Resolution string `json:"resolution,omitempty"` + FormatID string `json:"format_id"` + Ext string `json:"ext"` + TBR *float64 `json:"tbr,omitempty"` +} + +type AudioOption struct { + Format string `json:"format"` + FormatID string `json:"format_id"` + Ext string `json:"ext"` + TBR *float64 `json:"tbr,omitempty"` + Language *string `json:"language,omitempty"` +} + +type FormatOptions struct { + VideoOptions []VideoOption `json:"video_options"` + AudioOptions []AudioOption `json:"audio_options"` } type ProgressUpdate struct { @@ -20,6 +44,89 @@ type ProgressUpdate struct { Phase string } +func GetFormats(url string) (*FormatOptions, error) { + dl := ytdlp.New(). + SkipDownload(). + DumpJSON() + + result, err := dl.Run(context.TODO(), url) + if err != nil { + return nil, err + } + + // Parse the JSON output + var info struct { + Formats []struct { + VCodec *string `json:"vcodec"` + ACodec *string `json:"acodec"` + NeedsTesting *bool `json:"__needs_testing"` + Height *int `json:"height"` + Resolution string `json:"resolution"` + FormatID string `json:"format_id"` + Format string `json:"format"` + Ext string `json:"ext"` + TBR *float64 `json:"tbr"` + Language *string `json:"language"` + LanguagePref *int `json:"language_preference"` + URL *string `json:"url"` + Protocol *string `json:"protocol"` + } `json:"formats"` + } + + if err := json.Unmarshal([]byte(result.Stdout), &info); err != nil { + return nil, err + } + + formatOpts := &FormatOptions{ + VideoOptions: []VideoOption{}, + AudioOptions: []AudioOption{}, + } + + for _, fmt := range info.Formats { + // Skip formats that need testing + if fmt.NeedsTesting != nil && *fmt.NeedsTesting { + continue + } + + // Skip SABR formats (https://github.com/yt-dlp/yt-dlp/issues/12482) + if fmt.URL == nil || *fmt.URL == "" { + continue + } + + // Video-only: has video codec but no audio codec + if fmt.VCodec != nil && *fmt.VCodec != "none" && + fmt.ACodec != nil && *fmt.ACodec == "none" { + formatOpts.VideoOptions = append(formatOpts.VideoOptions, VideoOption{ + Height: fmt.Height, + Resolution: fmt.Resolution, + FormatID: fmt.FormatID, + Ext: fmt.Ext, + TBR: fmt.TBR, + }) + } + + // Audio-only: has audio codec but no video codec + if fmt.ACodec != nil && *fmt.ACodec != "none" && + fmt.VCodec != nil && *fmt.VCodec == "none" { + audioOpt := AudioOption{ + Format: fmt.Format, + FormatID: fmt.FormatID, + Ext: fmt.Ext, + TBR: fmt.TBR, + } + + // Use language if available, otherwise use language_preference + if fmt.Language != nil { + audioOpt.Language = fmt.Language + } + + formatOpts.AudioOptions = append(formatOpts.AudioOptions, audioOpt) + } + } + + return formatOpts, nil +} + func downloadVideo(out_dir, url string, opts DownloadOptions, progressChan chan<- ProgressUpdate) { defer close(progressChan) @@ -27,7 +134,6 @@ func downloadVideo(out_dir, url string, opts DownloadOptions, progressChan chan< dl := ytdlp.New(). SetWorkDir(out_dir). - FormatSort("res,ext:mp4:m4a"). RecodeVideo("mp4"). ProgressFunc(100*time.Millisecond, func(prog ytdlp.ProgressUpdate) { // Detect phase transition -- differentiate "downloading" as the main download @@ -54,6 +160,18 @@ func downloadVideo(out_dir, url string, opts DownloadOptions, progressChan chan< }). Output("%(title)s.%(ext)s") + // Set format if both video and audio format IDs are provided + if opts.VideoFormatID != "" && opts.AudioFormatID != "" { + dl = dl.Format(opts.VideoFormatID + "+" + opts.AudioFormatID) + } else if opts.VideoFormatID != "" { + dl = dl.Format(opts.VideoFormatID) + } else if opts.AudioFormatID != "" { + dl = dl.Format(opts.AudioFormatID) + } else { + // Default format selection if none specified + dl = dl.FormatSort("res,ext:mp4:m4a") + } + if opts.EmbedThumbnail { dl = dl.EmbedThumbnail() } -- 2.49.1 From 4f34872f10a86fde42bca57661249d4958a3d05c Mon Sep 17 00:00:00 2001 From: William P Date: Tue, 10 Feb 2026 21:59:30 -0500 Subject: [PATCH 12/16] code cleanup --- app/main.go | 57 +++---------------------------------------------- app/misc.go | 6 ++++++ app/state.go | 27 +++++++++++++++++++++++ app/types.go | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++ app/ytdlp.go | 38 +-------------------------------- 5 files changed, 97 insertions(+), 91 deletions(-) create mode 100644 app/misc.go create mode 100644 app/state.go create mode 100644 app/types.go diff --git a/app/main.go b/app/main.go index a40de2e..77d8780 100644 --- a/app/main.go +++ b/app/main.go @@ -6,30 +6,12 @@ import ( "os" "os/signal" "sort" - "sync" "syscall" "time" "github.com/bwmarrin/discordgo" ) -// InteractionState holds the state for a specific interaction -type InteractionState struct { - URL string - FormatOptions *FormatOptions - VideoFormatID string - AudioFormatID string -} - -// DownloadResult represents the result of an async download operation -type DownloadResult struct { - Success bool - Message string - URL string - Format string - Error error -} - // startAsyncDownload initiates a download in a goroutine and handles progress updates func startAsyncDownload(s *discordgo.Session, i *discordgo.InteractionCreate, url, videoFormatID, audioFormatID, outputDir string) { progressChan := make(chan ProgressUpdate, 1) @@ -40,7 +22,7 @@ func startAsyncDownload(s *discordgo.Session, i *discordgo.InteractionCreate, ur defer close(resultChan) defer func() { if r := recover(); r != nil { - // Handle panic from downloadVideo + // Handle panic from DownloadVideo resultChan <- DownloadResult{ Success: false, Message: fmt.Sprintf("Download failed: %v", r), @@ -51,8 +33,8 @@ func startAsyncDownload(s *discordgo.Session, i *discordgo.InteractionCreate, ur } }() - // Call downloadVideo (it panics on error instead of returning error) - downloadVideo(outputDir, url, DownloadOptions{ + // Call DownloadVideo (it panics on error instead of returning error) + DownloadVideo(outputDir, url, DownloadOptions{ EmbedThumbnail: true, IncludeSubtitles: true, VideoFormatID: videoFormatID, @@ -127,35 +109,6 @@ func startAsyncDownload(s *discordgo.Session, i *discordgo.InteractionCreate, ur }() } -// Helper function to create string pointer -func ptr(s string) *string { - return &s -} - -// Global state management -var ( - interactionStates = make(map[string]*InteractionState) - interactionStatesMutex = sync.RWMutex{} -) - -func getInteractionState(token string) *InteractionState { - interactionStatesMutex.RLock() - defer interactionStatesMutex.RUnlock() - return interactionStates[token] -} - -func setInteractionState(token string, state *InteractionState) { - interactionStatesMutex.Lock() - defer interactionStatesMutex.Unlock() - interactionStates[token] = state -} - -func deleteInteractionState(token string) { - interactionStatesMutex.Lock() - defer interactionStatesMutex.Unlock() - delete(interactionStates, token) -} - func main() { out_dir := os.Getenv("OUT_PATH") @@ -631,8 +584,4 @@ func main() { <-sc s.Close() - - //var url string = "https://www.youtube.com/watch?v=WpBWSFF03eI" - - //downloadVideo(out_dir, url, DownloadOptions{EmbedThumbnail: true, IncludeSubtitles: true}) } diff --git a/app/misc.go b/app/misc.go new file mode 100644 index 0000000..2afa308 --- /dev/null +++ b/app/misc.go @@ -0,0 +1,6 @@ +package main + +// Helper function to create string pointer +func ptr(s string) *string { + return &s +} diff --git a/app/state.go b/app/state.go new file mode 100644 index 0000000..08220e2 --- /dev/null +++ b/app/state.go @@ -0,0 +1,27 @@ +package main + +import "sync" + +// Global state management +var ( + interactionStates = make(map[string]*InteractionState) + interactionStatesMutex = sync.RWMutex{} +) + +func getInteractionState(token string) *InteractionState { + interactionStatesMutex.RLock() + defer interactionStatesMutex.RUnlock() + return interactionStates[token] +} + +func setInteractionState(token string, state *InteractionState) { + interactionStatesMutex.Lock() + defer interactionStatesMutex.Unlock() + interactionStates[token] = state +} + +func deleteInteractionState(token string) { + interactionStatesMutex.Lock() + defer interactionStatesMutex.Unlock() + delete(interactionStates, token) +} diff --git a/app/types.go b/app/types.go new file mode 100644 index 0000000..a0deeda --- /dev/null +++ b/app/types.go @@ -0,0 +1,60 @@ +package main + +import ( + "time" + + "github.com/lrstanley/go-ytdlp" +) + +type DownloadOptions struct { + EmbedThumbnail bool + IncludeSubtitles bool + VideoFormatID string + AudioFormatID string +} + +type VideoOption struct { + Height *int `json:"height,omitempty"` + Resolution string `json:"resolution,omitempty"` + FormatID string `json:"format_id"` + Ext string `json:"ext"` + TBR *float64 `json:"tbr,omitempty"` +} + +type AudioOption struct { + Format string `json:"format"` + FormatID string `json:"format_id"` + Ext string `json:"ext"` + TBR *float64 `json:"tbr,omitempty"` + Language *string `json:"language,omitempty"` +} + +type FormatOptions struct { + VideoOptions []VideoOption `json:"video_options"` + AudioOptions []AudioOption `json:"audio_options"` +} + +type ProgressUpdate struct { + Status ytdlp.ProgressStatus + Percent string + ETA time.Duration + Filename string + Phase string +} + +// InteractionState holds the state for a specific interaction +type InteractionState struct { + URL string + FormatOptions *FormatOptions + VideoFormatID string + AudioFormatID string +} + +// DownloadResult represents the result of an async download operation +type DownloadResult struct { + Success bool + Message string + URL string + Format string + Error error +} diff --git a/app/ytdlp.go b/app/ytdlp.go index 3d3a5ee..e388a57 100644 --- a/app/ytdlp.go +++ b/app/ytdlp.go @@ -8,42 +8,6 @@ import ( "github.com/lrstanley/go-ytdlp" ) -type DownloadOptions struct { - EmbedThumbnail bool - IncludeSubtitles bool - VideoFormatID string - AudioFormatID string -} - -type VideoOption struct { - Height *int `json:"height,omitempty"` - Resolution string `json:"resolution,omitempty"` - FormatID string `json:"format_id"` - Ext string `json:"ext"` - TBR *float64 `json:"tbr,omitempty"` -} - -type AudioOption struct { - Format string `json:"format"` - FormatID string `json:"format_id"` - Ext string `json:"ext"` - TBR *float64 `json:"tbr,omitempty"` - Language *string `json:"language,omitempty"` -} - -type FormatOptions struct { - VideoOptions []VideoOption `json:"video_options"` - AudioOptions []AudioOption `json:"audio_options"` -} - -type ProgressUpdate struct { - Status ytdlp.ProgressStatus - Percent string - ETA time.Duration - Filename string - Phase string -} - func GetFormats(url string) (*FormatOptions, error) { dl := ytdlp.New(). SkipDownload(). @@ -127,7 +91,7 @@ func GetFormats(url string) (*FormatOptions, error) { return formatOpts, nil } -func downloadVideo(out_dir, url string, opts DownloadOptions, progressChan chan<- ProgressUpdate) { +func DownloadVideo(out_dir, url string, opts DownloadOptions, progressChan chan<- ProgressUpdate) { defer close(progressChan) var lastPhase string -- 2.49.1 From 63a6c3807980217a7a78981ec77a4167c8c27210 Mon Sep 17 00:00:00 2001 From: William P Date: Tue, 10 Feb 2026 22:00:34 -0500 Subject: [PATCH 13/16] create download.go for async download function --- app/download.go | 105 ++++++++++++++++++++++++++++++++++++++++++++++++ app/main.go | 97 -------------------------------------------- 2 files changed, 105 insertions(+), 97 deletions(-) create mode 100644 app/download.go diff --git a/app/download.go b/app/download.go new file mode 100644 index 0000000..3a73f93 --- /dev/null +++ b/app/download.go @@ -0,0 +1,105 @@ +package main + +import ( + "fmt" + "log" + + "github.com/bwmarrin/discordgo" +) + +// startAsyncDownload initiates a download in a goroutine and handles progress updates +func startAsyncDownload(s *discordgo.Session, i *discordgo.InteractionCreate, url, videoFormatID, audioFormatID, outputDir string) { + progressChan := make(chan ProgressUpdate, 1) + resultChan := make(chan DownloadResult, 1) + + // Start download in goroutine + go func() { + defer close(resultChan) + defer func() { + if r := recover(); r != nil { + // Handle panic from DownloadVideo + resultChan <- DownloadResult{ + Success: false, + Message: fmt.Sprintf("Download failed: %v", r), + URL: url, + Format: fmt.Sprintf("video: %s, audio: %s", videoFormatID, audioFormatID), + Error: fmt.Errorf("%v", r), + } + } + }() + + // Call DownloadVideo (it panics on error instead of returning error) + DownloadVideo(outputDir, url, DownloadOptions{ + EmbedThumbnail: true, + IncludeSubtitles: true, + VideoFormatID: videoFormatID, + AudioFormatID: audioFormatID, + }, progressChan) + + // If we reach here, download was successful + resultChan <- DownloadResult{ + Success: true, + Message: "Video Downloaded Successfully!", + URL: url, + Format: fmt.Sprintf("video: %s, audio: %s", videoFormatID, audioFormatID), + Error: nil, + } + }() + + // Handle progress and results asynchronously + go func() { + // First update the original ephemeral message with "Processing..." + _, err := s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr(fmt.Sprintf("šŸ”„ Processing download...\nURL: %s\nVideo: %s\nAudio: %s", url, videoFormatID, audioFormatID)), + }) + if err != nil { + log.Printf("Error updating interaction: %v", err) + } + + for { + select { + case prog, ok := <-progressChan: + if !ok { + progressChan = nil + continue + } + // Update message w/ phase and real time progress + phaseEmoji := "ā¬" + if prog.Phase == "post-processing" { + phaseEmoji = "āš™ļø" + } + content := fmt.Sprintf("%s %s\n%s @ %s [eta: %s]\nšŸ“„ %s", + phaseEmoji, + prog.Phase, + prog.Status, + prog.Percent, + prog.ETA, + prog.Filename) + _, err := s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr(content), + }) + if err != nil { + log.Printf("Error updating progress: %v", err) + } + case result := <-resultChan: + // Handle completion + if result.Success { + _, err = s.FollowupMessageCreate(i.Interaction, false, &discordgo.WebhookParams{ + Content: "šŸ“„ Video downloaded: " + result.URL, + }) + if err != nil { + log.Printf("Error updating interaction: %v", err) + } + } else { + _, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: ptr("āŒ Download failed: " + result.Message), + }) + if err != nil { + log.Printf("Error updating interaction: %v", err) + } + } + return + } + } + }() +} diff --git a/app/main.go b/app/main.go index 77d8780..9eff50e 100644 --- a/app/main.go +++ b/app/main.go @@ -12,103 +12,6 @@ import ( "github.com/bwmarrin/discordgo" ) -// startAsyncDownload initiates a download in a goroutine and handles progress updates -func startAsyncDownload(s *discordgo.Session, i *discordgo.InteractionCreate, url, videoFormatID, audioFormatID, outputDir string) { - progressChan := make(chan ProgressUpdate, 1) - resultChan := make(chan DownloadResult, 1) - - // Start download in goroutine - go func() { - defer close(resultChan) - defer func() { - if r := recover(); r != nil { - // Handle panic from DownloadVideo - resultChan <- DownloadResult{ - Success: false, - Message: fmt.Sprintf("Download failed: %v", r), - URL: url, - Format: fmt.Sprintf("video: %s, audio: %s", videoFormatID, audioFormatID), - Error: fmt.Errorf("%v", r), - } - } - }() - - // Call DownloadVideo (it panics on error instead of returning error) - DownloadVideo(outputDir, url, DownloadOptions{ - EmbedThumbnail: true, - IncludeSubtitles: true, - VideoFormatID: videoFormatID, - AudioFormatID: audioFormatID, - }, progressChan) - - // If we reach here, download was successful - resultChan <- DownloadResult{ - Success: true, - Message: "Video Downloaded Successfully!", - URL: url, - Format: fmt.Sprintf("video: %s, audio: %s", videoFormatID, audioFormatID), - Error: nil, - } - }() - - // Handle progress and results asynchronously - go func() { - // First update the original ephemeral message with "Processing..." - _, err := s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ - Content: ptr(fmt.Sprintf("šŸ”„ Processing download...\nURL: %s\nVideo: %s\nAudio: %s", url, videoFormatID, audioFormatID)), - }) - if err != nil { - log.Printf("Error updating interaction: %v", err) - } - - for { - select { - case prog, ok := <-progressChan: - if !ok { - progressChan = nil - continue - } - // Update message w/ phase and real time progress - phaseEmoji := "ā¬" - if prog.Phase == "post-processing" { - phaseEmoji = "āš™ļø" - } - content := fmt.Sprintf("%s %s\n%s @ %s [eta: %s]\nšŸ“„ %s", - phaseEmoji, - prog.Phase, - prog.Status, - prog.Percent, - prog.ETA, - prog.Filename) - _, err := s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ - Content: ptr(content), - }) - if err != nil { - log.Printf("Error updating progress: %v", err) - } - case result := <-resultChan: - // Handle completion - if result.Success { - _, err = s.FollowupMessageCreate(i.Interaction, false, &discordgo.WebhookParams{ - Content: "šŸ“„ Video downloaded: " + result.URL, - }) - if err != nil { - log.Printf("Error updating interaction: %v", err) - } - } else { - _, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ - Content: ptr("āŒ Download failed: " + result.Message), - }) - if err != nil { - log.Printf("Error updating interaction: %v", err) - } - } - return - } - } - }() -} - func main() { out_dir := os.Getenv("OUT_PATH") -- 2.49.1 From bd643344ef891772328b20996545bdf3e503f775 Mon Sep 17 00:00:00 2001 From: William P Date: Wed, 11 Feb 2026 20:40:43 -0500 Subject: [PATCH 14/16] direct requirements --- app/go.mod | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/go.mod b/app/go.mod index 5fbb7f8..ba5944a 100644 --- a/app/go.mod +++ b/app/go.mod @@ -2,12 +2,15 @@ module git.dubyatp.xyz/williamp/yt-dlp-bot go 1.25.2 +require ( + github.com/bwmarrin/discordgo v0.29.0 + github.com/lrstanley/go-ytdlp v1.2.7 +) + require ( github.com/ProtonMail/go-crypto v1.3.0 // indirect - github.com/bwmarrin/discordgo v0.29.0 // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/gorilla/websocket v1.4.2 // indirect - github.com/lrstanley/go-ytdlp v1.2.7 // indirect github.com/ulikunitz/xz v0.5.13 // indirect golang.org/x/crypto v0.41.0 // indirect golang.org/x/sys v0.35.0 // indirect -- 2.49.1 From a2a0aabb3ad6de9d63bcbc6fe9e35c80a0f13542 Mon Sep 17 00:00:00 2001 From: William P Date: Thu, 12 Feb 2026 23:26:08 -0500 Subject: [PATCH 15/16] downlad into temp folder then move to out folder when done, temp replace with my fork until upstream PR is merged --- app/download.go | 4 ++-- app/go.mod | 4 ++++ app/go.sum | 12 ++++++++++-- app/main.go | 7 ++++++- app/ytdlp.go | 8 ++++++-- 5 files changed, 28 insertions(+), 7 deletions(-) diff --git a/app/download.go b/app/download.go index 3a73f93..5099deb 100644 --- a/app/download.go +++ b/app/download.go @@ -8,7 +8,7 @@ import ( ) // startAsyncDownload initiates a download in a goroutine and handles progress updates -func startAsyncDownload(s *discordgo.Session, i *discordgo.InteractionCreate, url, videoFormatID, audioFormatID, outputDir string) { +func startAsyncDownload(s *discordgo.Session, i *discordgo.InteractionCreate, url, videoFormatID, audioFormatID, outputDir, tempDir string) { progressChan := make(chan ProgressUpdate, 1) resultChan := make(chan DownloadResult, 1) @@ -29,7 +29,7 @@ func startAsyncDownload(s *discordgo.Session, i *discordgo.InteractionCreate, ur }() // Call DownloadVideo (it panics on error instead of returning error) - DownloadVideo(outputDir, url, DownloadOptions{ + DownloadVideo(outputDir, tempDir, url, DownloadOptions{ EmbedThumbnail: true, IncludeSubtitles: true, VideoFormatID: videoFormatID, diff --git a/app/go.mod b/app/go.mod index ba5944a..efed80e 100644 --- a/app/go.mod +++ b/app/go.mod @@ -2,6 +2,10 @@ module git.dubyatp.xyz/williamp/yt-dlp-bot go 1.25.2 +//replace github.com/lrstanley/go-ytdlp => /home/williamp/go-ytdlp + +replace github.com/lrstanley/go-ytdlp => github.com/dubyatp/go-ytdlp v0.0.0-20260213041320-010f95f0f1d3 + require ( github.com/bwmarrin/discordgo v0.29.0 github.com/lrstanley/go-ytdlp v1.2.7 diff --git a/app/go.sum b/app/go.sum index 8bfee9d..bee45b3 100644 --- a/app/go.sum +++ b/app/go.sum @@ -4,10 +4,16 @@ github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+Eg github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dubyatp/go-ytdlp v0.0.0-20260213041320-010f95f0f1d3 h1:tGzvzV6fSzybhIpwU+wjvjOSq8RBNtHENm+G03E+mgo= +github.com/dubyatp/go-ytdlp v0.0.0-20260213041320-010f95f0f1d3/go.mod h1:38IL64XM6gULrWtKTiR0+TTNCVbxesNSbTyaFG2CGTI= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/lrstanley/go-ytdlp v1.2.7 h1:YNDvKkd0OCJSZLZePZvJwcirBCfL8Yw3eCwrTCE5w7Q= -github.com/lrstanley/go-ytdlp v1.2.7/go.mod h1:38IL64XM6gULrWtKTiR0+TTNCVbxesNSbTyaFG2CGTI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/ulikunitz/xz v0.5.13 h1:ar98gWrjf4H1ev05fYP/o29PDZw9DrI3niHtnEqyuXA= github.com/ulikunitz/xz v0.5.13/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= @@ -20,3 +26,5 @@ golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/app/main.go b/app/main.go index 9eff50e..bab02b7 100644 --- a/app/main.go +++ b/app/main.go @@ -15,12 +15,17 @@ import ( func main() { out_dir := os.Getenv("OUT_PATH") + temp_dir := os.Getenv("TEMP_PATH") bot_token := os.Getenv("DISCORD_TOKEN") if out_dir == "" { panic("No output dir specified") } + if temp_dir == "" { + panic("No temp dir specified") + } + s, err := discordgo.New("Bot " + bot_token) if err != nil { log.Fatalf("Invalid bot parameters: %v", err) @@ -248,7 +253,7 @@ func main() { go func() { // Small delay to ensure response is sent first time.Sleep(100 * time.Millisecond) - startAsyncDownload(s, i, state.URL, state.VideoFormatID, state.AudioFormatID, out_dir) + startAsyncDownload(s, i, state.URL, state.VideoFormatID, state.AudioFormatID, out_dir, temp_dir) }() // Clean up state after starting download diff --git a/app/ytdlp.go b/app/ytdlp.go index e388a57..cabd313 100644 --- a/app/ytdlp.go +++ b/app/ytdlp.go @@ -91,13 +91,17 @@ func GetFormats(url string) (*FormatOptions, error) { return formatOpts, nil } -func DownloadVideo(out_dir, url string, opts DownloadOptions, progressChan chan<- ProgressUpdate) { +func DownloadVideo(out_dir, temp_dir, url string, opts DownloadOptions, progressChan chan<- ProgressUpdate) { defer close(progressChan) + homePath := "home:" + out_dir + tempPath := "temp:" + temp_dir + var lastPhase string dl := ytdlp.New(). - SetWorkDir(out_dir). + Paths(homePath). + Paths(tempPath). RecodeVideo("mp4"). ProgressFunc(100*time.Millisecond, func(prog ytdlp.ProgressUpdate) { // Detect phase transition -- differentiate "downloading" as the main download -- 2.49.1 From 600fde2e71d7d93b73eb804c1cae08091b5e8cc2 Mon Sep 17 00:00:00 2001 From: William P Date: Thu, 12 Feb 2026 23:27:02 -0500 Subject: [PATCH 16/16] define static location for yt-dlp binary --- app/ytdlp.go | 5 +++++ flake.nix | 2 ++ 2 files changed, 7 insertions(+) diff --git a/app/ytdlp.go b/app/ytdlp.go index cabd313..ae72ab3 100644 --- a/app/ytdlp.go +++ b/app/ytdlp.go @@ -3,13 +3,17 @@ package main import ( "context" "encoding/json" + "os" "time" "github.com/lrstanley/go-ytdlp" ) +var ytdlpBinary = os.Getenv("YTDLP_BIN") + func GetFormats(url string) (*FormatOptions, error) { dl := ytdlp.New(). + SetExecutable(ytdlpBinary). SkipDownload(). DumpJSON() @@ -100,6 +104,7 @@ func DownloadVideo(out_dir, temp_dir, url string, opts DownloadOptions, progress var lastPhase string dl := ytdlp.New(). + SetExecutable(ytdlpBinary). Paths(homePath). Paths(tempPath). RecodeVideo("mp4"). diff --git a/flake.nix b/flake.nix index 6877dfa..f15805a 100644 --- a/flake.nix +++ b/flake.nix @@ -46,6 +46,8 @@ .venv/bin/pip install -r ./app/requirements.txt source .venv/bin/activate fi + + export YTDLP_BIN=${pkgs.lib.getExe pkgs.yt-dlp} ''; }; }); -- 2.49.1