Fixed Windows yt-dlp verification
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
YouTube Channel Archiver - Standalone Windows Executable
|
YouTube Channel Archiver - Standalone Windows Executable
|
||||||
Complete self-contained application with dependency management.
|
Fixed version that avoids subprocess recursion issues with PyInstaller.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
@@ -46,6 +46,35 @@ class YouTubeArchiverStandalone:
|
|||||||
# Start checking queue for output updates
|
# Start checking queue for output updates
|
||||||
self.root.after(100, self.check_queue)
|
self.root.after(100, self.check_queue)
|
||||||
|
|
||||||
|
def get_python_executable(self):
|
||||||
|
"""Get the actual Python executable path, not the PyInstaller executable."""
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
# Running as PyInstaller executable
|
||||||
|
# Try to find Python in common locations
|
||||||
|
possible_paths = [
|
||||||
|
'python',
|
||||||
|
'python3',
|
||||||
|
'py',
|
||||||
|
os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Programs', 'Python', 'Python*', 'python.exe'),
|
||||||
|
os.path.join(os.environ.get('PROGRAMFILES', ''), 'Python*', 'python.exe'),
|
||||||
|
os.path.join(os.environ.get('PROGRAMFILES(X86)', ''), 'Python*', 'python.exe'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for path in possible_paths:
|
||||||
|
try:
|
||||||
|
result = subprocess.run([path, '--version'],
|
||||||
|
capture_output=True, text=True, check=True, timeout=5)
|
||||||
|
if 'Python' in result.stdout:
|
||||||
|
return path
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If we can't find Python, we'll have to ask the user
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
# Running as normal Python script
|
||||||
|
return sys.executable
|
||||||
|
|
||||||
def create_widgets(self):
|
def create_widgets(self):
|
||||||
# Main frame with padding
|
# Main frame with padding
|
||||||
main_frame = ttk.Frame(self.root, padding="15")
|
main_frame = ttk.Frame(self.root, padding="15")
|
||||||
@@ -135,6 +164,12 @@ class YouTubeArchiverStandalone:
|
|||||||
ttk.Checkbutton(options_frame, text="Download metadata",
|
ttk.Checkbutton(options_frame, text="Download metadata",
|
||||||
variable=self.download_metadata).grid(row=2, column=0, sticky=tk.W, pady=2)
|
variable=self.download_metadata).grid(row=2, column=0, sticky=tk.W, pady=2)
|
||||||
|
|
||||||
|
# Manual install button (for when auto-install fails)
|
||||||
|
self.manual_install_btn = ttk.Button(options_frame, text="Install yt-dlp Manually",
|
||||||
|
command=self.show_manual_install_instructions)
|
||||||
|
self.manual_install_btn.grid(row=2, column=1, sticky=tk.W, pady=2)
|
||||||
|
self.manual_install_btn.grid_remove() # Hide initially
|
||||||
|
|
||||||
# Download button
|
# Download button
|
||||||
self.download_btn = ttk.Button(main_frame, text="Download Channel",
|
self.download_btn = ttk.Button(main_frame, text="Download Channel",
|
||||||
command=self.start_download,
|
command=self.start_download,
|
||||||
@@ -191,7 +226,18 @@ class YouTubeArchiverStandalone:
|
|||||||
# Check Python
|
# Check Python
|
||||||
python_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
python_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
||||||
self.log_output(f"Python {python_version} detected")
|
self.log_output(f"Python {python_version} detected")
|
||||||
self.python_status.config(text=f"Python: {python_version} [OK]", foreground="green")
|
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
self.log_output("Running as compiled executable")
|
||||||
|
python_exe = self.get_python_executable()
|
||||||
|
if python_exe:
|
||||||
|
self.log_output(f"Found Python at: {python_exe}")
|
||||||
|
self.python_status.config(text=f"Python: {python_version} [OK]", foreground="green")
|
||||||
|
else:
|
||||||
|
self.log_output("WARNING: Could not locate Python executable for package installation")
|
||||||
|
self.python_status.config(text="Python: Limited functionality", foreground="orange")
|
||||||
|
else:
|
||||||
|
self.python_status.config(text=f"Python: {python_version} [OK]", foreground="green")
|
||||||
|
|
||||||
# Check yt-dlp in background thread
|
# Check yt-dlp in background thread
|
||||||
threading.Thread(target=self.check_ytdlp, daemon=True).start()
|
threading.Thread(target=self.check_ytdlp, daemon=True).start()
|
||||||
@@ -219,29 +265,97 @@ class YouTubeArchiverStandalone:
|
|||||||
|
|
||||||
def install_ytdlp(self):
|
def install_ytdlp(self):
|
||||||
"""Install yt-dlp using pip."""
|
"""Install yt-dlp using pip."""
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
# Running as executable - need to find Python
|
||||||
|
python_exe = self.get_python_executable()
|
||||||
|
if not python_exe:
|
||||||
|
self.log_output("Cannot install yt-dlp automatically - Python not found")
|
||||||
|
self.ytdlp_status.config(text="yt-dlp: Manual installation required", foreground="red")
|
||||||
|
self.manual_install_btn.grid() # Show manual install button
|
||||||
|
self.show_manual_install_instructions()
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
python_exe = sys.executable
|
||||||
|
|
||||||
self.log_output("yt-dlp not found. Installing...")
|
self.log_output("yt-dlp not found. Installing...")
|
||||||
self.ytdlp_status.config(text="yt-dlp: Installing...", foreground="orange")
|
self.ytdlp_status.config(text="yt-dlp: Installing...", foreground="orange")
|
||||||
|
|
||||||
# Install in background thread
|
# Install in background thread
|
||||||
threading.Thread(target=self.do_install_ytdlp, daemon=True).start()
|
threading.Thread(target=self.do_install_ytdlp, args=(python_exe,), daemon=True).start()
|
||||||
|
|
||||||
def do_install_ytdlp(self):
|
def do_install_ytdlp(self, python_exe):
|
||||||
"""Actually install yt-dlp."""
|
"""Actually install yt-dlp."""
|
||||||
try:
|
try:
|
||||||
# Install yt-dlp
|
# Install yt-dlp
|
||||||
result = subprocess.run([sys.executable, '-m', 'pip', 'install', 'yt-dlp'],
|
self.root.after(0, lambda: self.log_output(f"Installing yt-dlp using: {python_exe}"))
|
||||||
capture_output=True, text=True, check=True, timeout=60)
|
|
||||||
|
|
||||||
# Verify installation
|
result = subprocess.run([python_exe, '-m', 'pip', 'install', 'yt-dlp'],
|
||||||
result = subprocess.run(['yt-dlp', '--version'],
|
capture_output=True, text=True, check=True, timeout=120)
|
||||||
capture_output=True, text=True, check=True, timeout=10)
|
|
||||||
version = result.stdout.strip()
|
|
||||||
|
|
||||||
# Update UI from main thread
|
self.root.after(0, lambda: self.log_output("Installation completed, verifying..."))
|
||||||
self.root.after(0, lambda: self.ytdlp_installed(version))
|
|
||||||
|
# Try multiple methods to verify yt-dlp installation
|
||||||
|
version = self.verify_ytdlp_installation(python_exe)
|
||||||
|
|
||||||
|
if version:
|
||||||
|
# Update UI from main thread
|
||||||
|
self.root.after(0, lambda: self.ytdlp_installed(version))
|
||||||
|
else:
|
||||||
|
# Installation succeeded but verification failed - that's still OK
|
||||||
|
self.root.after(0, lambda: self.ytdlp_install_partial())
|
||||||
|
|
||||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
||||||
self.root.after(0, lambda: self.ytdlp_install_failed(str(e)))
|
error_msg = str(e)
|
||||||
|
if hasattr(e, 'stderr') and e.stderr:
|
||||||
|
error_msg += f"\nError output: {e.stderr[:200]}"
|
||||||
|
self.root.after(0, lambda: self.ytdlp_install_failed(error_msg))
|
||||||
|
|
||||||
|
def verify_ytdlp_installation(self, python_exe):
|
||||||
|
"""Try multiple methods to verify yt-dlp is installed and get its version."""
|
||||||
|
|
||||||
|
# Method 1: Try direct yt-dlp command
|
||||||
|
try:
|
||||||
|
result = subprocess.run(['yt-dlp', '--version'],
|
||||||
|
capture_output=True, text=True, check=True, timeout=10)
|
||||||
|
return result.stdout.strip()
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Method 2: Try through Python module
|
||||||
|
try:
|
||||||
|
result = subprocess.run([python_exe, '-m', 'yt_dlp', '--version'],
|
||||||
|
capture_output=True, text=True, check=True, timeout=10)
|
||||||
|
return result.stdout.strip()
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Method 3: Try to import and get version programmatically
|
||||||
|
try:
|
||||||
|
result = subprocess.run([python_exe, '-c', 'import yt_dlp; print(yt_dlp.version.__version__)'],
|
||||||
|
capture_output=True, text=True, check=True, timeout=10)
|
||||||
|
version = result.stdout.strip()
|
||||||
|
if version:
|
||||||
|
return version
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Method 4: Check if module can be imported (basic verification)
|
||||||
|
try:
|
||||||
|
result = subprocess.run([python_exe, '-c', 'import yt_dlp; print("installed")'],
|
||||||
|
capture_output=True, text=True, check=True, timeout=10)
|
||||||
|
if 'installed' in result.stdout:
|
||||||
|
return "installed (version unknown)"
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def ytdlp_install_partial(self):
|
||||||
|
"""Called when yt-dlp was installed but can't be verified normally."""
|
||||||
|
self.log_output("yt-dlp installed successfully!")
|
||||||
|
self.log_output("Note: yt-dlp command may need to be accessed via 'python -m yt_dlp'")
|
||||||
|
self.ytdlp_status.config(text="yt-dlp: Installed [OK]", foreground="green")
|
||||||
|
self.dependencies_ready()
|
||||||
|
|
||||||
def ytdlp_installed(self, version):
|
def ytdlp_installed(self, version):
|
||||||
"""Called when yt-dlp is successfully installed."""
|
"""Called when yt-dlp is successfully installed."""
|
||||||
@@ -253,18 +367,78 @@ class YouTubeArchiverStandalone:
|
|||||||
"""Called when yt-dlp installation fails."""
|
"""Called when yt-dlp installation fails."""
|
||||||
self.log_output(f"Failed to install yt-dlp: {error}")
|
self.log_output(f"Failed to install yt-dlp: {error}")
|
||||||
self.ytdlp_status.config(text="yt-dlp: Installation failed [ERROR]", foreground="red")
|
self.ytdlp_status.config(text="yt-dlp: Installation failed [ERROR]", foreground="red")
|
||||||
self.status_var.set("Error: Could not install yt-dlp. Check internet connection.")
|
self.status_var.set("Error: Could not install yt-dlp automatically")
|
||||||
|
self.manual_install_btn.grid() # Show manual install button
|
||||||
|
|
||||||
messagebox.showerror("Installation Error",
|
messagebox.showerror("Installation Error",
|
||||||
"Could not install yt-dlp automatically.\n\n"
|
"Could not install yt-dlp automatically.\n\n"
|
||||||
"Please check your internet connection and try again.\n"
|
"This can happen when running as an executable.\n"
|
||||||
"You can also install it manually with: pip install yt-dlp")
|
"Please click 'Install yt-dlp Manually' for instructions.")
|
||||||
|
|
||||||
|
def show_manual_install_instructions(self):
|
||||||
|
"""Show manual installation instructions."""
|
||||||
|
instructions = """Manual yt-dlp Installation Instructions:
|
||||||
|
|
||||||
|
1. Open Command Prompt or PowerShell as Administrator
|
||||||
|
2. Run one of these commands:
|
||||||
|
|
||||||
|
pip install yt-dlp
|
||||||
|
|
||||||
|
OR
|
||||||
|
|
||||||
|
python -m pip install yt-dlp
|
||||||
|
|
||||||
|
OR
|
||||||
|
|
||||||
|
py -m pip install yt-dlp
|
||||||
|
|
||||||
|
3. After installation, restart this application
|
||||||
|
|
||||||
|
Alternative method:
|
||||||
|
- Download yt-dlp.exe from: https://github.com/yt-dlp/yt-dlp/releases
|
||||||
|
- Place it in the same folder as this application
|
||||||
|
- Or place it in a folder that's in your system PATH
|
||||||
|
|
||||||
|
The application will automatically detect yt-dlp once it's installed."""
|
||||||
|
|
||||||
|
# Create instruction window
|
||||||
|
instruction_window = tk.Toplevel(self.root)
|
||||||
|
instruction_window.title("Manual Installation Instructions")
|
||||||
|
instruction_window.geometry("600x400")
|
||||||
|
instruction_window.transient(self.root)
|
||||||
|
instruction_window.grab_set()
|
||||||
|
|
||||||
|
frame = ttk.Frame(instruction_window, padding="15")
|
||||||
|
frame.pack(fill="both", expand=True)
|
||||||
|
|
||||||
|
text_widget = scrolledtext.ScrolledText(frame, height=20, width=70, font=('Consolas', 10))
|
||||||
|
text_widget.pack(fill="both", expand=True)
|
||||||
|
text_widget.insert(tk.END, instructions)
|
||||||
|
text_widget.config(state=tk.DISABLED)
|
||||||
|
|
||||||
|
button_frame = ttk.Frame(frame)
|
||||||
|
button_frame.pack(fill="x", pady=(10, 0))
|
||||||
|
|
||||||
|
ttk.Button(button_frame, text="Recheck Dependencies",
|
||||||
|
command=lambda: [instruction_window.destroy(), self.recheck_dependencies()]).pack(side="right", padx=(5, 0))
|
||||||
|
ttk.Button(button_frame, text="Close",
|
||||||
|
command=instruction_window.destroy).pack(side="right")
|
||||||
|
|
||||||
|
def recheck_dependencies(self):
|
||||||
|
"""Recheck dependencies after manual installation."""
|
||||||
|
self.dependencies_checked = False
|
||||||
|
self.manual_install_btn.grid_remove()
|
||||||
|
self.download_btn.state(['disabled'])
|
||||||
|
self.status_var.set("Rechecking dependencies...")
|
||||||
|
self.log_output("\n=== Rechecking Dependencies ===")
|
||||||
|
threading.Thread(target=self.check_ytdlp, daemon=True).start()
|
||||||
|
|
||||||
def dependencies_ready(self):
|
def dependencies_ready(self):
|
||||||
"""Called when all dependencies are ready."""
|
"""Called when all dependencies are ready."""
|
||||||
if not self.dependencies_checked:
|
if not self.dependencies_checked:
|
||||||
self.dependencies_checked = True
|
self.dependencies_checked = True
|
||||||
self.download_btn.state(['!disabled'])
|
self.download_btn.state(['!disabled'])
|
||||||
|
self.manual_install_btn.grid_remove()
|
||||||
self.status_var.set("Ready to download!")
|
self.status_var.set("Ready to download!")
|
||||||
self.log_output("=== All dependencies ready! ===")
|
self.log_output("=== All dependencies ready! ===")
|
||||||
|
|
||||||
@@ -301,7 +475,8 @@ class YouTubeArchiverStandalone:
|
|||||||
|
|
||||||
def build_command(self):
|
def build_command(self):
|
||||||
"""Build the yt-dlp command based on GUI settings."""
|
"""Build the yt-dlp command based on GUI settings."""
|
||||||
cmd = ['yt-dlp']
|
# Try to determine the best way to call yt-dlp
|
||||||
|
cmd = self.get_ytdlp_command()
|
||||||
|
|
||||||
# Output template
|
# Output template
|
||||||
output_path = Path(self.output_dir.get())
|
output_path = Path(self.output_dir.get())
|
||||||
@@ -344,6 +519,30 @@ class YouTubeArchiverStandalone:
|
|||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
|
def get_ytdlp_command(self):
|
||||||
|
"""Determine the best way to call yt-dlp based on what's available."""
|
||||||
|
|
||||||
|
# Method 1: Try direct yt-dlp command
|
||||||
|
try:
|
||||||
|
subprocess.run(['yt-dlp', '--version'],
|
||||||
|
capture_output=True, text=True, check=True, timeout=5)
|
||||||
|
return ['yt-dlp']
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Method 2: Try through Python module
|
||||||
|
python_exe = self.get_python_executable()
|
||||||
|
if python_exe:
|
||||||
|
try:
|
||||||
|
subprocess.run([python_exe, '-m', 'yt_dlp', '--version'],
|
||||||
|
capture_output=True, text=True, check=True, timeout=5)
|
||||||
|
return [python_exe, '-m', 'yt_dlp']
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Method 3: Fallback to direct command (might fail but user will see error)
|
||||||
|
return ['yt-dlp']
|
||||||
|
|
||||||
def start_download(self):
|
def start_download(self):
|
||||||
"""Start the download process."""
|
"""Start the download process."""
|
||||||
if self.is_downloading:
|
if self.is_downloading:
|
||||||
@@ -446,6 +645,12 @@ class YouTubeArchiverStandalone:
|
|||||||
self.root.after(100, self.check_queue)
|
self.root.after(100, self.check_queue)
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
# Prevent recursive calls when frozen
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
# Running as PyInstaller executable
|
||||||
|
import multiprocessing
|
||||||
|
multiprocessing.freeze_support()
|
||||||
|
|
||||||
# Set up the main window
|
# Set up the main window
|
||||||
root = tk.Tk()
|
root = tk.Tk()
|
||||||
|
|
||||||
|
@@ -1,492 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
YouTube Channel Archiver - Standalone Windows Executable
|
|
||||||
Complete self-contained application with dependency management.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import tkinter as tk
|
|
||||||
from tkinter import ttk, filedialog, messagebox, scrolledtext
|
|
||||||
import subprocess
|
|
||||||
import threading
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
import platform
|
|
||||||
from pathlib import Path
|
|
||||||
import queue
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
class YouTubeArchiverStandalone:
|
|
||||||
def __init__(self, root):
|
|
||||||
self.root = root
|
|
||||||
self.root.title("YouTube Channel Archiver v1.0")
|
|
||||||
self.root.geometry("700x600")
|
|
||||||
self.root.resizable(True, True)
|
|
||||||
|
|
||||||
# Queue for thread communication
|
|
||||||
self.output_queue = queue.Queue()
|
|
||||||
|
|
||||||
# Variables
|
|
||||||
self.channel_url = tk.StringVar()
|
|
||||||
self.output_dir = tk.StringVar(value=str(Path.home() / "Downloads" / "YouTube_Archives"))
|
|
||||||
self.quality = tk.StringVar(value="best")
|
|
||||||
self.audio_only = tk.BooleanVar()
|
|
||||||
self.download_thumbnails = tk.BooleanVar(value=True)
|
|
||||||
self.download_metadata = tk.BooleanVar(value=True)
|
|
||||||
|
|
||||||
# Track processes
|
|
||||||
self.download_process = None
|
|
||||||
self.is_downloading = False
|
|
||||||
self.dependencies_checked = False
|
|
||||||
|
|
||||||
self.create_widgets()
|
|
||||||
|
|
||||||
# Check dependencies in background
|
|
||||||
self.root.after(1000, self.check_all_dependencies)
|
|
||||||
|
|
||||||
# Start checking queue for output updates
|
|
||||||
self.root.after(100, self.check_queue)
|
|
||||||
|
|
||||||
def create_widgets(self):
|
|
||||||
# Main frame with padding
|
|
||||||
main_frame = ttk.Frame(self.root, padding="15")
|
|
||||||
main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
|
||||||
|
|
||||||
# Configure grid weights
|
|
||||||
self.root.columnconfigure(0, weight=1)
|
|
||||||
self.root.rowconfigure(0, weight=1)
|
|
||||||
main_frame.columnconfigure(1, weight=1)
|
|
||||||
|
|
||||||
# Title and info
|
|
||||||
title_frame = ttk.Frame(main_frame)
|
|
||||||
title_frame.grid(row=0, column=0, columnspan=3, pady=(0, 20))
|
|
||||||
|
|
||||||
title_label = ttk.Label(title_frame, text="YouTube Channel Archiver",
|
|
||||||
font=('Arial', 18, 'bold'))
|
|
||||||
title_label.pack()
|
|
||||||
|
|
||||||
subtitle_label = ttk.Label(title_frame, text="Download entire YouTube channels with ease",
|
|
||||||
font=('Arial', 10))
|
|
||||||
subtitle_label.pack()
|
|
||||||
|
|
||||||
# Status frame
|
|
||||||
status_frame = ttk.LabelFrame(main_frame, text="System Status", padding="10")
|
|
||||||
status_frame.grid(row=1, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(0, 15))
|
|
||||||
status_frame.columnconfigure(1, weight=1)
|
|
||||||
|
|
||||||
self.python_status = ttk.Label(status_frame, text="Python: Checking...", foreground="orange")
|
|
||||||
self.python_status.grid(row=0, column=0, sticky=tk.W, pady=2)
|
|
||||||
|
|
||||||
self.ytdlp_status = ttk.Label(status_frame, text="yt-dlp: Checking...", foreground="orange")
|
|
||||||
self.ytdlp_status.grid(row=1, column=0, sticky=tk.W, pady=2)
|
|
||||||
|
|
||||||
# Channel URL input
|
|
||||||
ttk.Label(main_frame, text="YouTube Channel URL:", font=('Arial', 10, 'bold')).grid(
|
|
||||||
row=2, column=0, sticky=tk.W, pady=(5, 2))
|
|
||||||
|
|
||||||
url_frame = ttk.Frame(main_frame)
|
|
||||||
url_frame.grid(row=3, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(0, 10))
|
|
||||||
url_frame.columnconfigure(0, weight=1)
|
|
||||||
|
|
||||||
url_entry = ttk.Entry(url_frame, textvariable=self.channel_url, font=('Arial', 10))
|
|
||||||
url_entry.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=(0, 10))
|
|
||||||
|
|
||||||
paste_btn = ttk.Button(url_frame, text="Paste", command=self.paste_from_clipboard)
|
|
||||||
paste_btn.grid(row=0, column=1)
|
|
||||||
|
|
||||||
# Examples
|
|
||||||
examples_label = ttk.Label(main_frame,
|
|
||||||
text="Examples: https://www.youtube.com/@channelname or https://www.youtube.com/c/channelname",
|
|
||||||
font=('Arial', 8), foreground="gray")
|
|
||||||
examples_label.grid(row=4, column=0, columnspan=3, sticky=tk.W)
|
|
||||||
|
|
||||||
# Output directory
|
|
||||||
ttk.Label(main_frame, text="Download Location:", font=('Arial', 10, 'bold')).grid(
|
|
||||||
row=5, column=0, sticky=tk.W, pady=(15, 2))
|
|
||||||
|
|
||||||
dir_frame = ttk.Frame(main_frame)
|
|
||||||
dir_frame.grid(row=6, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(0, 10))
|
|
||||||
dir_frame.columnconfigure(0, weight=1)
|
|
||||||
|
|
||||||
dir_entry = ttk.Entry(dir_frame, textvariable=self.output_dir, font=('Arial', 10))
|
|
||||||
dir_entry.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=(0, 10))
|
|
||||||
|
|
||||||
browse_btn = ttk.Button(dir_frame, text="Browse", command=self.browse_directory)
|
|
||||||
browse_btn.grid(row=0, column=1)
|
|
||||||
|
|
||||||
# Options frame
|
|
||||||
options_frame = ttk.LabelFrame(main_frame, text="Download Options", padding="10")
|
|
||||||
options_frame.grid(row=7, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(0, 15))
|
|
||||||
options_frame.columnconfigure(1, weight=1)
|
|
||||||
|
|
||||||
# Quality selection
|
|
||||||
ttk.Label(options_frame, text="Video Quality:").grid(row=0, column=0, sticky=tk.W, pady=2)
|
|
||||||
quality_combo = ttk.Combobox(options_frame, textvariable=self.quality,
|
|
||||||
values=["best", "1080p", "720p", "480p", "worst"],
|
|
||||||
state="readonly", width=15)
|
|
||||||
quality_combo.grid(row=0, column=1, sticky=tk.W, pady=2, padx=(10, 0))
|
|
||||||
|
|
||||||
# Checkboxes
|
|
||||||
ttk.Checkbutton(options_frame, text="Audio only",
|
|
||||||
variable=self.audio_only).grid(row=1, column=0, sticky=tk.W, pady=2)
|
|
||||||
|
|
||||||
ttk.Checkbutton(options_frame, text="Download thumbnails",
|
|
||||||
variable=self.download_thumbnails).grid(row=1, column=1, sticky=tk.W, pady=2)
|
|
||||||
|
|
||||||
ttk.Checkbutton(options_frame, text="Download metadata",
|
|
||||||
variable=self.download_metadata).grid(row=2, column=0, sticky=tk.W, pady=2)
|
|
||||||
|
|
||||||
# Download button
|
|
||||||
self.download_btn = ttk.Button(main_frame, text="Download Channel",
|
|
||||||
command=self.start_download,
|
|
||||||
style="Accent.TButton")
|
|
||||||
self.download_btn.grid(row=8, column=0, columnspan=3, pady=15)
|
|
||||||
self.download_btn.state(['disabled']) # Disabled until dependencies are ready
|
|
||||||
|
|
||||||
# Progress bar
|
|
||||||
self.progress = ttk.Progressbar(main_frame, mode='indeterminate')
|
|
||||||
self.progress.grid(row=9, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(0, 10))
|
|
||||||
|
|
||||||
# Output text area
|
|
||||||
output_frame = ttk.LabelFrame(main_frame, text="Download Progress", padding="5")
|
|
||||||
output_frame.grid(row=10, column=0, columnspan=3, sticky=(tk.W, tk.E, tk.N, tk.S), pady=(0, 10))
|
|
||||||
output_frame.columnconfigure(0, weight=1)
|
|
||||||
output_frame.rowconfigure(0, weight=1)
|
|
||||||
main_frame.rowconfigure(10, weight=1)
|
|
||||||
|
|
||||||
self.output_text = scrolledtext.ScrolledText(output_frame, height=10, width=80,
|
|
||||||
font=('Consolas', 9))
|
|
||||||
self.output_text.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
|
||||||
|
|
||||||
# Status bar
|
|
||||||
self.status_var = tk.StringVar(value="Checking system dependencies...")
|
|
||||||
status_bar = ttk.Label(main_frame, textvariable=self.status_var,
|
|
||||||
relief=tk.SUNKEN, anchor=tk.W)
|
|
||||||
status_bar.grid(row=11, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(5, 0))
|
|
||||||
|
|
||||||
def paste_from_clipboard(self):
|
|
||||||
"""Paste URL from clipboard."""
|
|
||||||
try:
|
|
||||||
clipboard_content = self.root.clipboard_get()
|
|
||||||
if "youtube.com" in clipboard_content or "youtu.be" in clipboard_content:
|
|
||||||
self.channel_url.set(clipboard_content.strip())
|
|
||||||
else:
|
|
||||||
messagebox.showwarning("Invalid URL", "Clipboard doesn't contain a YouTube URL")
|
|
||||||
except tk.TkError:
|
|
||||||
messagebox.showwarning("Clipboard Error", "Could not access clipboard")
|
|
||||||
|
|
||||||
def log_output(self, message):
|
|
||||||
"""Add message to output text area."""
|
|
||||||
self.output_text.insert(tk.END, message + "\n")
|
|
||||||
self.output_text.see(tk.END)
|
|
||||||
self.root.update_idletasks()
|
|
||||||
|
|
||||||
def clear_output(self):
|
|
||||||
"""Clear the output text area."""
|
|
||||||
self.output_text.delete(1.0, tk.END)
|
|
||||||
|
|
||||||
def check_all_dependencies(self):
|
|
||||||
"""Check all system dependencies."""
|
|
||||||
self.log_output("=== System Dependency Check ===")
|
|
||||||
|
|
||||||
# Check Python
|
|
||||||
python_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
|
||||||
self.log_output(f"Python {python_version} detected")
|
|
||||||
self.python_status.config(text=f"Python: {python_version} [OK]", foreground="green")
|
|
||||||
|
|
||||||
# Check yt-dlp in background thread
|
|
||||||
threading.Thread(target=self.check_ytdlp, daemon=True).start()
|
|
||||||
|
|
||||||
def check_ytdlp(self):
|
|
||||||
"""Check if yt-dlp is available, install if needed."""
|
|
||||||
try:
|
|
||||||
# Try to run yt-dlp
|
|
||||||
result = subprocess.run(['yt-dlp', '--version'],
|
|
||||||
capture_output=True, text=True, check=True, timeout=10)
|
|
||||||
version = result.stdout.strip()
|
|
||||||
|
|
||||||
# Update UI from main thread
|
|
||||||
self.root.after(0, lambda: self.ytdlp_found(version))
|
|
||||||
|
|
||||||
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
|
||||||
# yt-dlp not found, try to install
|
|
||||||
self.root.after(0, lambda: self.install_ytdlp())
|
|
||||||
|
|
||||||
def ytdlp_found(self, version):
|
|
||||||
"""Called when yt-dlp is found."""
|
|
||||||
self.log_output(f"yt-dlp {version} is available")
|
|
||||||
self.ytdlp_status.config(text=f"yt-dlp: {version} [OK]", foreground="green")
|
|
||||||
self.dependencies_ready()
|
|
||||||
|
|
||||||
def install_ytdlp(self):
|
|
||||||
"""Install yt-dlp using pip."""
|
|
||||||
self.log_output("yt-dlp not found. Installing...")
|
|
||||||
self.ytdlp_status.config(text="yt-dlp: Installing...", foreground="orange")
|
|
||||||
|
|
||||||
# Install in background thread
|
|
||||||
threading.Thread(target=self.do_install_ytdlp, daemon=True).start()
|
|
||||||
|
|
||||||
def do_install_ytdlp(self):
|
|
||||||
"""Actually install yt-dlp."""
|
|
||||||
try:
|
|
||||||
# Install yt-dlp
|
|
||||||
result = subprocess.run([sys.executable, '-m', 'pip', 'install', 'yt-dlp'],
|
|
||||||
capture_output=True, text=True, check=True, timeout=60)
|
|
||||||
|
|
||||||
# Verify installation
|
|
||||||
result = subprocess.run(['yt-dlp', '--version'],
|
|
||||||
capture_output=True, text=True, check=True, timeout=10)
|
|
||||||
version = result.stdout.strip()
|
|
||||||
|
|
||||||
# Update UI from main thread
|
|
||||||
self.root.after(0, lambda: self.ytdlp_installed(version))
|
|
||||||
|
|
||||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
|
||||||
self.root.after(0, lambda: self.ytdlp_install_failed(str(e)))
|
|
||||||
|
|
||||||
def ytdlp_installed(self, version):
|
|
||||||
"""Called when yt-dlp is successfully installed."""
|
|
||||||
self.log_output(f"yt-dlp {version} installed successfully!")
|
|
||||||
self.ytdlp_status.config(text=f"yt-dlp: {version} [OK]", foreground="green")
|
|
||||||
self.dependencies_ready()
|
|
||||||
|
|
||||||
def ytdlp_install_failed(self, error):
|
|
||||||
"""Called when yt-dlp installation fails."""
|
|
||||||
self.log_output(f"Failed to install yt-dlp: {error}")
|
|
||||||
self.ytdlp_status.config(text="yt-dlp: Installation failed [ERROR]", foreground="red")
|
|
||||||
self.status_var.set("Error: Could not install yt-dlp. Check internet connection.")
|
|
||||||
|
|
||||||
messagebox.showerror("Installation Error",
|
|
||||||
"Could not install yt-dlp automatically.\n\n"
|
|
||||||
"Please check your internet connection and try again.\n"
|
|
||||||
"You can also install it manually with: pip install yt-dlp")
|
|
||||||
|
|
||||||
def dependencies_ready(self):
|
|
||||||
"""Called when all dependencies are ready."""
|
|
||||||
if not self.dependencies_checked:
|
|
||||||
self.dependencies_checked = True
|
|
||||||
self.download_btn.state(['!disabled'])
|
|
||||||
self.status_var.set("Ready to download!")
|
|
||||||
self.log_output("=== All dependencies ready! ===")
|
|
||||||
|
|
||||||
def browse_directory(self):
|
|
||||||
"""Open directory browser."""
|
|
||||||
directory = filedialog.askdirectory(initialdir=self.output_dir.get())
|
|
||||||
if directory:
|
|
||||||
self.output_dir.set(directory)
|
|
||||||
|
|
||||||
def validate_inputs(self):
|
|
||||||
"""Validate user inputs."""
|
|
||||||
if not self.channel_url.get().strip():
|
|
||||||
messagebox.showerror("Error", "Please enter a YouTube channel URL")
|
|
||||||
return False
|
|
||||||
|
|
||||||
url = self.channel_url.get().strip()
|
|
||||||
if "youtube.com" not in url and "youtu.be" not in url:
|
|
||||||
messagebox.showerror("Error", "Please enter a valid YouTube URL")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not self.output_dir.get().strip():
|
|
||||||
messagebox.showerror("Error", "Please select an output directory")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Check if output directory exists or can be created
|
|
||||||
try:
|
|
||||||
output_path = Path(self.output_dir.get())
|
|
||||||
output_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
except Exception as e:
|
|
||||||
messagebox.showerror("Error", f"Cannot create output directory: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def build_command(self):
|
|
||||||
"""Build the yt-dlp command based on GUI settings."""
|
|
||||||
cmd = ['yt-dlp']
|
|
||||||
|
|
||||||
# Output template
|
|
||||||
output_path = Path(self.output_dir.get())
|
|
||||||
output_template = str(output_path / "%(uploader)s" / "%(upload_date)s - %(title)s.%(ext)s")
|
|
||||||
cmd.extend(['-o', output_template])
|
|
||||||
|
|
||||||
# Quality settings
|
|
||||||
if self.audio_only.get():
|
|
||||||
cmd.extend(['-f', 'bestaudio/best'])
|
|
||||||
else:
|
|
||||||
if self.quality.get() == 'best':
|
|
||||||
cmd.extend(['-f', 'best'])
|
|
||||||
elif self.quality.get() == 'worst':
|
|
||||||
cmd.extend(['-f', 'worst'])
|
|
||||||
else:
|
|
||||||
# Custom quality
|
|
||||||
quality_num = self.quality.get().replace('p', '')
|
|
||||||
cmd.extend(['-f', f'best[height<={quality_num}]'])
|
|
||||||
|
|
||||||
# Additional options
|
|
||||||
cmd.extend([
|
|
||||||
'--ignore-errors',
|
|
||||||
'--no-overwrites',
|
|
||||||
'--continue',
|
|
||||||
])
|
|
||||||
|
|
||||||
# Archive file
|
|
||||||
archive_file = output_path / 'download_archive.txt'
|
|
||||||
cmd.extend(['--download-archive', str(archive_file)])
|
|
||||||
|
|
||||||
# Metadata and thumbnails
|
|
||||||
if self.download_metadata.get():
|
|
||||||
cmd.extend(['--write-info-json', '--write-description'])
|
|
||||||
|
|
||||||
if self.download_thumbnails.get():
|
|
||||||
cmd.extend(['--write-thumbnail'])
|
|
||||||
|
|
||||||
# Add channel URL
|
|
||||||
cmd.append(self.channel_url.get().strip())
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
|
|
||||||
def start_download(self):
|
|
||||||
"""Start the download process."""
|
|
||||||
if self.is_downloading:
|
|
||||||
self.stop_download()
|
|
||||||
return
|
|
||||||
|
|
||||||
if not self.validate_inputs():
|
|
||||||
return
|
|
||||||
|
|
||||||
# Clear previous output
|
|
||||||
self.clear_output()
|
|
||||||
|
|
||||||
# Update UI
|
|
||||||
self.is_downloading = True
|
|
||||||
self.download_btn.config(text="Stop Download")
|
|
||||||
self.progress.start()
|
|
||||||
self.status_var.set("Downloading...")
|
|
||||||
|
|
||||||
# Build command
|
|
||||||
cmd = self.build_command()
|
|
||||||
self.log_output(f"Starting download...")
|
|
||||||
self.log_output(f"Channel: {self.channel_url.get()}")
|
|
||||||
self.log_output(f"Output: {self.output_dir.get()}")
|
|
||||||
self.log_output("-" * 50)
|
|
||||||
|
|
||||||
# Start download in separate thread
|
|
||||||
self.download_thread = threading.Thread(target=self.run_download, args=(cmd,))
|
|
||||||
self.download_thread.daemon = True
|
|
||||||
self.download_thread.start()
|
|
||||||
|
|
||||||
def run_download(self, cmd):
|
|
||||||
"""Run the download process."""
|
|
||||||
try:
|
|
||||||
# Start the process
|
|
||||||
self.download_process = subprocess.Popen(
|
|
||||||
cmd,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
text=True,
|
|
||||||
bufsize=1,
|
|
||||||
universal_newlines=True,
|
|
||||||
creationflags=subprocess.CREATE_NO_WINDOW if platform.system() == "Windows" else 0
|
|
||||||
)
|
|
||||||
|
|
||||||
# Read output line by line
|
|
||||||
for line in iter(self.download_process.stdout.readline, ''):
|
|
||||||
if not self.is_downloading:
|
|
||||||
break
|
|
||||||
self.output_queue.put(('output', line.rstrip()))
|
|
||||||
|
|
||||||
# Wait for process to complete
|
|
||||||
return_code = self.download_process.wait()
|
|
||||||
|
|
||||||
if return_code == 0:
|
|
||||||
self.output_queue.put(('status', 'Download completed successfully!'))
|
|
||||||
else:
|
|
||||||
self.output_queue.put(('status', f'Download finished with some errors (code {return_code})'))
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.output_queue.put(('error', f'Error during download: {str(e)}'))
|
|
||||||
finally:
|
|
||||||
self.output_queue.put(('finished', None))
|
|
||||||
|
|
||||||
def stop_download(self):
|
|
||||||
"""Stop the current download."""
|
|
||||||
if self.download_process and self.download_process.poll() is None:
|
|
||||||
self.download_process.terminate()
|
|
||||||
self.log_output("\n[WARNING] Download stopped by user")
|
|
||||||
|
|
||||||
self.download_finished()
|
|
||||||
|
|
||||||
def download_finished(self):
|
|
||||||
"""Handle download completion."""
|
|
||||||
self.is_downloading = False
|
|
||||||
self.download_btn.config(text="Download Channel")
|
|
||||||
self.progress.stop()
|
|
||||||
self.status_var.set("Ready to download!")
|
|
||||||
|
|
||||||
def check_queue(self):
|
|
||||||
"""Check the queue for messages from the download thread."""
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
msg_type, message = self.output_queue.get_nowait()
|
|
||||||
|
|
||||||
if msg_type == 'output':
|
|
||||||
self.log_output(message)
|
|
||||||
elif msg_type == 'status':
|
|
||||||
self.log_output(f"\n{message}")
|
|
||||||
self.status_var.set(message)
|
|
||||||
elif msg_type == 'error':
|
|
||||||
self.log_output(f"\n[ERROR] {message}")
|
|
||||||
messagebox.showerror("Download Error", message)
|
|
||||||
elif msg_type == 'finished':
|
|
||||||
self.download_finished()
|
|
||||||
break
|
|
||||||
except queue.Empty:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Schedule next check
|
|
||||||
self.root.after(100, self.check_queue)
|
|
||||||
|
|
||||||
def main():
|
|
||||||
# Set up the main window
|
|
||||||
root = tk.Tk()
|
|
||||||
|
|
||||||
# Configure styling
|
|
||||||
style = ttk.Style()
|
|
||||||
try:
|
|
||||||
style.theme_use('vista') # Windows native theme
|
|
||||||
except:
|
|
||||||
try:
|
|
||||||
style.theme_use('clam') # Fallback modern theme
|
|
||||||
except:
|
|
||||||
pass # Use default
|
|
||||||
|
|
||||||
# Configure button style
|
|
||||||
style.configure('Accent.TButton', font=('Arial', 10, 'bold'))
|
|
||||||
|
|
||||||
# Create the application
|
|
||||||
app = YouTubeArchiverStandalone(root)
|
|
||||||
|
|
||||||
# Handle window closing
|
|
||||||
def on_closing():
|
|
||||||
if app.is_downloading:
|
|
||||||
if messagebox.askokcancel("Quit", "Download in progress. Stop and quit?"):
|
|
||||||
app.stop_download()
|
|
||||||
root.destroy()
|
|
||||||
else:
|
|
||||||
root.destroy()
|
|
||||||
|
|
||||||
root.protocol("WM_DELETE_WINDOW", on_closing)
|
|
||||||
|
|
||||||
# Center window on screen
|
|
||||||
root.update_idletasks()
|
|
||||||
x = (root.winfo_screenwidth() // 2) - (root.winfo_width() // 2)
|
|
||||||
y = (root.winfo_screenheight() // 2) - (root.winfo_height() // 2)
|
|
||||||
root.geometry(f"+{x}+{y}")
|
|
||||||
|
|
||||||
# Set minimum size
|
|
||||||
root.minsize(600, 500)
|
|
||||||
|
|
||||||
# Run the application
|
|
||||||
root.mainloop()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
Reference in New Issue
Block a user