|
| 1 | +import customtkinter as ctk |
| 2 | +import tkinter as tk |
| 3 | +import sys |
| 4 | +import os, threading, yt_dlp |
| 5 | +from urllib.request import build_opener, install_opener |
| 6 | + |
| 7 | +opener = build_opener() |
| 8 | +opener.addheaders = [ |
| 9 | + ( |
| 10 | + "User-Agent", |
| 11 | + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", |
| 12 | + ) |
| 13 | +] |
| 14 | +install_opener(opener) |
| 15 | + |
| 16 | +# --------------------------------------------------------------------------- |
| 17 | +# Helper to load window icon (works for normal run & PyInstaller bundle) |
| 18 | +# --------------------------------------------------------------------------- |
| 19 | + |
| 20 | +def load_icon(filename: str) -> tk.PhotoImage: |
| 21 | + """Return a Tk PhotoImage for the given filename. |
| 22 | + The path is resolved correctly when bundled with PyInstaller.""" |
| 23 | + base_path = getattr(sys, "_MEIPASS", os.path.abspath(".")) |
| 24 | + return tk.PhotoImage(file=os.path.join(base_path, filename)) |
| 25 | +ctk.set_appearance_mode("dark") |
| 26 | +ctk.set_default_color_theme("blue") |
| 27 | +class OrangYT(ctk.CTk): |
| 28 | + def __init__(self): |
| 29 | + super().__init__() |
| 30 | + self.title("OrangYT Downloader") |
| 31 | + self.geometry("600x450") |
| 32 | + self.orange_colour = "#ff6b00" |
| 33 | + self.configure(fg_color=("gray90", "gray16")) |
| 34 | + # Set application icon |
| 35 | + self.icon = load_icon("orange.png") # keep reference to avoid GC |
| 36 | + self.iconphoto(False, self.icon) |
| 37 | + self.url_label = ctk.CTkLabel(self, text="Enter YouTube URL:", text_color=self.orange_colour) |
| 38 | + self.url_label.pack(pady=10) |
| 39 | + self.url_entry = ctk.CTkEntry(self, width=400, border_color=self.orange_colour, fg_color=("gray95", "gray20")) |
| 40 | + self.url_entry.pack(pady=5) |
| 41 | + self.format_frame = ctk.CTkFrame(self, fg_color="transparent") |
| 42 | + self.format_frame.pack(pady=20) |
| 43 | + self.format_var = ctk.StringVar(value="video") |
| 44 | + self.video_radio = ctk.CTkRadioButton( |
| 45 | + self.format_frame, |
| 46 | + text="Video", |
| 47 | + variable=self.format_var, |
| 48 | + value="video", |
| 49 | + command=self._update_format_options, |
| 50 | + fg_color=self.orange_colour, |
| 51 | + border_color=self.orange_colour, |
| 52 | + ) |
| 53 | + self.video_radio.pack(side="left", padx=10) |
| 54 | + self.audio_radio = ctk.CTkRadioButton( |
| 55 | + self.format_frame, |
| 56 | + text="Audio", |
| 57 | + variable=self.format_var, |
| 58 | + value="audio", |
| 59 | + command=self._update_format_options, |
| 60 | + fg_color=self.orange_colour, |
| 61 | + border_color=self.orange_colour, |
| 62 | + ) |
| 63 | + self.audio_radio.pack(side="left", padx=10) |
| 64 | + self.format_options_frame = ctk.CTkFrame(self, fg_color="transparent") |
| 65 | + self.format_options_frame.pack(pady=10) |
| 66 | + self.format_option_var = ctk.StringVar() |
| 67 | + self.format_menu = ctk.CTkOptionMenu( |
| 68 | + self.format_options_frame, |
| 69 | + variable=self.format_option_var, |
| 70 | + fg_color=self.orange_colour, |
| 71 | + button_color=self.orange_colour, |
| 72 | + button_hover_color=self.orange_colour, |
| 73 | + dropdown_hover_color=self.orange_colour, |
| 74 | + ) |
| 75 | + self.format_menu.pack(pady=10) |
| 76 | + self.download_button = ctk.CTkButton( |
| 77 | + self, |
| 78 | + text="Download", |
| 79 | + command=self._start_download_thread, |
| 80 | + fg_color=self.orange_colour, |
| 81 | + hover_color=self.orange_colour, |
| 82 | + height=40, |
| 83 | + ) |
| 84 | + self.download_button.pack(pady=20) |
| 85 | + self.progress_label = ctk.CTkLabel(self, text="", text_color=self.orange_colour) |
| 86 | + self.progress_label.pack(pady=10) |
| 87 | + # Available video formats (removed webm to avoid unwanted container) |
| 88 | + self.video_formats = ["mp4", "mkv"] |
| 89 | + self.audio_formats = ["mp3", "flac", "ogg", "wav", "m4a"] |
| 90 | + self._update_format_options() |
| 91 | + def _update_format_options(self): |
| 92 | + formats = self.video_formats if self.format_var.get() == "video" else self.audio_formats |
| 93 | + # Update dropdown values correctly using configure (required by customtkinter) |
| 94 | + self.format_menu.configure(values=formats) |
| 95 | + # Keep current selection if still valid, otherwise set to first option |
| 96 | + if self.format_option_var.get() not in formats: |
| 97 | + self.format_option_var.set(formats[0]) |
| 98 | + |
| 99 | + def _start_download_thread(self): |
| 100 | + self.download_button.configure(state="disabled") |
| 101 | + self.progress_label.configure(text="Starting download…") |
| 102 | + threading.Thread(target=self._download, daemon=True).start() |
| 103 | + # ------------------------------------------------------------------- |
| 104 | + # core download logic using yt-dlp |
| 105 | + # ------------------------------------------------------------------- |
| 106 | + def _download(self): |
| 107 | + try: |
| 108 | + url = self.url_entry.get().strip() |
| 109 | + if not url: |
| 110 | + raise ValueError("Please enter a YouTube URL") |
| 111 | + if "youtu.be" in url and not url.startswith("https://www.youtube.com/watch?v="): |
| 112 | + video_id = url.split("/")[-1] |
| 113 | + url = f"https://www.youtube.com/watch?v={video_id}" |
| 114 | + if not url.startswith(("http://", "https://")): |
| 115 | + url = "https://" + url |
| 116 | + format_type = self.format_var.get() |
| 117 | + selected_format = self.format_option_var.get() |
| 118 | + def hook(d): |
| 119 | + status = d.get("status") |
| 120 | + if status == "downloading": |
| 121 | + downloaded = d.get("downloaded_bytes", 0) |
| 122 | + total = d.get("total_bytes") or d.get("total_bytes_estimate") |
| 123 | + if total: |
| 124 | + pct_float = downloaded / total * 100 |
| 125 | + mb_downloaded = downloaded / 1024 / 1024 |
| 126 | + mb_total = total / 1024 / 1024 |
| 127 | + self.progress_label.configure( |
| 128 | + text=f"Downloading: {pct_float:.1f}% (" \ |
| 129 | + f"{mb_downloaded:.1f}/{mb_total:.1f} MB)" |
| 130 | + ) |
| 131 | + else: |
| 132 | + # Fallback to percent string if total unknown |
| 133 | + pct = d.get("_percent_str", "0% ").strip().rstrip("%") |
| 134 | + try: |
| 135 | + pct_float = float(pct) |
| 136 | + self.progress_label.configure(text=f"Downloading: {pct_float:.1f}%") |
| 137 | + except ValueError: |
| 138 | + pass |
| 139 | + elif status == "finished": |
| 140 | + self.progress_label.configure(text="Post-processing…") |
| 141 | + ydl_opts = { |
| 142 | + "quiet": True, |
| 143 | + "progress_hooks": [hook], |
| 144 | + # Correct template placeholders to include actual title and extension |
| 145 | + "outtmpl": "%(title)s.%(ext)s", |
| 146 | + # Remove intermediary files created during post-processing |
| 147 | + "keepvideo": False, |
| 148 | + } |
| 149 | + if format_type == "video": |
| 150 | + # Select streams that match chosen container when possible |
| 151 | + if selected_format == "mp4": |
| 152 | + # Prefer MP4 video+audio streams to avoid later conversion |
| 153 | + ydl_opts["format"] = ( |
| 154 | + "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best" |
| 155 | + ) |
| 156 | + else: |
| 157 | + # Grab best quality, we'll convert afterwards |
| 158 | + ydl_opts["format"] = "bestvideo+bestaudio/best" |
| 159 | + |
| 160 | + # Always run video converter so final container matches choice |
| 161 | + ydl_opts["merge_output_format"] = selected_format # ensure final container |
| 162 | + ydl_opts["postprocessors"] = [ |
| 163 | + { |
| 164 | + "key": "FFmpegVideoConvertor", |
| 165 | + "preferedformat": selected_format, |
| 166 | + } |
| 167 | + ] |
| 168 | + else: |
| 169 | + ydl_opts["format"] = "bestaudio/best" |
| 170 | + # Map GUI selection to correct ffmpeg codec name if necessary |
| 171 | + codec_map = { |
| 172 | + "ogg": "vorbis", # OGG container uses the Vorbis codec |
| 173 | + } |
| 174 | + ydl_opts["postprocessors"] = [ |
| 175 | + { |
| 176 | + "key": "FFmpegExtractAudio", |
| 177 | + "preferredcodec": codec_map.get(selected_format, selected_format), |
| 178 | + "preferredquality": "192", |
| 179 | + } |
| 180 | + ] |
| 181 | + with yt_dlp.YoutubeDL(ydl_opts) as ydl: |
| 182 | + info = ydl.extract_info(url, download=True) |
| 183 | + output_path = ydl.prepare_filename(info) |
| 184 | + |
| 185 | + # --- cleanup of any leftover temporary files (e.g., webm, m4a) --- |
| 186 | + base_title = os.path.splitext(output_path)[0] |
| 187 | + for ext in ("webm", "m4a", "mp4", "mkv"): |
| 188 | + tmp_path = f"{base_title}.{ext}" |
| 189 | + # keep the final chosen file, delete others if present |
| 190 | + if ext != selected_format and os.path.exists(tmp_path): |
| 191 | + try: |
| 192 | + os.remove(tmp_path) |
| 193 | + except OSError: |
| 194 | + pass |
| 195 | + |
| 196 | + self.progress_label.configure( |
| 197 | + text=f"Download complete!\nSaved as: {os.path.basename(base_title + '.' + selected_format)}" |
| 198 | + ) |
| 199 | + except Exception as exc: |
| 200 | + self.progress_label.configure(text=f"Error: {exc}") |
| 201 | + finally: |
| 202 | + self.download_button.configure(state="normal") |
| 203 | +if __name__ == "__main__": |
| 204 | + app = OrangYT() |
| 205 | + app.mainloop() |
0 commit comments