Let's just create an installer for Windows to dumb it down
This commit is contained in:
21
windows/LICENSE.txt
Normal file
21
windows/LICENSE.txt
Normal file
@@ -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.
|
48
windows/README.txt
Normal file
48
windows/README.txt
Normal file
@@ -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.
|
57
windows/build_windows.bat
Normal file
57
windows/build_windows.bat
Normal file
@@ -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
|
38
windows/installer.iss
Normal file
38
windows/installer.iss
Normal file
@@ -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
|
492
windows/youtube_archiver_standalone.py
Normal file
492
windows/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()
|
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