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("", lambda _: self._draw(self._hov, fg) if self._on else None) self.bind("", lambda _: self._draw(bg, fg) if self._on else None) self.bind("", lambda _: self._draw(dis_bg, fg) if self._on else None) self.bind("", 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("", 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.save_description = tk.BooleanVar(value=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("<>")) menu.add_command(label="Copy", command=lambda: e.event_generate("<>")) menu.add_command(label="Paste", command=lambda: e.event_generate("<>")) 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("", 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 # Options checkboxes chk_row = tk.Frame(self, bg=BG) chk_row.pack(pady=(0, 2)) tk.Checkbutton(chk_row, text="Save description - for mixes with tracklist", variable=self.save_description, bg=BG, fg=FG, selectcolor=BG2, activebackground=BG, activeforeground=FG, font=FONT).pack(side="left", padx=8) 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"Format : {fmt} | Speed : {speed}", "info") self.log(f"Folder : {folder}", "info") #out = os.path.join(folder, "%(title)s.%(ext)s") out = os.path.join(folder, "%(artist&{} - |)s%(title)s.%(ext)s") cmd = [self.ytdlp_path, "--no-cache-dir", "--no-overwrites", "--js-runtimes", "node"] if self.save_description.get(): cmd += ["--write-description"] if cookies: cmd += ["--cookies", cookies] if speed != "none": cmd += ["--limit-rate", speed] #This block replaces the one after it if fmt == "original": cmd += ["-f", "bestaudio/best", "--write-thumbnail", "--embed-metadata", "-o", out] else: cmd += ["-f", "bestaudio", "-x", "--audio-format", fmt, "--embed-thumbnail", "--embed-metadata", "--audio-quality", "0", "-o", out] #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()