Let's just create an installer for Windows to dumb it down
This commit is contained in:
492
youtube_archiver_standalone.py
Normal file
492
youtube_archiver_standalone.py
Normal file
@@ -0,0 +1,492 @@
|
||||
#!/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