Files
YouTube-Downloader/GUI_Download-v4.pyw
2026-03-22 02:47:55 +00:00

462 lines
20 KiB
Python

import tkinter as tk
from tkinter import filedialog, messagebox
import subprocess
import threading
import os
import sys
import re
from tkinter import ttk
# ── Colour palette ────────────────────────────────────────────────────────────
BG = "#1a1a2e" # deep navy — main background
BG2 = "#16213e" # slightly darker — section cards
ACCENT = "#e94560" # red-pink — active buttons & highlights
ACCENT2 = "#0f3460" # mid-navy — inactive / borders
FG = "#eaeaea" # near-white text
FG_DIM = "#7a7a9a" # dimmed label text
LOG_BG = "#0d0d1a" # near-black log background
LOG_OK = "#4ec9b0"
LOG_ERR = "#f44747"
LOG_WARN = "#dcdcaa"
LOG_INFO = "#9cdcfe"
LOG_PLAIN = "#c8c8c8"
FONT = ("Segoe UI", 9)
FONT_BOLD = ("Segoe UI", 9, "bold")
FONT_LOG = ("Consolas", 9)
# ── Reusable widgets ──────────────────────────────────────────────────────────
class FlatButton(tk.Canvas):
"""Canvas-drawn flat button — consistent look regardless of Windows theme."""
def __init__(self, parent, text, command,
bg=ACCENT, fg=FG, dis_bg=ACCENT2, dis_fg=FG_DIM,
width=130, height=30, radius=6):
try:
frame_bg = parent["bg"]
except Exception:
frame_bg = BG
super().__init__(parent, width=width, height=height,
bd=0, highlightthickness=0, bg=frame_bg)
self._text = text
self._command = command
self._bg = bg
self._fg = fg
self._hov = self._shift(bg, 30)
self._dis_bg = dis_bg
self._dis_fg = dis_fg
self._r = radius
self._on = True
self._draw(bg, fg)
self.bind("<Enter>", lambda _: self._draw(self._hov, fg) if self._on else None)
self.bind("<Leave>", lambda _: self._draw(bg, fg) if self._on else None)
self.bind("<ButtonPress-1>", lambda _: self._draw(dis_bg, fg) if self._on else None)
self.bind("<ButtonRelease-1>", self._release)
def _shift(self, c, n):
r = min(255, int(c[1:3], 16) + n)
g = min(255, int(c[3:5], 16) + n)
b = min(255, int(c[5:7], 16) + n)
return f"#{r:02x}{g:02x}{b:02x}"
def _rrect(self, x1, y1, x2, y2, r, **kw):
self.create_arc(x1, y1, x1+2*r, y1+2*r, start=90, extent=90, **kw)
self.create_arc(x2-2*r, y1, x2, y1+2*r, start=0, extent=90, **kw)
self.create_arc(x1, y2-2*r, x1+2*r, y2, start=180, extent=90, **kw)
self.create_arc(x2-2*r, y2-2*r, x2, y2, start=270, extent=90, **kw)
self.create_rectangle(x1+r, y1, x2-r, y2, **kw)
self.create_rectangle(x1, y1+r, x2, y2-r, **kw)
def _draw(self, bg, fg):
self.delete("all")
w, h = int(self["width"]), int(self["height"])
self._rrect(1, 1, w-1, h-1, self._r, fill=bg, outline=bg)
self.create_text(w//2, h//2, text=self._text, fill=fg,
font=FONT_BOLD, anchor="center")
def _release(self, _=None):
if self._on:
self._draw(self._bg, self._fg)
self._command()
def config_state(self, state):
self._on = (state == "normal")
self._draw(self._bg if self._on else self._dis_bg,
self._fg if self._on else self._dis_fg)
class RadioRow(tk.Frame):
"""Row of pill-shaped toggle labels acting as radio buttons."""
def __init__(self, parent, variable, choices):
super().__init__(parent, bg=BG2)
self._var = variable
self._btns = {}
for label, value in choices:
lbl = tk.Label(self, text=label, font=FONT,
bg=ACCENT2, fg=FG_DIM, padx=10, pady=3,
cursor="hand2")
lbl.pack(side="left", padx=(0, 3))
lbl.bind("<Button-1>", lambda _, v=value: self._var.set(v))
self._btns[value] = lbl
variable.trace_add("write", lambda *_: self._refresh())
self._refresh()
def _refresh(self):
cur = self._var.get()
for val, lbl in self._btns.items():
lbl.config(bg=ACCENT if val == cur else ACCENT2,
fg=FG if val == cur else FG_DIM)
class Section(tk.Frame):
"""Labelled card-style grouping frame."""
def __init__(self, parent, title):
super().__init__(parent, bg=BG2,
highlightbackground=ACCENT2, highlightthickness=1)
tk.Label(self, text=title.upper(), bg=BG2, fg=FG_DIM,
font=("Segoe UI", 7, "bold")).pack(anchor="w", padx=10, pady=(6, 2))
# ── Main application ──────────────────────────────────────────────────────────
class YTDLPDownloader(tk.Tk):
_SKIP = [
re.compile(r'^\[download\]\s+\d+\.\d+%'),
re.compile(r'^\[ffmpeg\]'),
]
def __init__(self):
super().__init__()
self.iconbitmap("icon.ico")
self.wm_iconbitmap("icon.ico")
self.title("NiX's YT Downloader")
self.geometry("660x610")
self.minsize(520, 500)
self.resizable(True, True)
self.configure(bg=BG)
self.download_format = tk.StringVar(value="mp3")
self.bitrate_option = tk.StringVar(value="default")
self.speed_limit = tk.StringVar(value="none")
self.download_folder = tk.StringVar(value="")
self.video_url = tk.StringVar(value="")
self.url_list_file = tk.StringVar(value="")
self.cookies_file = tk.StringVar(value="")
self._process = None
self._stop_req = False
self.ytdlp_path = os.path.join(os.path.dirname(sys.argv[0]), "yt-dlp.exe")
self._build_ui()
if not os.path.isfile(self.ytdlp_path):
self.log("⚠ yt-dlp.exe not found in script directory!", "warn")
# ── Build UI ──────────────────────────────────────────────────────────────
def _make_entry(self, parent, textvariable):
"""Dark-styled Entry with a right-click context menu for cut/copy/paste."""
e = tk.Entry(parent, textvariable=textvariable,
bg=LOG_BG, fg=FG, insertbackground=FG,
relief="flat", font=FONT, bd=6)
menu = tk.Menu(e, tearoff=0, bg=BG2, fg=FG,
activebackground=ACCENT, activeforeground=FG,
bd=0, relief="flat")
menu.add_command(label="Cut", command=lambda: e.event_generate("<<Cut>>"))
menu.add_command(label="Copy", command=lambda: e.event_generate("<<Copy>>"))
menu.add_command(label="Paste", command=lambda: e.event_generate("<<Paste>>"))
menu.add_separator()
menu.add_command(label="Select all", command=lambda: e.select_range(0, "end"))
menu.add_command(label="Clear", command=lambda: textvariable.set(""))
e.bind("<Button-3>", lambda ev: menu.tk_popup(ev.x_root, ev.y_root))
return e
def _build_ui(self):
# Header
hdr = tk.Frame(self, bg=ACCENT2)
hdr.pack(fill="x")
tk.Label(hdr, text=" ▶ YT Audio Downloader",
bg=ACCENT2, fg=FG, font=("Segoe UI", 11, "bold"),
pady=9).pack(side="left")
# URL
sec = Section(self, "Video URL (single link)")
sec.pack(fill="x", padx=12, pady=(10, 4))
self._make_entry(sec, self.video_url).pack(fill="x", padx=10, pady=(0, 8))
# Options row
row = tk.Frame(self, bg=BG)
row.pack(fill="x", padx=12, pady=4)
fmt_sec = Section(row, "Format")
fmt_sec.pack(side="left", fill="both", expand=True, padx=(0, 4))
RadioRow(fmt_sec, self.download_format,
[("MP3", "mp3"), ("Opus", "opus"), ("Original", "original")]
).pack(anchor="w", padx=10, pady=(0, 8))
br_sec = Section(row, "Bitrate")
br_sec.pack(side="left", fill="both", expand=True, padx=(0, 4))
RadioRow(br_sec, self.bitrate_option,
[("Default", "default"), ("High", "high")]
).pack(anchor="w", padx=10, pady=(0, 8))
spd_sec = Section(row, "Speed limit")
spd_sec.pack(side="left", fill="both", expand=True)
RadioRow(spd_sec, self.speed_limit,
[("", "none"), ("100M", "100M"), ("10M", "10M"), ("1M", "1M")]
).pack(anchor="w", padx=10, pady=(0, 8))
# Folder
fsec = Section(self, "Download folder")
fsec.pack(fill="x", padx=12, pady=4)
frow = tk.Frame(fsec, bg=BG2)
frow.pack(fill="x", padx=10, pady=(0, 8))
FlatButton(frow, "Browse…", self.select_folder,
width=90, height=26).pack(side="left")
self.folder_label = tk.Label(frow, text="No folder selected",
bg=BG2, fg=FG_DIM, font=FONT, anchor="w")
self.folder_label.pack(side="left", padx=10, fill="x", expand=True)
# URL list (.txt)
usec = Section(self, "URL list (optional — .txt, one link per line)")
usec.pack(fill="x", padx=12, pady=4)
urow = tk.Frame(usec, bg=BG2)
urow.pack(fill="x", padx=10, pady=(0, 8))
FlatButton(urow, "Browse…", self.select_url_list,
width=90, height=26).pack(side="left")
FlatButton(urow, "Clear", self.clear_url_list,
bg=ACCENT2, width=60, height=26).pack(side="left", padx=(4, 0))
self.url_list_label = tk.Label(urow, text="No file selected",
bg=BG2, fg=FG_DIM, font=FONT, anchor="w")
self.url_list_label.pack(side="left", padx=10, fill="x", expand=True)
# Cookies (.txt)
csec = Section(self, "Cookies file (optional — for age-restricted / private content)")
csec.pack(fill="x", padx=12, pady=4)
crow = tk.Frame(csec, bg=BG2)
crow.pack(fill="x", padx=10, pady=(0, 8))
FlatButton(crow, "Browse…", self.select_cookies,
width=90, height=26).pack(side="left")
FlatButton(crow, "Clear", self.clear_cookies,
bg=ACCENT2, width=60, height=26).pack(side="left", padx=(4, 0))
self.cookies_label = tk.Label(crow, text="No file selected",
bg=BG2, fg=FG_DIM, font=FONT, anchor="w")
self.cookies_label.pack(side="left", padx=10, fill="x", expand=True)
# Progress — styled ttk bar (accent colour via Style)
style = ttk.Style()
style.theme_use("default")
style.configure("Accent.Horizontal.TProgressbar",
troughcolor=ACCENT2,
background=ACCENT,
bordercolor=BG,
lightcolor=ACCENT,
darkcolor=ACCENT)
self.progress = ttk.Progressbar(self, style="Accent.Horizontal.TProgressbar",
mode="indeterminate", length=200)
self.progress.pack(fill="x", padx=12, pady=6)
# Buttons
brow = tk.Frame(self, bg=BG)
brow.pack(pady=4)
self.dl_btn = FlatButton(brow, "⬇ Download",
self.start_download_thread,
width=140, height=32)
self.dl_btn.pack(side="left", padx=5)
self.stop_btn = FlatButton(brow, "⏹ Stop",
self.stop_download,
bg=ACCENT2, width=110, height=32)
self.stop_btn.config_state("disabled")
self.stop_btn.pack(side="left", padx=5)
FlatButton(brow, "🗑 Clear log", self.clear_log,
bg=ACCENT2, width=120, height=32).pack(side="left", padx=5)
# Log
lsec = Section(self, "Log")
lsec.pack(fill="both", expand=True, padx=12, pady=(4, 12))
lframe = tk.Frame(lsec, bg=LOG_BG)
lframe.pack(fill="both", expand=True, padx=10, pady=(0, 8))
self.log_box = tk.Text(lframe, state="disabled", wrap="word",
bg=LOG_BG, fg=LOG_PLAIN, font=FONT_LOG,
relief="flat", bd=0, selectbackground=ACCENT2)
sb = tk.Scrollbar(lframe, command=self.log_box.yview,
bg=BG2, troughcolor=LOG_BG,
activebackground=ACCENT, bd=0,
highlightthickness=0, width=10)
self.log_box.configure(yscrollcommand=sb.set)
sb.pack(side="right", fill="y")
self.log_box.pack(side="left", fill="both", expand=True)
for tag, color in [("ok", LOG_OK), ("err", LOG_ERR),
("warn", LOG_WARN), ("info", LOG_INFO),
("plain", LOG_PLAIN)]:
self.log_box.tag_configure(tag, foreground=color)
# ── Logging ───────────────────────────────────────────────────────────────
def _tag_for(self, t):
tl = t.lower()
if any(w in tl for w in ("error", "failed", "")): return "err"
if any(w in tl for w in ("warning", "warn", "")): return "warn"
if any(w in tl for w in ("complete", "", "already", "skip")): return "ok"
if t.startswith("[") or "downloading" in tl or "destination" in tl: return "info"
return "plain"
def log(self, msg, tag=None):
for p in self._SKIP:
if p.search(msg):
return
self.log_box.config(state="normal")
self.log_box.insert("end", msg + "\n", tag or self._tag_for(msg))
self.log_box.see("end")
self.log_box.config(state="disabled")
def clear_log(self):
self.log_box.config(state="normal")
self.log_box.delete("1.0", "end")
self.log_box.config(state="disabled")
# ── Folder / file pickers ─────────────────────────────────────────────────
def select_folder(self):
f = filedialog.askdirectory()
if f:
self.download_folder.set(f)
self.folder_label.config(text=f, fg=FG)
def select_url_list(self):
f = filedialog.askopenfilename(
title="Select URL list",
filetypes=[("Text files", "*.txt"), ("All files", "*.*")])
if f:
self.url_list_file.set(f)
self.url_list_label.config(text=f, fg=FG)
def clear_url_list(self):
self.url_list_file.set("")
self.url_list_label.config(text="No file selected", fg=FG_DIM)
def select_cookies(self):
f = filedialog.askopenfilename(
title="Select cookies file",
filetypes=[("Text files", "*.txt"), ("All files", "*.*")])
if f:
self.cookies_file.set(f)
self.cookies_label.config(text=f, fg=FG)
def clear_cookies(self):
self.cookies_file.set("")
self.cookies_label.config(text="No file selected", fg=FG_DIM)
# ── Download control ──────────────────────────────────────────────────────
def start_download_thread(self):
self._stop_req = False
self.stop_btn.config_state("normal")
threading.Thread(target=self.download_video, daemon=True).start()
def stop_download(self):
self._stop_req = True
if self._process and self._process.poll() is None:
self._process.terminate()
self.log("⏹ Stop requested…", "warn")
self.stop_btn.config_state("disabled")
def download_video(self):
url = self.video_url.get().strip()
url_list = self.url_list_file.get().strip()
cookies = self.cookies_file.get().strip()
folder = self.download_folder.get()
fmt = self.download_format.get()
bitrate = self.bitrate_option.get()
speed = self.speed_limit.get()
# Need at least one source
if not url and not url_list:
messagebox.showerror("Error", "Please enter a URL or select a URL list file.")
self._finish(); return
if url_list and not os.path.isfile(url_list):
messagebox.showerror("Error", f"URL list file not found:\n{url_list}")
self._finish(); return
if cookies and not os.path.isfile(cookies):
messagebox.showerror("Error", f"Cookies file not found:\n{cookies}")
self._finish(); return
if not folder:
messagebox.showerror("Error", "Please select a download folder.")
self._finish(); return
if not os.path.isfile(self.ytdlp_path):
messagebox.showerror("Error", "yt-dlp.exe not found in script directory.")
self._finish(); return
self.progress.start()
self.log("── New download " + "" * 42, "info")
if url:
self.log(f"URL : {url}", "info")
if url_list:
self.log(f"List : {url_list}", "info")
if cookies:
self.log(f"Cookies: {cookies}", "info")
self.log(f"Format : {fmt} │ Bitrate : {bitrate} │ Speed : {speed}", "info")
self.log(f"Folder : {folder}", "info")
out = os.path.join(folder, "%(title)s.%(ext)s")
cmd = [self.ytdlp_path, "--no-cache-dir", "--no-overwrites"]
if cookies:
cmd += ["--cookies", cookies]
if speed != "none":
cmd += ["--limit-rate", speed]
if fmt == "original":
cmd += ["-f", "bestaudio", "-o", out]
else:
cmd += ["-f", "bestaudio", "-x",
"--audio-format", fmt, "--embed-thumbnail"]
cmd += ["--audio-quality",
("320k" if bitrate == "high" else "192k") if fmt == "mp3"
else ("0" if bitrate == "high" else "5")]
cmd += ["-o", out]
# Source: list file takes priority; single URL is appended last
if url_list:
cmd += ["--batch-file", url_list]
if url:
cmd += [url]
try:
self._process = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, cwd=os.path.dirname(self.ytdlp_path),
creationflags=subprocess.CREATE_NO_WINDOW)
for line in iter(self._process.stdout.readline, ""):
if self._stop_req:
break
if line.strip():
self.log(line.strip())
self._process.stdout.close()
self._process.wait()
if self._stop_req:
self.log("⏹ Download stopped by user.", "warn")
elif self._process.returncode == 0:
self.log("✅ Download complete!", "ok")
else:
self.log(f"❌ Download failed (exit code {self._process.returncode}).", "err")
except Exception as e:
self.log(f"❌ Unexpected error: {e}", "err")
self._finish()
def _finish(self):
self.progress.stop()
self._process = None
self.stop_btn.config_state("disabled")
if __name__ == "__main__":
app = YTDLPDownloader()
app.mainloop()