|
| 1 | +import os |
| 2 | +import threading |
| 3 | +import subprocess |
| 4 | +import tkinter as tk |
| 5 | +from tkinter import messagebox |
| 6 | +from PIL import Image, ImageTk |
| 7 | +import requests |
| 8 | +import io |
| 9 | +import ttkbootstrap as tb |
| 10 | +from ttkbootstrap.constants import * |
| 11 | + |
| 12 | +# ---------------- HELPERS ---------------- # |
| 13 | +def format_size(bytes_size): |
| 14 | + if not bytes_size: |
| 15 | + return "N/A" |
| 16 | + try: |
| 17 | + bytes_size = int(bytes_size) |
| 18 | + except: |
| 19 | + return "N/A" |
| 20 | + for unit in ['B','KB','MB','GB','TB']: |
| 21 | + if bytes_size < 1024: |
| 22 | + return f"{bytes_size:.1f} {unit}" |
| 23 | + bytes_size /= 1024 |
| 24 | + return f"{bytes_size:.1f} PB" |
| 25 | + |
| 26 | +def run_command(command): |
| 27 | + try: |
| 28 | + proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) |
| 29 | + output_lines = [] |
| 30 | + for line in proc.stdout: |
| 31 | + output_lines.append(line.strip()) |
| 32 | + proc.wait() |
| 33 | + return output_lines |
| 34 | + except Exception as e: |
| 35 | + messagebox.showerror("Error", str(e)) |
| 36 | + return [] |
| 37 | + |
| 38 | +def download_with_progress(video_url, fmt_code, progress_var): |
| 39 | + """Download selected format and update progress bar""" |
| 40 | + downloads_folder = "downloads" |
| 41 | + os.makedirs(downloads_folder, exist_ok=True) |
| 42 | + output_template = os.path.join(downloads_folder, "%(title)s.%(ext)s") |
| 43 | + command = [ |
| 44 | + "yt-dlp", "-f", fmt_code, "-o", output_template, "--newline", video_url |
| 45 | + ] |
| 46 | + try: |
| 47 | + proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) |
| 48 | + for line in proc.stdout: |
| 49 | + if "%" in line: |
| 50 | + # Extract progress % |
| 51 | + try: |
| 52 | + percent = float(line.split("%")[0].split()[-1]) |
| 53 | + progress_var.set(percent) |
| 54 | + except: |
| 55 | + pass |
| 56 | + proc.wait() |
| 57 | + progress_var.set(100) |
| 58 | + messagebox.showinfo("Downloaded", f"Video saved to {downloads_folder}") |
| 59 | + except Exception as e: |
| 60 | + messagebox.showerror("Error", str(e)) |
| 61 | + |
| 62 | +def parse_formats(output_lines): |
| 63 | + """Return video/audio formats sorted high->low""" |
| 64 | + formats = [] |
| 65 | + for line in output_lines: |
| 66 | + if not line or line.startswith("format code"): |
| 67 | + continue |
| 68 | + parts = line.split() |
| 69 | + if len(parts) < 3: |
| 70 | + continue |
| 71 | + fmt_code = parts[0] |
| 72 | + res = parts[1] |
| 73 | + type_ = parts[2] |
| 74 | + if "video" not in type_.lower() and "audio" not in type_.lower(): |
| 75 | + continue |
| 76 | + size = "-" |
| 77 | + if len(parts) > 3 and parts[-1].endswith(("KiB","MiB","GiB")): |
| 78 | + size = parts[-1] |
| 79 | + sort_key = 0 |
| 80 | + if "video" in type_.lower() and res != "audio": |
| 81 | + try: sort_key = int(res.replace("p","")) |
| 82 | + except: sort_key=0 |
| 83 | + formats.append({"fmt_code": fmt_code, "res": res, "type": type_, "size": size, "sort_key": sort_key}) |
| 84 | + formats.sort(key=lambda x: x["sort_key"], reverse=True) |
| 85 | + return formats |
| 86 | + |
| 87 | +def fetch_thumbnail(video_url): |
| 88 | + """Fetch video thumbnail""" |
| 89 | + try: |
| 90 | + cmd = ["yt-dlp", "--get-thumbnail", video_url] |
| 91 | + result = subprocess.run(cmd, stdout=subprocess.PIPE, text=True) |
| 92 | + url = result.stdout.strip() |
| 93 | + if not url: |
| 94 | + return None |
| 95 | + resp = requests.get(url) |
| 96 | + img = Image.open(io.BytesIO(resp.content)) |
| 97 | + img.thumbnail((320, 180)) |
| 98 | + return ImageTk.PhotoImage(img) |
| 99 | + except: |
| 100 | + return None |
| 101 | + |
| 102 | +# ---------------- GUI FUNCTIONS ---------------- # |
| 103 | +def fetch_formats_gui(): |
| 104 | + video_url = url_entry.get().strip() |
| 105 | + if not video_url: |
| 106 | + messagebox.showwarning("Input Needed", "Please enter a YouTube URL.") |
| 107 | + return |
| 108 | + fetching_label.config(text="Fetching formats...") |
| 109 | + |
| 110 | + def worker(): |
| 111 | + # Clear previous |
| 112 | + for widget in list_frame.winfo_children(): |
| 113 | + widget.destroy() |
| 114 | + # Fetch thumbnail |
| 115 | + thumb = fetch_thumbnail(video_url) |
| 116 | + if thumb: |
| 117 | + thumb_label.config(image=thumb) |
| 118 | + thumb_label.image = thumb |
| 119 | + |
| 120 | + output_lines = run_command(["yt-dlp", "-F", video_url]) |
| 121 | + if not output_lines: |
| 122 | + fetching_label.config(text="") |
| 123 | + messagebox.showerror("Error", "Failed to fetch formats.") |
| 124 | + return |
| 125 | + formats = parse_formats(output_lines) |
| 126 | + |
| 127 | + # Headers |
| 128 | + headers = ["Format", "Resolution", "Type", "Size", "Download"] |
| 129 | + for c,h in enumerate(headers): |
| 130 | + tb.Label(list_frame, text=h, font=("Segoe UI",10,"bold")).grid(row=0,column=c,padx=5,pady=2) |
| 131 | + # Add rows |
| 132 | + for r_idx, fmt in enumerate(formats,start=1): |
| 133 | + bg_color = "#2c2f33" if r_idx % 2==0 else "#23272a" |
| 134 | + tb.Label(list_frame, text=fmt["fmt_code"], background=bg_color).grid(row=r_idx,column=0,padx=2,sticky="w") |
| 135 | + tb.Label(list_frame, text=fmt["res"], background=bg_color).grid(row=r_idx,column=1,padx=2,sticky="w") |
| 136 | + tb.Label(list_frame, text=fmt["type"], background=bg_color).grid(row=r_idx,column=2,padx=2,sticky="w") |
| 137 | + tb.Label(list_frame, text=fmt["size"], background=bg_color).grid(row=r_idx,column=3,padx=2,sticky="w") |
| 138 | + progress_var = tk.DoubleVar() |
| 139 | + progress = tb.Progressbar(list_frame, variable=progress_var, bootstyle="info-striped", length=120) |
| 140 | + progress.grid(row=r_idx,column=4,padx=2) |
| 141 | + btn = tb.Button(list_frame, text="⬇ Download", bootstyle="success", |
| 142 | + command=lambda f=fmt["fmt_code"], pv=progress_var: threading.Thread(target=download_with_progress,args=(video_url,f,pv),daemon=True).start()) |
| 143 | + btn.grid(row=r_idx,column=5,padx=5) |
| 144 | + fetching_label.config(text="") |
| 145 | + |
| 146 | + threading.Thread(target=worker, daemon=True).start() |
| 147 | + |
| 148 | +# ---------------- GUI ---------------- # |
| 149 | +app = tb.Window(title="Professional YouTube Downloader", themename="darkly", size=(1000,650)) |
| 150 | + |
| 151 | +# URL entry |
| 152 | +top_frame = tb.Frame(app,padding=10) |
| 153 | +top_frame.pack(fill=tk.X) |
| 154 | +tb.Label(top_frame,text="YouTube URL:", font=("Segoe UI",12)).pack(side=tk.LEFT) |
| 155 | +url_entry = tb.Entry(top_frame,font=("Segoe UI",12)) |
| 156 | +url_entry.pack(side=tk.LEFT,fill=tk.X,expand=True,padx=5) |
| 157 | +tb.Button(top_frame,text="Fetch Formats",bootstyle="primary", command=fetch_formats_gui).pack(side=tk.LEFT) |
| 158 | + |
| 159 | +fetching_label = tb.Label(app,text="",font=("Segoe UI",10),foreground="yellow") |
| 160 | +fetching_label.pack(pady=2) |
| 161 | + |
| 162 | +# Thumbnail |
| 163 | +thumb_label = tb.Label(app) |
| 164 | +thumb_label.pack(pady=5) |
| 165 | + |
| 166 | +# List Frame with Scroll |
| 167 | +canvas_frame = tb.Frame(app) |
| 168 | +canvas_frame.pack(fill=tk.BOTH,expand=True,pady=10) |
| 169 | +canvas = tk.Canvas(canvas_frame) |
| 170 | +scrollbar = tk.Scrollbar(canvas_frame,orient="vertical",command=canvas.yview) |
| 171 | +scrollable_frame = tb.Frame(canvas) |
| 172 | +scrollable_frame.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) |
| 173 | +canvas.create_window((0,0),window=scrollable_frame,anchor="nw") |
| 174 | +canvas.configure(yscrollcommand=scrollbar.set) |
| 175 | +canvas.pack(side="left",fill="both",expand=True) |
| 176 | +scrollbar.pack(side="right",fill="y") |
| 177 | +list_frame = scrollable_frame |
| 178 | + |
| 179 | +app.mainloop() |
0 commit comments