What makes us different from other similar websites? › Forums › Tech › How to Stop Ad Blocker Detection on Websites Like YouTube › Reply To: How to Stop Ad Blocker Detection on Websites Like YouTube

We have updated the program to run in python.
The screenshot, above, depends on your system theme. It could look different, but the general layout is the same.
You will have to do the following if you want to run the new program.
Commands:
import tkinter as tk
from tkinter import filedialog, messagebox
import subprocess
import os
import shutil
import re
import threading
import json
import glob
import sys
import webbrowser
import time
# Import yt_dlp library
# This library is crucial for the application's core functionality.
# If it's not installed, the application will prompt the user and exit.
try:
import yt_dlp
except ImportError:
messagebox.showerror("Error", "The 'yt-dlp' Python library is not installed.\n"
"Please install it using: pip install yt-dlp\n"
"Then try running the application again.")
exit()
# --- Configuration Constants ---
# Base name for temporarily downloaded video files.
# yt-dlp will append the correct accurate extension (e.g., downloaded_video.mp4).
OUTPUT_BASENAME = "downloaded_video"
# File to store the path of the last used save folder for user convenience.
# Stored in JSON format for robustness.
LAST_SAVE_FOLDER_FILE = ".last_save_folder.json"
# Temporary WAV file for the audio test sound.
TEST_SOUND_FILE = "taks_shack_test_test_sound.wav"
# --- Windows 95 Theme Colors ---
_WINDOW_BG = '#C0C0C0' # Standard light gray
_BUTTON_FACE = '#C0C0C0'
_TEXT_BG = '#FFFFFF' # White for entry/text
_TEXT_FG = '#000000' # Black for entry/text
_LOG_BG = '#000000' # Black for debug console
_LOG_FG = '#DDDDDD' # Light gray for debug text
_ACCENT_BLUE = '#000080' # Dark blue, typical for selections/progress
_DARK_GRAY = '#808080' # For sunken/raised effects
_LIGHT_GRAY = '#F0F0F0' # For highlight effects
# Defragmenter specific colors
_NOT_DEFRAGMENTED_COLOR = '#000080' # Dark Blue
_IN_PROGRESS_COLOR = '#FF0000' # Red
_DEFRAGMENTED_COLOR = '#00FFFF' # Cyan (Light Blue)
# --- Windows 95 Theme Fonts ---
_HEADER_FONT = ("Fixedsys", 14, "bold")
_GENERAL_FONT = ("MS Sans Serif", 9) # Using generic "MS Sans Serif" which is often mapped to a suitable system font
_MONOSPACE_FONT = ("Fixedsys", 10)
# --- Progress Grid Configuration ---
_GRID_ROWS = 20 # Increased rows to make squares smaller
_GRID_COLS = 80 # Increased columns to make squares smaller
_GRID_LINE_THICKNESS = 2 # Thickness of the grid lines (Increased for more prominent lines)
# _BLOCK_SIZE, _GRID_CANVAS_WIDTH, _GRID_CANVAS_HEIGHT will be calculated dynamically
class YouTubeDownloaderApp:
"""
A Tkinter-based GUI application for downloading and playing YouTube videos.
It leverages the yt-dlp Python library for downloads and external players
like smplayer for video playback.
"""
def __init__(self, root):
self.root = root
# Updated application title to "Defrag YouTube Video"
self.root.title("Defrag YouTube Video")
# Set a default window size, allowing it to be resized by the user.
self.root.geometry("1200x900") # Increased initial window size
self.root.resizable(True, True)
self.root.configure(bg=_WINDOW_BG) # Set root window background
# Variables to store the path of the currently downloaded video
# and its suggested title from YouTube.
self.downloaded_video_path = None
self.suggested_video_title = None
self.smplayer_process = None # To store the subprocess object for SMPlayer
# Store references to the grid blocks for dynamic coloring
self.grid_rects = [] # Changed to store Canvas rectangle IDs
# Load the last saved folder path for saving videos.
self.last_save_folder = self._load_last_save_folder()
# Initialize Tkinter BooleanVars for checkboxes
self.play_after_download_var = tk.BooleanVar(value=True) # Default to True (checked)
# Removed self.auto_delete_after_play_var as per user request
# Initialize the GUI widgets.
self._create_widgets()
# Perform initial checks for external system dependencies.
self._check_dependencies()
def _load_last_save_folder(self):
"""
Loads the last used save folder path from a JSON file.
Returns an empty string if the file doesn't exist or is unreadable.
"""
if os.path.exists(LAST_SAVE_FOLDER_FILE):
try:
with open(LAST_SAVE_FOLDER_FILE, 'r') as f:
data = json.load(f)
return data.get("last_folder", "")
except (json.JSONDecodeError, IOError) as e:
# Log a warning if the file cannot be read or parsed.
self._log(f"Warning: Could not load last save folder: {e}", "orange")
return ""
def _save_last_save_folder(self, folder_path):
"""
Saves the given folder path as the last used save folder to a JSON file.
"""
try:
with open(LAST_SAVE_FOLDER_FILE, 'w') as f:
json.dump({"last_folder": folder_path}, f)
self._log(f"Last save folder updated to: {folder_path}", "green")
except IOError as e:
self._log(f"Error saving last save folder: {e}", "red")
def _check_dependencies(self):
"""
Checks for required external system dependencies (smplayer, ffmpeg)
and optional ones (espeak-ng, aplay), and checks for yt-dlp updates.
Informs the user about missing dependencies and exits if critical ones are absent.
"""
self._log("Checking system dependencies...", "blue")
missing_critical = []
missing_optional = []
# --- Critical Dependencies ---
# smplayer is used for playing the downloaded video.
if subprocess.run(["which", "smplayer"], capture_output=True).returncode != 0:
missing_critical.append("smplayer")
else:
self._log("smplayer: [OK]", "green")
# ffmpeg is required by yt-dlp to merge video and audio streams.
if subprocess.run(["which", "ffmpeg"], capture_output=True).returncode != 0:
missing_critical.append("ffmpeg")
else:
self._log("ffmpeg: [OK]", "green")
# --- Optional Dependencies (for audio test) ---
# espeak-ng and aplay are used to play a short "wake-up" sound.
espeak_ng_ok = subprocess.run(["which", "espeak-ng"], capture_output=True).returncode == 0
aplay_ok = subprocess.run(["which", "aplay"], capture_output=True).returncode == 0
if not espeak_ng_ok:
missing_optional.append("espeak-ng")
if not aplay_ok:
missing_optional.append("aplay")
if espeak_ng_ok and aplay_ok:
self._log("espeak-ng & aplay: [OK] (for audio test)", "green")
elif missing_optional:
self._log(f"Optional tools missing: {', '.join(missing_optional)}. Audio test will be skipped.", "orange")
# Handle critical missing dependencies.
if missing_critical:
message = (f"The following critical tools are missing and must be installed for this application to function:\n"
f"{', '.join(missing_critical)}\n\n"
f"Please install them using your system's package manager (e.g., 'sudo apt install {missing_critical[0]}' on Debian/Ubuntu).\n"
f"The application will now exit.")
messagebox.showerror("Missing Dependencies", message)
self.root.destroy() # Close the application if critical dependencies are missing.
else:
self._log("All critical dependencies are installed.", "green")
self._log("Dependency check complete.", "blue")
# --- yt-dlp Update Check ---
self._log("Checking for yt-dlp updates...", "blue")
try:
installed_version = yt_dlp.version.__version__
self._log(f"Installed yt-dlp version: {installed_version}")
# Run pip list --outdated and capture output
pip_outdated_process = subprocess.run(
[sys.executable, "-m", "pip", "list", "--outdated"], # Use sys.executable for robust pip call
capture_output=True,
text=True,
check=True
)
outdated_packages = pip_outdated_process.stdout
if "yt-dlp" in outdated_packages:
self._log("A newer version of yt-dlp is available!", "orange")
# Ask user if they want to update
if messagebox.askyesno("Update Available", "A newer version of yt-dlp is available. Would you like to update it now?\n\n"
"The application will restart after the update."):
self._log("Attempting to update yt-dlp...", "blue")
try:
update_command = [sys.executable, "-m", "pip", "install", "--upgrade", "yt-dlp"]
update_process = subprocess.run(
update_command,
capture_output=True,
text=True,
check=True
)
self._log("yt-dlp updated successfully!", "green")
self._log(update_process.stdout, "green")
messagebox.showinfo("Update Complete", "yt-dlp has been updated. Please restart the application to use the new version.")
self.root.destroy() # Close the current application instance
# Restart the application
python = sys.executable
os.execl(python, python, *sys.argv)
except subprocess.CalledProcessError as update_e:
error_message = update_e.stderr.strip()
self._log(f"Error updating yt-dlp: {error_message}", "red")
# Check for the "externally-managed-environment" error
if "externally-managed-environment" in error_message:
self._log("This error usually means your system prevents direct pip installs.", "orange")
self._log("It is highly recommended to use a Python virtual environment to manage dependencies.", "orange")
if messagebox.askyesno("System Package Conflict",
"Your system prevents direct pip installs (externally-managed-environment).\n"
"It is strongly recommended to use a Python virtual environment.\n\n"
"Do you want to force the update using '--break-system-packages'?\n"
"WARNING: This can potentially break your system's Python installation.",
icon='warning'):
self._log("Attempting to force update with --break-system-packages...", "orange")
force_update_command = [sys.executable, "-m", "pip", "install", "--upgrade", "yt-dlp", "--break-system-packages"]
try:
force_update_process = subprocess.run(
force_update_command,
capture_output=True,
text=True,
check=True
)
self._log("yt-dlp forced update successful!", "green")
self._log(force_update_process.stdout, "green")
messagebox.showinfo("Update Complete (Forced)", "yt-dlp has been forced updated. Please restart the application to use the new version.")
self.root.destroy()
python = sys.executable
os.execl(python, python, *sys.argv)
except subprocess.CalledProcessError as force_e:
self._log(f"Forced update failed: {force_e.stderr.strip()}", "red")
messagebox.showerror("Forced Update Error", f"Forced update failed: {force_e.stderr.strip()}")
except Exception as force_e:
self._log(f"An unexpected error occurred during forced update: {force_e}", "red")
messagebox.showerror("Forced Update Error", f"An unexpected error occurred during forced update: {force_e}")
else:
self._log("Forced update cancelled by user.", "blue")
messagebox.showinfo("Update Cancelled", "Update cancelled. Please consider using a virtual environment for future updates.")
else:
messagebox.showerror("Update Error", f"Failed to update yt-dlp: {error_message}")
except Exception as update_e:
self._log(f"An unexpected error occurred during yt-dlp update: {update_e}", "red")
messagebox.showerror("Update Error", f"An unexpected error occurred during yt-dlp update: {update_e}")
else:
self._log("Skipping yt-dlp update as requested by user.", "blue")
else:
self._log("yt-dlp is up to date.", "green")
except subprocess.CalledProcessError as e:
self._log(f"Warning: Could not check for yt-dlp updates (pip error: {e.stderr.strip()}).", "orange")
except FileNotFoundError:
self._log("Warning: 'pip' command not found. Cannot check for yt-dlp updates.", "orange")
except AttributeError:
# This specific error means yt_dlp.version.__version__ was not found.
# This can happen with older yt-dlp installations or if it's not installed correctly.
self._log("Warning: Could not determine yt-dlp version. Ensure yt-dlp is correctly installed.", "orange")
except Exception as e:
self._log(f"An unexpected error occurred during yt-dlp update check: {e}", "orange")
def _create_widgets(self):
"""
Creates and arranges all the GUI widgets within the main window.
"""
# Main Frame for padding and consistent layout.
main_frame = tk.Frame(self.root, bg=_WINDOW_BG)
main_frame.pack(fill=tk.BOTH, expand=True, padx=15, pady=15)
# Header Label for the application title.
header_label = tk.Label(main_frame, text="Defrag YouTube Video", font=_HEADER_FONT,
bg=_WINDOW_BG, fg=_ACCENT_BLUE, relief=tk.RIDGE, borderwidth=2)
header_label.pack(pady=10, fill=tk.X)
# Add the clickable URL below the title
self.url_label = tk.Label(main_frame, text="https://taksshack.com",
foreground=_TEXT_FG, # Changed to black
background=_WINDOW_BG, font=_MONOSPACE_FONT, cursor="")
self.url_label.pack(pady=(0, 10))
self.url_label.bind("<Button-1>", lambda e: self._open_url("https://taksshack.com"))
# --- URL Input Section ---
url_frame = tk.LabelFrame(main_frame, text="YouTube Video URL",
bg=_WINDOW_BG, font=_GENERAL_FONT, relief=tk.GROOVE, borderwidth=2)
url_frame.pack(fill=tk.X, pady=10, padx=5)
# Entry widget for the user to paste the YouTube video link.
self.url_entry = tk.Entry(url_frame, width=80, bg=_TEXT_BG, fg=_TEXT_FG,
insertbackground=_TEXT_FG, relief=tk.SUNKEN, borderwidth=2, font=_MONOSPACE_FONT)
self.url_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(5, 5), pady=5)
# Bind the Enter key to trigger the download.
# Changed to call start_process()
self.url_entry.bind("<Return>", lambda event: self.start_process())
# Button to initiate the download. Renamed to "START"
self.start_button = tk.Button(url_frame, text="START", command=self.start_process, width=10,
font=_GENERAL_FONT, bg=_BUTTON_FACE, fg=_TEXT_FG, relief=tk.RAISED, borderwidth=2)
self.start_button.pack(side=tk.RIGHT, padx=(0, 5), pady=5)
# --- Checkboxes Frame (for Play After Download) ---
checkbox_frame = tk.Frame(main_frame, bg=_WINDOW_BG)
checkbox_frame.pack(pady=(5, 10), anchor=tk.W, padx=5)
# Play After Download Checkbox
self.play_after_download_checkbox = tk.Checkbutton(
checkbox_frame,
text="Play video after download completes",
variable=self.play_after_download_var,
bg=_WINDOW_BG, fg=_TEXT_FG, selectcolor=_WINDOW_BG, font=_GENERAL_FONT
)
self.play_after_download_checkbox.pack(side=tk.LEFT, padx=(0, 15))
# --- Cookie Options Section ---
cookie_frame = tk.LabelFrame(main_frame, text="Cookie Options (Optional)",
bg=_WINDOW_BG, font=_GENERAL_FONT, relief=tk.GROOVE, borderwidth=2)
cookie_frame.pack(fill=tk.X, pady=10, padx=5)
# Radio buttons to select the cookie method.
self.cookie_method = tk.StringVar(value="none") # Default: No cookies
tk.Radiobutton(cookie_frame, text="No Cookies", variable=self.cookie_method, value="none",
command=self._toggle_cookie_input, bg=_WINDOW_BG, fg=_TEXT_FG, selectcolor=_WINDOW_BG, font=_GENERAL_FONT).pack(side=tk.LEFT, padx=5, pady=5)
tk.Radiobutton(cookie_frame, text="From Browser", variable=self.cookie_method, value="browser",
command=self._toggle_cookie_input, bg=_WINDOW_BG, fg=_TEXT_FG, selectcolor=_WINDOW_BG, font=_GENERAL_FONT).pack(side=tk.LEFT, padx=5, pady=5)
tk.Radiobutton(cookie_frame, text="From File", variable=self.cookie_method, value="file",
command=self._toggle_cookie_input, bg=_WINDOW_BG, fg=_TEXT_FG, selectcolor=_WINDOW_BG, font=_GENERAL_FONT).pack(side=tk.LEFT, padx=5, pady=5)
# Option menu for selecting a browser if "From Browser" is chosen.
self.browser_var = tk.StringVar(value="firefox") # Default browser
self.browser_options = ["firefox", "chrome", "chromium", "brave", "edge", "opera", "safari", "vivaldi", "librewolf"]
self.browser_menu = tk.OptionMenu(cookie_frame, self.browser_var, *self.browser_options)
self.browser_menu.pack(side=tk.LEFT, padx=5, pady=5)
self.browser_menu.config(state=tk.DISABLED, bg=_BUTTON_FACE, fg=_TEXT_FG, relief=tk.RAISED, borderwidth=2,
highlightbackground=_WINDOW_BG, font=_GENERAL_FONT)
self.browser_menu["menu"].config(bg=_BUTTON_FACE, fg=_TEXT_FG, font=_GENERAL_FONT)
# Entry widget for the cookie file path if "From File" is chosen.
self.cookie_file_entry = tk.Entry(cookie_frame, width=40, bg=_TEXT_BG, fg=_TEXT_FG,
insertbackground=_TEXT_FG, relief=tk.SUNKEN, borderwidth=2, font=_MONOSPACE_FONT)
self.cookie_file_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=5, pady=5)
self.cookie_file_entry.config(state=tk.DISABLED)
# Button to browse for a cookie file.
self.browse_cookie_button = tk.Button(cookie_frame, text="BROWSE", command=self._browse_cookie_file,
font=_GENERAL_FONT, bg=_BUTTON_FACE, fg=_TEXT_FG, relief=tk.RAISED, borderwidth=2)
self.browse_cookie_button.pack(side=tk.LEFT, padx=5, pady=5)
self.browse_cookie_button.config(state=tk.DISABLED)
# --- Download Progress Section (Defragmenter-style Grid) ---
progress_section_frame = tk.Frame(main_frame, bg=_WINDOW_BG)
progress_section_frame.pack(pady=10, padx=5, fill=tk.X)
# Grid container frame with sunken relief and calculated size
self.grid_canvas = tk.Canvas(progress_section_frame,
bg=_NOT_DEFRAGMENTED_COLOR, relief=tk.SUNKEN, borderwidth=2,
highlightbackground=_DARK_GRAY)
self.grid_canvas.pack(pady=(0, 5), fill=tk.BOTH, expand=True) # Fill both X and Y, expand
# Bind the <Configure> event to redraw the grid when the canvas size changes
self.grid_canvas.bind("<Configure>", self._on_canvas_resize)
# Initial drawing of the grid (will be updated on resize)
self.grid_rects = [] # Ensure it's empty before first draw
# Progress percentage label below the grid
self.progress_label = tk.Label(progress_section_frame, text="0% completed",
bg=_WINDOW_BG, fg=_TEXT_FG, font=_GENERAL_FONT)
self.progress_label.pack(pady=(5, 5))
# Progress bar below the percentage (for fine-grained progress)
self.small_progress_bar = tk.Canvas(progress_section_frame, height=10, bg=_WINDOW_BG,
relief=tk.SUNKEN, borderwidth=1, highlightbackground=_DARK_GRAY)
self.small_progress_bar.pack(pady=5, fill=tk.X, expand=True)
self.small_progress_fill = self.small_progress_bar.create_rectangle(0, 0, 0, 10, fill=_ACCENT_BLUE, outline="")
# Legend for the progress colors
legend_frame = tk.Frame(progress_section_frame, bg=_WINDOW_BG)
legend_frame.pack(pady=(5, 0), fill=tk.X)
# Helper function for legend items
def create_legend_item(parent, color, text):
item_frame = tk.Frame(parent, bg=_WINDOW_BG)
item_frame.pack(side=tk.LEFT, padx=10)
color_block = tk.Frame(item_frame, width=15, height=15, bg=color, relief=tk.SUNKEN, borderwidth=1)
color_block.pack(side=tk.LEFT)
label = tk.Label(item_frame, text=text, bg=_WINDOW_BG, fg=_TEXT_FG, font=_GENERAL_FONT)
label.pack(side=tk.LEFT)
create_legend_item(legend_frame, _NOT_DEFRAGMENTED_COLOR, "Not downloaded")
create_legend_item(legend_frame, _IN_PROGRESS_COLOR, "In progress")
create_legend_item(legend_frame, _DEFRAGMENTED_COLOR, "Downloaded")
# --- Action Buttons Section (Play, Save, Delete) ---
action_frame = tk.Frame(main_frame, bg=_WINDOW_BG)
action_frame.pack(fill=tk.X, pady=10, padx=5)
self.play_button = tk.Button(action_frame, text="PLAY VIDEO", command=self.play_video, state=tk.DISABLED,
font=_GENERAL_FONT, bg=_BUTTON_FACE, fg=_TEXT_FG, relief=tk.RAISED, borderwidth=2)
self.play_button.pack(side=tk.LEFT, expand=True, padx=5, pady=5)
self.save_button = tk.Button(action_frame, text="SAVE VIDEO", command=self.save_video, state=tk.DISABLED,
font=_GENERAL_FONT, bg=_BUTTON_FACE, fg=_TEXT_FG, relief=tk.RAISED, borderwidth=2)
self.save_button.pack(side=tk.LEFT, expand=True, padx=5, pady=5)
self.delete_button = tk.Button(action_frame, text="DELETE DOWNLOADED", command=self.delete_downloaded_video, state=tk.DISABLED,
font=_GENERAL_FONT, bg=_BUTTON_FACE, fg=_TEXT_FG, relief=tk.RAISED, borderwidth=2)
self.delete_button.pack(side=tk.LEFT, expand=True, padx=5, pady=5)
# --- Activity Log Section ---
log_frame = tk.LabelFrame(main_frame, text="Activity Log",
bg=_WINDOW_BG, font=_GENERAL_FONT, relief=tk.GROOVE, borderwidth=2)
log_frame.pack(fill=tk.BOTH, expand=True, pady=10, padx=5)
# Text widget to display logs and messages to the user.
self.log_text = tk.Text(log_frame, wrap=tk.WORD, state=tk.DISABLED, height=10, font=_MONOSPACE_FONT,
bg=_LOG_BG, fg=_LOG_FG, relief=tk.SUNKEN, borderwidth=2, insertbackground=_LOG_FG)
self.log_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
# Scrollbar for the log text area.
log_scrollbar = tk.Scrollbar(log_frame, command=self.log_text.yview, relief=tk.FLAT,
troughcolor=_WINDOW_BG, bg=_BUTTON_FACE)
log_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.log_text.config(yscrollcommand=log_scrollbar.set)
# Configure tags for different log message colors.
self.log_text.tag_config("red", foreground="red")
self.log_text.tag_config("green", foreground="green")
self.log_text.tag_config("blue", foreground="blue")
self.log_text.tag_config("orange", foreground="orange")
# Initial call to set the correct cookie input states
self._toggle_cookie_input()
def _on_canvas_resize(self, event):
"""
Callback function for the <Configure> event of the grid_canvas.
Redraws the grid whenever the canvas is resized.
"""
self._draw_grid()
# After redrawing, ensure the progress is updated to reflect the current download state
# (if a download is in progress)
# This is a simplified approach; a more robust solution might store the last known percentage
# and apply it here. For now, it will just reset to 0% visually if no download is active.
self.small_progress_bar.coords(self.small_progress_fill, 0, 0, 0, 10)
self.progress_label.config(text="0% completed")
def _draw_grid(self):
"""
Clears the existing grid and redraws it to fit the current canvas size.
Calculates block sizes dynamically to fill the canvas, ensuring no gaps.
Blocks will be as close to square as possible given the canvas aspect ratio.
"""
self.grid_canvas.delete("all") # Clear all existing items on the canvas
self.grid_rects = [] # Reset the list of rectangle IDs
canvas_width_actual = self.grid_canvas.winfo_width()
canvas_height_actual = self.grid_canvas.winfo_height()
if canvas_width_actual <= 0 or canvas_height_actual <= 0:
# Fallback for initial calls before widgets are fully rendered
# or if the window is minimized.
self.root.update_idletasks() # Force update to get actual dimensions
canvas_width_actual = self.grid_canvas.winfo_width()
canvas_height_actual = self.grid_canvas.winfo_height()
if canvas_width_actual <= 0 or canvas_height_actual <= 0:
return # Still zero, exit
# Calculate block dimensions based on filling the actual canvas size.
# These will be float values.
block_width_float = canvas_width_actual / _GRID_COLS
block_height_float = canvas_height_actual / _GRID_ROWS
# Draw rectangles
for r in range(_GRID_ROWS):
for c in range(_GRID_COLS):
x1 = int(c * block_width_float)
y1 = int(r * block_height_float)
# For the last column, ensure x2 reaches the absolute right edge of the canvas.
# For the last row, ensure y2 reaches the absolute bottom edge of the canvas.
x2 = int((c + 1) * block_width_float)
if c == _GRID_COLS - 1:
x2 = canvas_width_actual
y2 = int((r + 1) * block_height_float)
if r == _GRID_ROWS - 1:
y2 = canvas_height_actual
rect = self.grid_canvas.create_rectangle(x1, y1, x2, y2,
fill=_NOT_DEFRAGMENTED_COLOR, outline="")
self.grid_rects.append(rect)
# Draw horizontal grid lines
for r in range(_GRID_ROWS + 1):
y = int(r * block_height_float)
if r == _GRID_ROWS: # Ensure the very last line is at the bottom edge
y = canvas_height_actual
self.grid_canvas.create_line(0, y, canvas_width_actual, y, fill=_DARK_GRAY, width=_GRID_LINE_THICKNESS)
# Draw vertical grid lines
for c in range(_GRID_COLS + 1):
x = int(c * block_width_float)
if c == _GRID_COLS: # Ensure the very last line is at the right edge
x = canvas_width_actual
self.grid_canvas.create_line(x, 0, x, canvas_height_actual, fill=_DARK_GRAY, width=_GRID_LINE_THICKNESS)
def _open_url(self, url):
"""
Opens the given URL in the default web browser.
"""
try:
webbrowser.open_new_tab(url)
self._log(f"Opened URL: {url}", "blue")
except Exception as e:
self._log(f"Error opening URL {url}: {e}", "red")
messagebox.showerror("Error", f"Failed to open URL: {e}")
def _paste_url(self):
"""
Pastes the content from the clipboard into the URL entry field.
"""
try:
clipboard_content = self.root.clipboard_get()
self.url_entry.delete(0, tk.END)
self.url_entry.insert(0, clipboard_content)
self._log("Pasted URL from clipboard.", "blue")
except tk.TclError:
self._log("Clipboard is empty or inaccessible.", "orange")
# messagebox.showwarning("Paste Error", "Clipboard is empty or inaccessible.") # Suppress pop-up for smoother start
except Exception as e:
self._log(f"An error occurred while pasting: {e}", "red")
# messagebox.showerror("Paste Error", f"An error occurred while pasting: {e}") # Suppress pop-up
def start_process(self):
"""
Combines pasting the URL and starting the download process.
This is the new command for the "START" button.
"""
self._paste_url() # First, try to paste from clipboard
self.start_download() # Then, start the download with the content in the entry
def _toggle_cookie_input(self):
"""
Enables or disables cookie input fields (browser menu, file entry, browse button)
based on the selected cookie method radio button.
"""
method = self.cookie_method.get()
if method == "browser":
# Enable the main OptionMenu widget
self.browser_menu.config(state=tk.NORMAL)
# Enable its internal menu (the dropdown options)
for i in range(len(self.browser_options)):
self.browser_menu["menu"].entryconfig(i, state=tk.NORMAL)
self.cookie_file_entry.config(state=tk.DISABLED)
self.browse_cookie_button.config(state=tk.DISABLED)
elif method == "file":
self.browser_menu.config(state=tk.DISABLED)
# Explicitly disable the internal menu options when not in use
for i in range(len(self.browser_options)):
self.browser_menu["menu"].entryconfig(i, state=tk.DISABLED)
self.cookie_file_entry.config(state=tk.NORMAL)
self.browse_cookie_button.config(state=tk.NORMAL)
else: # "none" selected
self.browser_menu.config(state=tk.DISABLED)
# Explicitly disable the internal menu options when not in use
for i in range(len(self.browser_options)):
self.browser_menu["menu"].entryconfig(i, state=tk.DISABLED)
self.cookie_file_entry.config(state=tk.DISABLED)
self.browse_cookie_button.config(state=tk.DISABLED)
def _browse_cookie_file(self):
"""
Opens a file dialog for the user to select a cookies.txt file.
Updates the cookie file entry field with the selected path.
"""
filepath = filedialog.askopenfilename(
title="Select Cookies File",
filetypes=[("Text files", "*.txt"), ("All files", "*.*")]
)
if filepath:
self.cookie_file_entry.delete(0, tk.END)
self.cookie_file_entry.insert(0, filepath)
def _log(self, message, color="black"):
"""
Appends a message to the activity log text area.
Automatically scrolls to the end and applies a specified color.
"""
self.log_text.config(state=tk.NORMAL) # Enable editing
self.log_text.insert(tk.END, message + "\n", color) # Insert message
self.log_text.see(tk.END) # Scroll to the end
self.log_text.config(state=tk.DISABLED) # Disable editing
def _update_progress(self, d):
"""
Callback function for yt-dlp to update the custom progress bar and label.
This function is called frequently by yt-dlp during the download process.
"""
total_blocks = _GRID_ROWS * _GRID_COLS
if d['status'] == 'downloading':
total_bytes = d.get('total_bytes') or d.get('total_bytes_estimate')
downloaded_bytes = d.get('downloaded_bytes')
if total_bytes and downloaded_bytes:
percent = (downloaded_bytes / total_bytes) * 100
# Update progress label
self.progress_label.config(text=f"{percent:.0f}% completed")
# Update small progress bar
canvas_width = self.small_progress_bar.winfo_width()
if canvas_width == 1: # Initial state before widget is fully rendered
self.root.update_idletasks()
canvas_width = self.small_progress_bar.winfo_width()
if canvas_width == 1: # Still not rendered, skip update for now
return
fill_width = (percent / 100) * canvas_width
self.small_progress_bar.coords(self.small_progress_fill, 0, 0, fill_width, 10)
# Update grid blocks
completed_blocks = int((percent / 100) * total_blocks)
current_block_index = completed_blocks # The block that is currently "in progress"
# Ensure grid_rects is not empty before attempting to update
if not self.grid_rects:
self._log("Warning: grid_rects is empty, cannot update progress visually.", "orange")
return
for i in range(total_blocks):
# Use itemconfig to change fill color of existing rectangle
if i < completed_blocks:
self.grid_canvas.itemconfig(self.grid_rects[i], fill=_DEFRAGMENTED_COLOR) # Downloaded
elif i == current_block_index:
self.grid_canvas.itemconfig(self.grid_rects[i], fill=_IN_PROGRESS_COLOR) # In progress
else:
self.grid_canvas.itemconfig(self.grid_rects[i], fill=_NOT_DEFRAGMENTED_COLOR) # Not downloaded
self.root.update_idletasks() # Force GUI update immediately
elif d['status'] == 'finished':
# Ensure all blocks are "downloaded" and progress is 100%
if self.grid_rects: # Check if grid_rects is populated
for rect_id in self.grid_rects: # Iterate through rectangle IDs
self.grid_canvas.itemconfig(rect_id, fill=_DEFRAGMENTED_COLOR)
canvas_width = self.small_progress_bar.winfo_width()
self.small_progress_bar.coords(self.small_progress_fill, 0, 0, canvas_width, 10)
self.progress_label.config(text="100% completed - Finished")
self._log("Download finished successfully.", "green")
self.root.update_idletasks()
def start_download(self):
"""
Initiates the video download process.
It validates the URL and starts the download in a separate thread
to keep the GUI responsive.
"""
youtube_link = self.url_entry.get().strip()
# --- URL Validation ---
# Regex to validate YouTube video URLs.
# This pattern covers common YouTube video and short URLs.
youtube_regex = (
r'(https?://)?(www\.)?'
'(youtube|youtu|youtube-nocookie)\.(com|be)/'
'(watch\?v=|embed/|v/|.+\?v=|)'
'([a-zA-Z0-9_-]{11})'
)
if not re.match(youtube_regex, youtube_link):
messagebox.showwarning("Input Error", "Please enter a valid YouTube video link.")
self._log(f"Invalid URL entered: {youtube_link}", "red")
return
# Disable action buttons during the download process.
self.start_button.config(state=tk.DISABLED) # Changed from download_button
self.play_button.config(state=tk.DISABLED)
self.save_button.config(state=tk.DISABLED)
self.delete_button.config(state=tk.DISABLED)
# Reset progress bar and grid for a new download.
# Call _draw_grid to reset the visual state of the grid
self._draw_grid()
self.small_progress_bar.coords(self.small_progress_fill, 0, 0, 0, 10)
self.progress_label.config(text="0% completed")
self._log("\n--- Starting New Download ---", "blue")
self._log(f"Downloading: {youtube_link}")
# Run the download operation in a separate thread.
# This prevents the GUI from freezing while yt-dlp is working.
download_thread = threading.Thread(target=self._download_video_thread, args=(youtube_link,))
download_thread.start()
def _download_video_thread(self, youtube_link):
"""
The actual video download logic, executed in a separate thread.
Uses the yt-dlp Python library.
"""
self.downloaded_video_path = None
self.suggested_video_title = None
# yt-dlp options configuration.
ydl_opts = {
# Prioritize best quality video (mp4) and audio (m4a), then merge them.
# Fallback to best overall mp4, then just best.
'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best',
'merge_output_format': 'mp4', # Ensure merged output is MP4.
# Output template for the downloaded file.
# yt-dlp will automatically append the correct extension.
'outtmpl': f'{OUTPUT_BASENAME}.%(ext)s',
'progress_hooks': [self._update_progress], # Callback for progress updates.
'postprocessors': [{
'key': 'SponsorBlock',
'categories': ['sponsor'] # Remove sponsor segments.
}],
'quiet': True, # Suppress most console output from yt-dlp itself.
'no_warnings': True, # Suppress warnings from yt-dlp.
}
# Apply cookie options based on user selection.
cookie_method = self.cookie_method.get()
if cookie_method == "browser":
browser_name = self.browser_var.get()
if browser_name: # Ensure a browser is selected
# Pass the browser name as a list, as yt-dlp expects a sequence for cookiesfrombrowser
ydl_opts['cookiesfrombrowser'] = [browser_name]
self._log(f"Using cookies from browser: {browser_name}", "blue")
else:
self._log("Warning: No browser selected for cookies. Proceeding without browser cookies.", "orange")
# No messagebox here, just log, as it's optional.
elif cookie_method == "file":
cookie_file = self.cookie_file_entry.get().strip()
# Expand user's home directory (e.g., ~/.config/cookies.txt)
expanded_cookie_file = os.path.expanduser(cookie_file)
if cookie_file and os.path.exists(expanded_cookie_file):
ydl_opts['cookiefile'] = expanded_cookie_file
self._log(f"Using cookies from file: {expanded_cookie_file}", "blue")
else:
self._log("Warning: Cookie file not found or path invalid. Proceeding without cookies.", "orange")
self.root.after(0, lambda: messagebox.showwarning("Cookie Error", "Cookie file not found or path invalid. Proceeding without cookies."))
# Reset cookie options in GUI if file is invalid.
self.root.after(0, lambda: self.cookie_method.set("none"))
self.root.after(0, self._toggle_cookie_input) # Update GUI on main thread
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
# Extract information and download the video.
info_dict = ydl.extract_info(youtube_link, download=True)
# Get the video title and sanitize it for use as a filename.
self.suggested_video_title = info_dict.get('title', 'downloaded_video')
self.suggested_video_title = re.sub(r'[\\/:*?"<>|]', '_', self.suggested_video_title)
self.suggested_video_title = self.suggested_video_title.strip()
if not self.suggested_video_title:
self.suggested_video_title = "youtube_video" # Fallback title
# Find the actual downloaded file.
# yt-dlp creates a file starting with OUTPUT_BASENAME and an extension.
downloaded_files = glob.glob(f"{OUTPUT_BASENAME}.*")
for f in downloaded_files:
# Prioritize common video extensions.
if f.startswith(OUTPUT_BASENAME) and (f.endswith(".mp4") or f.endswith(".webm") or f.endswith(".mkv")):
self.downloaded_video_path = f
break
if not self.downloaded_video_path:
raise Exception("Downloaded video file not found after yt-dlp completion.")
self._log(f"Video '{self.suggested_video_title}' downloaded to: {self.downloaded_video_path}", "green")
self._play_test_sound() # Play the test sound after successful download.
# Check if the "Play video after download" checkbox is checked
if self.play_after_download_var.get():
self.root.after(0, self.play_video) # Call play_video on the main thread
# Enable action buttons on the main thread after download is complete.
self.root.after(0, lambda: self.play_button.config(state=tk.NORMAL))
self.root.after(0, lambda: self.save_button.config(state=tk.NORMAL))
self.root.after(0, lambda: self.delete_button.config(state=tk.NORMAL))
except yt_dlp.utils.DownloadError as e:
self._log(f"Download Error: {e}", "red")
self.root.after(0, lambda: messagebox.showerror("Download Error", f"Failed to download video: {e}"))
except Exception as e:
self._log(f"An unexpected error occurred: {e}", "red")
self.root.after(0, lambda: messagebox.showerror("Error", f"An unexpected error occurred during download: {e}"))
finally:
# Re-enable the start button on the main thread.
self.root.after(0, lambda: self.start_button.config(state=tk.NORMAL)) # Changed from download_button
def _play_test_sound(self):
"""
Generates a small test sound using espeak-ng and aplay, then plays it.
This function is optional and will be skipped if the tools are not found.
"""
espeak_ng_ok = subprocess.run(["which", "espeak-ng"], capture_output=True).returncode == 0
aplay_ok = subprocess.run(["which", "aplay"], capture_output=True).returncode == 0
if not (espeak_ng_ok and aplay_ok):
self._log("Skipping audio test: espeak-ng or aplay not found.", "orange")
return
self._log("Generating and playing audio test...", "blue")
test_text = "Initiating video playback. Stand by."
try:
# Generate WAV file from text.
subprocess.run(["espeak-ng", "-w", TEST_SOUND_FILE, test_text], check=True, capture_output=True)
# Play the generated WAV file.
subprocess.run(["aplay", TEST_SOUND_FILE], check=True, capture_output=True)
self._log("Audio test played successfully.", "green")
except (subprocess.CalledProcessError, FileNotFoundError) as e:
self._log(f"Warning: Failed to generate or play test sound: {e}", "orange")
finally:
# Clean up the temporary sound file.
if os.path.exists(TEST_SOUND_FILE):
os.remove(TEST_SOUND_FILE)
def play_video(self):
"""
Plays the downloaded video using smplayer.
It launches smplayer as a separate process.
Auto-delete functionality has been removed; videos are not deleted after playing.
"""
if not self.downloaded_video_path or not os.path.exists(self.downloaded_video_path):
messagebox.showwarning("Playback Error", "No video downloaded or file not found to play.")
return
self._log(f"Playing video: {self.downloaded_video_path}", "blue")
try:
# Launch smplayer in a non-blocking way (Popen) so the GUI remains responsive.
self.smplayer_process = subprocess.Popen(["smplayer", self.downloaded_video_path])
self._log("smplayer launched. Check your desktop for the video player window.", "green")
# Start a thread to monitor SMPlayer (only for cleanup, not auto-delete)
monitor_thread = threading.Thread(target=self._monitor_playback_and_cleanup)
monitor_thread.daemon = True # Allow the main program to exit even if this thread is running
monitor_thread.start()
except FileNotFoundError:
messagebox.showerror("Playback Error", "smplayer not found. Please ensure it is installed and in your system's PATH.")
self._log("smplayer not found. Cannot play video.", "red")
except Exception as e:
messagebox.showerror("Playback Error", f"An error occurred during playback: {e}")
self._log(f"Error during playback: {e}", "red")
def _monitor_playback_and_cleanup(self):
"""
Monitors the SMPlayer process. This is now primarily for knowing when playback
is finished, not for auto-deletion.
This runs in a separate thread.
"""
if self.smplayer_process:
self._log("Monitoring SMPlayer process for closure...", "blue")
self.smplayer_process.wait() # Wait until SMPlayer process terminates
self.root.after(0, lambda: self._log("SMPlayer process closed.", "blue"))
self.smplayer_process = None # Clear the process reference
def save_video(self):
"""
Allows the user to save the downloaded video to a chosen location.
Uses a file dialog for path selection and moves the file.
"""
if not self.downloaded_video_path or not os.path.exists(self.downloaded_video_path):
messagebox.showwarning("Save Error", "No video downloaded or file not found to save.")
return
self._log("Initiating video save process...", "blue") # Log for saving in progress
# Determine the original file extension.
original_ext = os.path.splitext(self.downloaded_video_path)[1]
# Suggest a filename based on the video title and original extension.
initial_filename = f"{self.suggested_video_title}{original_ext}" if self.suggested_video_title else os.path.basename(self.downloaded_video_path)
# Set the initial directory for the file dialog to the last saved folder,
# or the user's home directory if no last folder is set or it's invalid.
initial_dir = self.last_save_folder if os.path.isdir(self.last_save_folder) else os.path.expanduser("~")
# Open a "Save As" file dialog.
save_path = filedialog.asksaveasfilename(
initialdir=initial_dir,
initialfile=initial_filename,
defaultextension=original_ext,
filetypes=[("Video files", "*.mp4 *.webm *.mkv"), ("All files", "*.*")]
)
if save_path:
try:
# Move the downloaded file to the new location.
shutil.move(self.downloaded_video_path, save_path)
self._log(f"Video saved successfully to: {save_path}", "green")
self.downloaded_video_path = None # Mark as moved, no longer managed by app.
self.suggested_video_title = None # Reset title.
# Disable action buttons as the video has been saved/moved.
self.play_button.config(state=tk.DISABLED)
self.save_button.config(state=tk.DISABLED)
self.delete_button.config(state=tk.DISABLED)
# Update the last save folder for future use.
new_save_folder = os.path.dirname(save_path)
self._save_last_save_folder(new_save_folder)
except Exception as e:
messagebox.showerror("Save Error", f"Failed to save video: {e}")
self._log(f"Error saving video: {e}", "red")
else:
self._log("Video save cancelled by user.", "blue")
def delete_downloaded_video(self, confirm=True):
"""
Deletes the temporarily downloaded video file from the current directory.
Asks for user confirmation before deletion, unless confirm=False.
"""
if not self.downloaded_video_path or not os.path.exists(self.downloaded_video_path):
if not confirm: # Only log if it's an auto-delete attempt on a non-existent file
self._log("No video downloaded or file not found for auto-deletion.", "orange")
else:
messagebox.showwarning("Delete Error", "No video downloaded or file not found to delete.")
return
should_delete = True
if confirm:
should_delete = messagebox.askyesno("Confirm Deletion", f"Are you sure you want to delete '{os.path.basename(self.downloaded_video_path)}'?")
if should_delete:
try:
os.remove(self.downloaded_video_path)
self._log(f"Deleted downloaded video: {self.downloaded_video_path}", "green")
self.downloaded_video_path = None # Mark as deleted.
self.suggested_video_title = None # Reset title.
# Disable action buttons as the video has been deleted.
self.play_button.config(state=tk.DISABLED)
self.save_button.config(state=tk.DISABLED)
self.delete_button.config(state=tk.DISABLED)
except Exception as e:
messagebox.showerror("Delete Error", f"Failed to delete video: {e}")
self._log(f"Error deleting video: {e}", "red")
elif confirm: # Only log cancellation if it was a user-initiated delete
self._log("Video deletion cancelled by user.", "blue")
def cleanup_on_exit(self):
"""
Performs final cleanup of any leftover temporary files (downloaded video,
test sound file) when the application window is closed.
"""
self._log("Performing final cleanup...", "blue")
# Clean up any residual 'downloaded_video.*' files that might be left over.
for f in glob.glob(f"{OUTPUT_BASENAME}.*"):
if os.path.exists(f): # Ensure file still exists before trying to remove.
try:
os.remove(f)
self._log(f"Cleaned up residual temporary file: {f}", "blue")
except Exception as e:
self._log(f"Warning: Could not clean up temporary file {f}. Reason: {e}", "orange")
# Clean up the temporary test sound file.
if os.path.exists(TEST_SOUND_FILE):
try:
os.remove(TEST_SOUND_FILE)
self._log(f"Cleaned up temporary sound file: {TEST_SOUND_FILE}", "blue")
except Exception as e:
self._log(f"Warning: Could not clean up temporary sound file {TEST_SOUND_FILE}. Reason: {e}", "orange")
self._log("Cleanup complete. Exiting application.", "blue")
self.root.destroy() # Destroy the Tkinter root window to close the application.
if __name__ == "__main__":
# Create the main Tkinter window.
root = tk.Tk()
# Create an instance of the application.
app = YouTubeDownloaderApp(root)
# Bind the cleanup function to the window close event.
root.protocol("WM_DELETE_WINDOW", app.cleanup_on_exit)
# Start the Tkinter event loop.
root.mainloop()
Save the code and run the command with:
$ python3 youtube-watcher.py
Note:
This can take a moment to open, and don’t close the terminal window you used to open the app. If you do, this will close the app. The best to do is minimize it. If you want to add a menu item, you can also use the command to add the app to your applications menu, or panel, for a shortcut.
-
This reply was modified 1 week, 6 days ago by
thumbtak. Reason: Added checkbox called "Auto delete video after playing"
-
This reply was modified 1 week, 6 days ago by
thumbtak. Reason: Removed popup "asking if user wants to delete the video" after the box is checked for auto delete
-
This reply was modified 1 week, 6 days ago by
thumbtak. Reason: Paste button added, and grab button changed to download icon
-
This reply was modified 1 week, 6 days ago by
thumbtak. Reason: Theme and layout changed
-
This reply was modified 1 week, 6 days ago by
thumbtak. Reason: Script overhaul to fix bug
-
This reply was modified 1 week, 6 days ago by
thumbtak. Reason: Cookies weren't working do to an issue where each letter was being sent from the browser, not as a single word. This was fixed