From b9041445ba4536d7e4fe012ff57c367f85e6fc9e Mon Sep 17 00:00:00 2001 From: Kevin Thompson Date: Wed, 23 Jul 2025 14:27:58 -0500 Subject: [PATCH] Add frontend --- yt-channel-archiver.py => youtube_archiver.py | 0 youtube_archiver_gui.py | 331 ++++++++++++++++++ 2 files changed, 331 insertions(+) rename yt-channel-archiver.py => youtube_archiver.py (100%) create mode 100644 youtube_archiver_gui.py diff --git a/yt-channel-archiver.py b/youtube_archiver.py similarity index 100% rename from yt-channel-archiver.py rename to youtube_archiver.py diff --git a/youtube_archiver_gui.py b/youtube_archiver_gui.py new file mode 100644 index 0000000..010391a --- /dev/null +++ b/youtube_archiver_gui.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +""" +YouTube Channel Archiver GUI +A simple graphical interface for the YouTube channel archiver script. +""" + +import tkinter as tk +from tkinter import ttk, filedialog, messagebox, scrolledtext +import subprocess +import threading +import sys +import os +from pathlib import Path +import queue + +class YouTubeArchiverGUI: + def __init__(self, root): + self.root = root + self.root.title("YouTube Channel Archiver") + self.root.geometry("600x500") + 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.cwd())) + 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 download process + self.download_process = None + self.is_downloading = False + + self.create_widgets() + self.check_dependencies() + + # Start checking queue for output updates + self.root.after(100, self.check_queue) + + def create_widgets(self): + # Main frame + main_frame = ttk.Frame(self.root, padding="10") + 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 + title_label = ttk.Label(main_frame, text="YouTube Channel Archiver", + font=('Arial', 16, 'bold')) + title_label.grid(row=0, column=0, columnspan=3, pady=(0, 20)) + + # Channel URL input + ttk.Label(main_frame, text="Channel URL:").grid(row=1, column=0, sticky=tk.W, pady=5) + url_entry = ttk.Entry(main_frame, textvariable=self.channel_url, width=50) + url_entry.grid(row=1, column=1, columnspan=2, sticky=(tk.W, tk.E), pady=5, padx=(10, 0)) + + # Output directory + ttk.Label(main_frame, text="Output Directory:").grid(row=2, column=0, sticky=tk.W, pady=5) + dir_entry = ttk.Entry(main_frame, textvariable=self.output_dir, width=40) + dir_entry.grid(row=2, column=1, sticky=(tk.W, tk.E), pady=5, padx=(10, 5)) + + browse_btn = ttk.Button(main_frame, text="Browse", command=self.browse_directory) + browse_btn.grid(row=2, column=2, pady=5) + + # Options frame + options_frame = ttk.LabelFrame(main_frame, text="Options", padding="10") + options_frame.grid(row=3, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=10) + options_frame.columnconfigure(1, weight=1) + + # Quality selection + ttk.Label(options_frame, text="Quality:").grid(row=0, column=0, sticky=tk.W, pady=2) + quality_combo = ttk.Combobox(options_frame, textvariable=self.quality, + values=["best", "worst", "1080p", "720p", "480p"], + state="readonly", width=15) + quality_combo.grid(row=0, column=1, sticky=tk.W, pady=2, padx=(10, 0)) + + # Checkboxes + audio_check = ttk.Checkbutton(options_frame, text="Audio only", + variable=self.audio_only) + audio_check.grid(row=1, column=0, sticky=tk.W, pady=2) + + thumb_check = ttk.Checkbutton(options_frame, text="Download thumbnails", + variable=self.download_thumbnails) + thumb_check.grid(row=1, column=1, sticky=tk.W, pady=2) + + meta_check = ttk.Checkbutton(options_frame, text="Download metadata", + variable=self.download_metadata) + meta_check.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=4, column=0, columnspan=3, pady=20) + + # Progress bar + self.progress = ttk.Progressbar(main_frame, mode='indeterminate') + self.progress.grid(row=5, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(0, 10)) + + # Output text area + output_frame = ttk.LabelFrame(main_frame, text="Output", padding="5") + output_frame.grid(row=6, 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(6, weight=1) + + self.output_text = scrolledtext.ScrolledText(output_frame, height=12, width=70) + 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="Ready") + status_bar = ttk.Label(main_frame, textvariable=self.status_var, + relief=tk.SUNKEN, anchor=tk.W) + status_bar.grid(row=7, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(5, 0)) + + def check_dependencies(self): + """Check if yt-dlp is available.""" + try: + result = subprocess.run(['yt-dlp', '--version'], + capture_output=True, text=True, check=True) + version = result.stdout.strip() + self.log_output(f"✓ yt-dlp is available: {version}") + self.status_var.set("Ready - yt-dlp found") + except (subprocess.CalledProcessError, FileNotFoundError): + self.log_output("⚠ yt-dlp not found. It will be installed automatically when needed.") + self.status_var.set("Ready - yt-dlp will be auto-installed") + + def browse_directory(self): + """Open directory browser.""" + directory = filedialog.askdirectory(initialdir=self.output_dir.get()) + if directory: + self.output_dir.set(directory) + + 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 validate_inputs(self): + """Validate user inputs.""" + if not self.channel_url.get().strip(): + messagebox.showerror("Error", "Please enter a channel 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.""" + # Find the archiver script + script_path = Path(__file__).parent / "youtube_archiver.py" + if not script_path.exists(): + # If not found, assume it's in the same directory + script_path = "youtube_archiver.py" + + cmd = [sys.executable, str(script_path)] + + # Add channel URL + cmd.append(self.channel_url.get().strip()) + + # Add options + cmd.extend(["--output", self.output_dir.get()]) + cmd.extend(["--quality", self.quality.get()]) + + if self.audio_only.get(): + cmd.append("--audio-only") + + if not self.download_thumbnails.get(): + cmd.append("--no-thumbnails") + + if not self.download_metadata.get(): + cmd.append("--no-metadata") + + return cmd + + def start_download(self): + """Start the download process in a separate thread.""" + 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 with command: {' '.join(cmd)}") + self.log_output("-" * 60) + + # 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 + ) + + # Read output line by line + for line in iter(self.download_process.stdout.readline, ''): + if not self.is_downloading: # Check if stopped + break + # Put output in queue for main thread to handle + 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 failed with 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⚠ 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") + + 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❌ {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(): + root = tk.Tk() + + # Set up styling + style = ttk.Style() + + # Try to use a modern theme + try: + style.theme_use('clam') # Modern looking theme + except: + pass # Use default theme if clam is not available + + app = YouTubeArchiverGUI(root) + + # Handle window closing + def on_closing(): + if app.is_downloading: + if messagebox.askokcancel("Quit", "Download in progress. Do you want to 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}") + + root.mainloop() + +if __name__ == "__main__": + main()