From 1eb761ec43af072f84383f809cca73457eb35775 Mon Sep 17 00:00:00 2001 From: Kevin Thompson Date: Sun, 10 Aug 2025 13:09:36 -0500 Subject: [PATCH] Let's just create an installer for Windows to dumb it down --- windows/LICENSE.txt | 21 ++ windows/README.txt | 48 +++ windows/build_windows.bat | 57 +++ windows/installer.iss | 38 ++ windows/youtube_archiver_standalone.py | 492 +++++++++++++++++++++++++ youtube_archiver_standalone.py | 492 +++++++++++++++++++++++++ 6 files changed, 1148 insertions(+) create mode 100644 windows/LICENSE.txt create mode 100644 windows/README.txt create mode 100644 windows/build_windows.bat create mode 100644 windows/installer.iss create mode 100644 windows/youtube_archiver_standalone.py create mode 100644 youtube_archiver_standalone.py diff --git a/windows/LICENSE.txt b/windows/LICENSE.txt new file mode 100644 index 0000000..53ff6dc --- /dev/null +++ b/windows/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 YouTube Channel Archiver + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/windows/README.txt b/windows/README.txt new file mode 100644 index 0000000..2468d7f --- /dev/null +++ b/windows/README.txt @@ -0,0 +1,48 @@ +YouTube Channel Archiver v1.0 +============================== + +A simple, user-friendly tool to download entire YouTube channels. + +FEATURES: +✓ Download all videos from any YouTube channel +✓ Choose video quality (720p, 1080p, best quality, etc.) +✓ Audio-only downloads supported +✓ Download thumbnails and metadata +✓ Resume interrupted downloads automatically +✓ Automatic dependency management +✓ Clean, intuitive interface + +SYSTEM REQUIREMENTS: +- Windows 10 or later (64-bit) +- Internet connection for downloads +- Sufficient disk space for videos + +QUICK START: +1. Launch YouTube Channel Archiver +2. Paste any YouTube channel URL +3. Choose your download preferences +4. Select where to save the videos +5. Click "Download Channel" + +SUPPORTED URL FORMATS: +- https://www.youtube.com/@channelname +- https://www.youtube.com/c/channelname +- https://www.youtube.com/user/username +- https://www.youtube.com/channel/UCxxxxxxxxx + +The application will automatically: +- Install required dependencies (yt-dlp) +- Create organized folders by channel name +- Track downloaded videos to avoid duplicates +- Handle errors gracefully and continue downloading + +TROUBLESHOOTING: +- Ensure you have a stable internet connection +- Check that the YouTube URL is correct and accessible +- Make sure you have enough disk space +- Some videos may be unavailable due to region restrictions + +For support and updates, visit: https://github.com/yt-dlp/yt-dlp + +LICENSE: +This software is provided as-is under the MIT License. diff --git a/windows/build_windows.bat b/windows/build_windows.bat new file mode 100644 index 0000000..339bbcd --- /dev/null +++ b/windows/build_windows.bat @@ -0,0 +1,57 @@ +@echo off +title YouTube Channel Archiver - Windows Build Script +echo ================================================================ +echo YouTube Channel Archiver - Windows Build Script +echo ================================================================ +echo. + +echo [1/5] Checking Python installation... +python --version > nul 2>&1 +if errorlevel 1 ( + echo ERROR: Python is not installed or not in PATH + echo Please install Python from https://python.org + pause + exit /b 1 +) +python --version + +echo. +echo [2/5] Installing/updating build dependencies... +python -m pip install --upgrade pip +python -m pip install pyinstaller + +echo. +echo [3/5] Creating executable with PyInstaller... +pyinstaller --onefile --windowed --name "YouTubeChannelArchiver" --distpath dist --workpath build youtube_archiver_standalone.py + +echo. +echo [4/5] Testing the executable... +if exist "dist\YouTubeChannelArchiver.exe" ( + echo SUCCESS: Executable created at dist\YouTubeChannelArchiver.exe +) else ( + echo ERROR: Executable was not created + pause + exit /b 1 +) + +echo. +echo [5/5] Creating portable package... +if not exist "portable" mkdir portable +copy "dist\YouTubeChannelArchiver.exe" "portable\" +echo Portable version created in 'portable' folder + +echo. +echo ================================================================ +echo BUILD COMPLETED SUCCESSFULLY! +echo ================================================================ +echo. +echo Files created: +echo - dist\YouTubeChannelArchiver.exe (main executable) +echo - portable\YouTubeChannelArchiver.exe (portable version) +echo. +echo You can now: +echo 1. Test the executable by running it +echo 2. Distribute the portable folder +echo 3. Create an installer using the Inno Setup script below +echo. +pause diff --git a/windows/installer.iss b/windows/installer.iss new file mode 100644 index 0000000..f964248 --- /dev/null +++ b/windows/installer.iss @@ -0,0 +1,38 @@ +[Setup] +AppName=YouTube Channel Archiver +AppVersion=1.0.0 +AppPublisher=YouTube Channel Archiver Team +AppPublisherURL=https://github.com/yt-dlp/yt-dlp +AppSupportURL=https://github.com/yt-dlp/yt-dlp +DefaultDirName={autopf}\YouTubeChannelArchiver +DefaultGroupName=YouTube Channel Archiver +AllowNoIcons=yes +LicenseFile=LICENSE.txt +OutputDir=installer_output +OutputBaseFilename=YouTubeChannelArchiver_v1.0_Setup +SetupIconFile=icon.ico +Compression=lzma +SolidCompression=yes +PrivilegesRequired=lowest +ArchitecturesAllowed=x64 +ArchitecturesInstallIn64BitMode=x64 + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked + +[Files] +Source: "dist\YouTubeChannelArchiver.exe"; DestDir: "{app}"; Flags: ignoreversion +Source: "README.txt"; DestDir: "{app}"; Flags: ignoreversion isreadme +Source: "LICENSE.txt"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +Name: "{group}\YouTube Channel Archiver"; Filename: "{app}\YouTubeChannelArchiver.exe" +Name: "{group}\{cm:ProgramOnTheWeb,YouTube Channel Archiver}"; Filename: "https://github.com/yt-dlp/yt-dlp" +Name: "{group}\{cm:UninstallProgram,YouTube Channel Archiver}"; Filename: "{uninstallexe}" +Name: "{autodesktop}\YouTube Channel Archiver"; Filename: "{app}\YouTubeChannelArchiver.exe"; Tasks: desktopicon + +[Run] +Filename: "{app}\YouTubeChannelArchiver.exe"; Description: "{cm:LaunchProgram,YouTube Channel Archiver}"; Flags: nowait postinstall skipifsilent diff --git a/windows/youtube_archiver_standalone.py b/windows/youtube_archiver_standalone.py new file mode 100644 index 0000000..44c389d --- /dev/null +++ b/windows/youtube_archiver_standalone.py @@ -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() diff --git a/youtube_archiver_standalone.py b/youtube_archiver_standalone.py new file mode 100644 index 0000000..44c389d --- /dev/null +++ b/youtube_archiver_standalone.py @@ -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()