868 lines
36 KiB
Python
868 lines
36 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
YouTube Channel Archiver - Standalone Windows Executable
|
|
Fixed version that avoids subprocess recursion issues with PyInstaller.
|
|
"""
|
|
|
|
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.total_videos = 0
|
|
self.downloaded_videos = 0
|
|
self.current_video = ""
|
|
|
|
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 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):
|
|
# 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)
|
|
|
|
# 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
|
|
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='determinate', maximum=100)
|
|
self.progress.grid(row=9, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(0, 5))
|
|
|
|
# Progress label
|
|
self.progress_label = ttk.Label(main_frame, text="", font=('Arial', 9))
|
|
self.progress_label.grid(row=10, column=0, columnspan=3, pady=(0, 10))
|
|
|
|
# Output text area
|
|
output_frame = ttk.LabelFrame(main_frame, text="Download Progress", padding="5")
|
|
output_frame.grid(row=11, 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(11, 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=12, 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")
|
|
|
|
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
|
|
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."""
|
|
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.ytdlp_status.config(text="yt-dlp: Installing...", foreground="orange")
|
|
|
|
# Install in background thread
|
|
threading.Thread(target=self.do_install_ytdlp, args=(python_exe,), daemon=True).start()
|
|
|
|
def do_install_ytdlp(self, python_exe):
|
|
"""Actually install yt-dlp."""
|
|
try:
|
|
# Install yt-dlp
|
|
self.root.after(0, lambda: self.log_output(f"Installing yt-dlp using: {python_exe}"))
|
|
|
|
result = subprocess.run([python_exe, '-m', 'pip', 'install', 'yt-dlp'],
|
|
capture_output=True, text=True, check=True, timeout=120)
|
|
|
|
self.root.after(0, lambda: self.log_output("Installation completed, verifying..."))
|
|
|
|
# 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:
|
|
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):
|
|
"""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 automatically")
|
|
self.manual_install_btn.grid() # Show manual install button
|
|
|
|
messagebox.showerror("Installation Error",
|
|
"Could not install yt-dlp automatically.\n\n"
|
|
"This can happen when running as an executable.\n"
|
|
"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):
|
|
"""Called when all dependencies are ready."""
|
|
if not self.dependencies_checked:
|
|
self.dependencies_checked = True
|
|
self.download_btn.state(['!disabled'])
|
|
self.manual_install_btn.grid_remove()
|
|
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."""
|
|
# Try to determine the best way to call yt-dlp
|
|
cmd = self.get_ytdlp_command()
|
|
|
|
# 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 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):
|
|
"""Start the download process."""
|
|
if self.is_downloading:
|
|
self.stop_download()
|
|
return
|
|
|
|
if not self.validate_inputs():
|
|
return
|
|
|
|
# Clear previous output and reset progress
|
|
self.clear_output()
|
|
self.reset_progress()
|
|
|
|
# Update UI
|
|
self.is_downloading = True
|
|
self.download_btn.config(text="Stop Download")
|
|
self.progress.config(mode='indeterminate')
|
|
self.progress.start()
|
|
self.progress_label.config(text="Initializing download...")
|
|
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 reset_progress(self):
|
|
"""Reset progress tracking variables."""
|
|
self.total_videos = 0
|
|
self.downloaded_videos = 0
|
|
self.current_video = ""
|
|
self.progress.config(value=0)
|
|
self.progress_label.config(text="")
|
|
|
|
def update_progress(self, downloaded, total, current_title=""):
|
|
"""Update the progress bar and label."""
|
|
self.downloaded_videos = downloaded
|
|
self.total_videos = total
|
|
self.current_video = current_title
|
|
|
|
if total > 0:
|
|
percentage = (downloaded / total) * 100
|
|
self.progress.config(mode='determinate', value=percentage)
|
|
|
|
# Truncate long titles
|
|
display_title = current_title[:50] + "..." if len(current_title) > 50 else current_title
|
|
|
|
self.progress_label.config(
|
|
text=f"Progress: {downloaded}/{total} videos ({percentage:.1f}%) - {display_title}"
|
|
)
|
|
else:
|
|
self.progress.config(mode='indeterminate')
|
|
self.progress_label.config(text="Analyzing channel...")
|
|
|
|
def parse_download_output(self, line):
|
|
"""Parse yt-dlp output to extract progress information."""
|
|
line = line.strip()
|
|
|
|
# Look for playlist info: [download] Downloading video 5 of 23: Title
|
|
if "[download] Downloading video" in line:
|
|
try:
|
|
# Extract: "Downloading video 5 of 23: Title"
|
|
parts = line.split(":")
|
|
if len(parts) >= 2:
|
|
video_part = parts[1].strip() # "Downloading video 5 of 23"
|
|
title_part = ":".join(parts[2:]).strip() if len(parts) > 2 else ""
|
|
|
|
# Extract numbers: "5 of 23"
|
|
if " of " in video_part:
|
|
numbers = video_part.split(" of ")
|
|
if len(numbers) == 2:
|
|
try:
|
|
current = int(numbers[0].split()[-1]) # Get last word before "of"
|
|
total = int(numbers[1].split()[0]) # Get first word after "of"
|
|
self.root.after(0, lambda: self.update_progress(current, total, title_part))
|
|
except (ValueError, IndexError):
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
# Look for single video download progress
|
|
elif "[download]" in line and "%" in line:
|
|
# Extract percentage from lines like: [download] 45.2% of 123.45MiB at 1.23MiB/s ETA 00:30
|
|
try:
|
|
if "%" in line:
|
|
percent_part = line.split("%")[0]
|
|
percent_value = float(percent_part.split()[-1])
|
|
|
|
# If we don't have total videos info, show single video progress
|
|
if self.total_videos == 0:
|
|
self.root.after(0, lambda: self.update_single_video_progress(percent_value))
|
|
except (ValueError, IndexError):
|
|
pass
|
|
|
|
# Look for playlist detection
|
|
elif "[youtube] Extracting playlist information" in line or "playlist" in line.lower():
|
|
self.root.after(0, lambda: self.update_progress(0, 0, "Analyzing playlist..."))
|
|
|
|
def update_single_video_progress(self, percentage):
|
|
"""Update progress for single video download."""
|
|
self.progress.config(mode='determinate', value=percentage)
|
|
self.progress_label.config(text=f"Download progress: {percentage:.1f}%")
|
|
|
|
def show_completion_notification(self, success=True, message=""):
|
|
"""Show a popup notification when download completes."""
|
|
if success:
|
|
title = "Download Complete!"
|
|
icon = "info"
|
|
msg = f"Channel download completed successfully!\n\n"
|
|
if self.total_videos > 0:
|
|
msg += f"Downloaded {self.downloaded_videos} out of {self.total_videos} videos.\n"
|
|
msg += f"Files saved to:\n{self.output_dir.get()}"
|
|
else:
|
|
title = "Download Finished"
|
|
icon = "warning"
|
|
msg = f"Download finished with some issues.\n\n{message}\n\n"
|
|
msg += f"Files saved to:\n{self.output_dir.get()}"
|
|
|
|
# Create custom notification window
|
|
notification = tk.Toplevel(self.root)
|
|
notification.title(title)
|
|
notification.geometry("450x250")
|
|
notification.resizable(False, False)
|
|
notification.transient(self.root)
|
|
notification.grab_set()
|
|
|
|
# Center the notification
|
|
notification.update_idletasks()
|
|
x = (notification.winfo_screenwidth() // 2) - (notification.winfo_width() // 2)
|
|
y = (notification.winfo_screenheight() // 2) - (notification.winfo_height() // 2)
|
|
notification.geometry(f"+{x}+{y}")
|
|
|
|
# Configure notification content
|
|
frame = ttk.Frame(notification, padding="20")
|
|
frame.pack(fill="both", expand=True)
|
|
|
|
# Title
|
|
title_label = ttk.Label(frame, text=title, font=('Arial', 14, 'bold'))
|
|
title_label.pack(pady=(0, 10))
|
|
|
|
# Message
|
|
message_label = ttk.Label(frame, text=msg, wraplength=400, justify="center")
|
|
message_label.pack(pady=(0, 15))
|
|
|
|
# Buttons
|
|
button_frame = ttk.Frame(frame)
|
|
button_frame.pack(fill="x", pady=(10, 0))
|
|
|
|
def open_folder():
|
|
"""Open the download folder."""
|
|
try:
|
|
if platform.system() == "Windows":
|
|
os.startfile(self.output_dir.get())
|
|
elif platform.system() == "Darwin": # macOS
|
|
subprocess.run(["open", self.output_dir.get()])
|
|
else: # Linux
|
|
subprocess.run(["xdg-open", self.output_dir.get()])
|
|
notification.destroy()
|
|
except Exception as e:
|
|
messagebox.showerror("Error", f"Could not open folder: {e}")
|
|
|
|
ttk.Button(button_frame, text="Open Folder", command=open_folder).pack(side="left", padx=(0, 10))
|
|
ttk.Button(button_frame, text="OK", command=notification.destroy).pack(side="right")
|
|
|
|
# Auto-close after 30 seconds
|
|
notification.after(30000, notification.destroy)
|
|
|
|
# Play system notification sound
|
|
try:
|
|
if platform.system() == "Windows":
|
|
import winsound
|
|
winsound.PlaySound("SystemExclamation", winsound.SND_ALIAS)
|
|
except ImportError:
|
|
pass # No sound on non-Windows or if winsound not available
|
|
|
|
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
|
|
|
|
# Parse the line for progress information
|
|
self.parse_download_output(line)
|
|
|
|
# Send line to output display
|
|
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(('success', 'Download completed successfully!'))
|
|
else:
|
|
self.output_queue.put(('warning', 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.progress.config(mode='determinate')
|
|
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 == 'success':
|
|
self.log_output(f"\n{message}")
|
|
self.status_var.set(message)
|
|
self.show_completion_notification(success=True)
|
|
elif msg_type == 'warning':
|
|
self.log_output(f"\n{message}")
|
|
self.status_var.set(message)
|
|
self.show_completion_notification(success=False, message=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():
|
|
# Prevent recursive calls when frozen
|
|
if getattr(sys, 'frozen', False):
|
|
# Running as PyInstaller executable
|
|
import multiprocessing
|
|
multiprocessing.freeze_support()
|
|
|
|
# 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()
|