Complete LBRY Downloader implementation v1.0.0

Features:
- Incremental sync with version tracking (sd_hash)
- Per-channel download paths
- Download limit (default: 10 most recent per channel)
- Interactive setup script with user prompts
- Uninstall script for clean removal
- Comprehensive test suite (42 tests)
- Full documentation suite

Files:
- 9 Python core modules (main, models, utils, config, client, state, planner, downloader, init)
- 3 Setup scripts (setup.py, uninstall.py, install.sh)
- 5 Test files (42 tests, all passing)
- 9 Documentation files
- 4 Build/config files

Total: 31 files, ~4,500 lines
This commit is contained in:
thePR0M3TH3AN committed 2026-03-15 18:10:56 -04:00
1 parent db4f11ba6d
commit e707400ffd
31 files changed
+5825

No files matched your search

Executable
+593
View File
@@ -0,0 +1,593 @@
#!/usr/bin/env python3
"""
LBRY Downloader - Interactive Setup Script
This script handles:
- Dependency checking and installation
- Virtual environment setup
- Configuration with user prompts
- Download location selection
- Channel configuration
"""
import os
import sys
import subprocess
import platform
import shutil
from pathlib import Path
from typing import Optional, List, Tuple
# Colors for terminal output
class Colors:
GREEN = "\033[92m"
YELLOW = "\033[93m"
RED = "\033[91m"
BLUE = "\033[94m"
BOLD = "\033[1m"
END = "\033[0m"
def print_success(msg: str):
print(f"{Colors.GREEN}✓{Colors.END} {msg}")
def print_warning(msg: str):
print(f"{Colors.YELLOW}⚠{Colors.END} {msg}")
def print_error(msg: str):
print(f"{Colors.RED}✗{Colors.END} {msg}")
def print_info(msg: str):
print(f"{Colors.BLUE}ℹ{Colors.END} {msg}")
def print_header(msg: str):
print(f"\n{Colors.BOLD}{msg}{Colors.END}")
print("=" * 60)
def run_command(
cmd: List[str], check: bool = True, capture: bool = False
) -> Tuple[int, str, str]:
"""Run a shell command and return result."""
try:
if capture:
result = subprocess.run(cmd, check=check, capture_output=True, text=True)
return result.returncode, result.stdout, result.stderr
else:
result = subprocess.run(cmd, check=check)
return result.returncode, "", ""
except subprocess.CalledProcessError as e:
if check:
raise
return e.returncode, "", str(e)
except FileNotFoundError:
if check:
raise
return 1, "", f"Command not found: {cmd[0]}"
def check_python_version() -> bool:
"""Check if Python version is 3.8 or higher."""
version = sys.version_info
if version.major < 3 or (version.major == 3 and version.minor < 8):
print_error(f"Python 3.8+ required, found {version.major}.{version.minor}")
return False
print_success(f"Python {version.major}.{version.minor}.{version.micro} found")
return True
def check_system_dependencies() -> bool:
"""Check if required system packages are available."""
print_header("Checking System Dependencies")
missing = []
# Check for apt (Debian-based)
if shutil.which("apt-get"):
print_success("Package manager (apt-get) found")
elif shutil.which("dnf"):
print_success("Package manager (dnf) found")
elif shutil.which("pacman"):
print_success("Package manager (pacman) found")
else:
print_warning("No recognized package manager found")
# Check for git
if shutil.which("git"):
print_success("Git found")
else:
print_warning("Git not found (optional)")
# Check for curl
if shutil.which("curl"):
print_success("curl found")
else:
missing.append("curl")
return len(missing) == 0
def install_system_dependencies() -> bool:
"""Install required system packages."""
print_header("Installing System Dependencies")
packages = ["python3-pip", "python3-venv"]
if shutil.which("apt-get"):
# Debian/Ubuntu/Mint
cmd = ["sudo", "apt-get", "update"]
print_info("Updating package lists...")
try:
run_command(cmd)
except:
print_warning("Failed to update package lists, continuing anyway...")
cmd = ["sudo", "apt-get", "install", "-y"] + packages
print_info("Installing packages: " + ", ".join(packages))
try:
run_command(cmd)
print_success("System dependencies installed")
return True
except Exception as e:
print_error(f"Failed to install packages: {e}")
return False
else:
print_warning("Automatic installation only supported on apt-based systems")
print_info("Please manually install: " + ", ".join(packages))
return True # Continue anyway
def setup_virtual_environment(install_dir: Path) -> Path:
"""Create and setup virtual environment."""
print_header("Setting up Virtual Environment")
venv_path = install_dir / "venv"
if venv_path.exists():
print_warning("Virtual environment already exists")
response = input("Recreate it? (y/N): ").strip().lower()
if response == "y":
shutil.rmtree(venv_path)
else:
print_info("Using existing virtual environment")
return venv_path
print_info("Creating virtual environment...")
try:
run_command([sys.executable, "-m", "venv", str(venv_path)])
print_success("Virtual environment created")
except Exception as e:
print_error(f"Failed to create virtual environment: {e}")
sys.exit(1)
return venv_path
def install_python_dependencies(venv_path: Path, install_dir: Path) -> bool:
"""Install Python packages in virtual environment."""
print_header("Installing Python Dependencies")
pip_path = venv_path / "bin" / "pip"
if not pip_path.exists():
pip_path = venv_path / "Scripts" / "pip.exe" # Windows
requirements_file = install_dir / "requirements.txt"
print_info("Upgrading pip...")
try:
run_command([str(pip_path), "install", "--upgrade", "pip"])
except:
print_warning("Failed to upgrade pip, continuing...")
print_info("Installing required packages...")
try:
run_command([str(pip_path), "install", "-r", str(requirements_file)])
print_success("Python dependencies installed")
return True
except Exception as e:
print_error(f"Failed to install Python dependencies: {e}")
return False
def get_installation_directory() -> Path:
"""Ask user for installation directory."""
print_header("Installation Directory")
default_dir = Path.home() / "Documents" / "LBRY-Downloader"
print_info(f"Default: {default_dir}")
response = input(f"Enter installation directory [{default_dir}]: ").strip()
if response:
install_dir = Path(response).expanduser().resolve()
else:
install_dir = default_dir
# Create directory if it doesn't exist
install_dir.mkdir(parents=True, exist_ok=True)
print_success(f"Installation directory: {install_dir}")
return install_dir
def get_download_location() -> str:
"""Ask user for download location."""
print_header("Download Location")
print_info("Where would you like to store downloaded content?")
print_info(
"(This is the base directory - each channel will have its own subfolder)"
)
print()
default_location = "~/Documents/lbry-downloads"
print_info(f"Default: {default_location}")
while True:
response = input(f"Enter download location [{default_location}]: ").strip()
if not response:
location = default_location
else:
location = response
# Expand and check
expanded_path = Path(location).expanduser().resolve()
try:
# Try to create it
expanded_path.mkdir(parents=True, exist_ok=True)
# Check if writable
test_file = expanded_path / ".write_test"
try:
test_file.touch()
test_file.unlink()
print_success(f"Download location: {expanded_path}")
return str(location)
except PermissionError:
print_error(f"Cannot write to {expanded_path}")
print_info("Please choose a different location or fix permissions")
except Exception as e:
print_error(f"Invalid path: {e}")
def get_channels() -> List[dict]:
"""Ask user for channels to download."""
print_header("Channel Configuration")
print_info("Enter the channels you want to download from.")
print_info("You can add more later by editing the config file.")
print()
print_info("Supported formats:")
print_info(" - Odysee URL: https://odysee.com/@ChannelName:1")
print_info(" - LBRY URI: lbry://@ChannelName#1")
print_info(" - Just press Enter when done")
print()
channels = []
while True:
channel_num = len(channels) + 1
url = input(f"Channel {channel_num} URL (or Enter to finish): ").strip()
if not url:
if len(channels) == 0:
print_warning(
"No channels added. You can add them later in the config file."
)
break
# Basic validation
if not ("odysee.com" in url or "lbry://" in url or url.startswith("@")):
print_warning("URL format looks unusual. Make sure it's correct.")
confirm = input("Use this URL anyway? (Y/n): ").strip().lower()
if confirm == "n":
continue
channels.append({"input": url, "enabled": True})
print_success(f"Added: {url}")
return channels
def get_download_limit() -> int:
"""Ask user for download limit."""
print_header("Download Limit")
print_info("How many items should be downloaded per channel per run?")
print_info(" - Lower numbers = safer for testing")
print_info(" - Higher numbers = faster initial sync")
print_info(" - Downloads most recent items first")
print()
print_info("Recommended values:")
print_info(" - 5-10: Good for testing or large channels")
print_info(" - 20-50: Moderate syncing")
print_info(" - 0: Download everything (use with caution!)")
print()
while True:
response = input("Download limit per channel [10]: ").strip()
if not response:
return 10
try:
limit = int(response)
if limit < 0:
print_error("Please enter 0 or a positive number")
continue
if limit == 0:
confirm = (
input("Are you sure you want to download ALL items? (y/N): ")
.strip()
.lower()
)
if confirm != "y":
continue
print_warning("Download limit set to ALL items")
else:
print_success(f"Download limit set to {limit} items per channel")
return limit
except ValueError:
print_error("Please enter a number")
def create_config(
download_location: str, channels: List[dict], download_limit: int
) -> str:
"""Generate configuration file content."""
config = f"""# LBRY Downloader Configuration
# Generated by setup script on {platform.platform()}
lbrynet:
# URL of the local LBRY daemon (lbrynet)
api_url: "http://127.0.0.1:5279"
# Timeout for daemon requests in seconds
timeout_seconds: 60
general:
# Base directory for all downloads and state
base_dir: "{download_location}"
# Path to the state database
state_file: "{download_location}/state/database.json"
# Number of concurrent downloads (keep low to avoid daemon issues)
max_workers: 2
# Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL
log_level: "INFO"
# If true, show what would be downloaded without actually downloading
dry_run: false
# Verify that previously downloaded files still exist
verify_existing_files: true
# Write SHA256 checksums for downloaded files
write_checksums: true
# Filename handling: 'original' or 'safe' (aggressively sanitized)
filename_mode: "original"
# Include reposts in downloads (default: false)
include_reposts: false
# Number of claims to fetch per page from the daemon
channel_page_size: 50
# Keep records of claims even if they disappear from the channel
keep_missing_claim_records: true
# Download limit: Number of most recent downloads per channel (0 = all)
download_limit: {download_limit}
channels:
"""
for channel in channels:
config += f''' - input: "{channel["input"]}"
enabled: true
'''
if not channels:
config += """ # Add your channels here:
# - input: "https://odysee.com/@ChannelName:1"
# enabled: true
"""
return config
def create_launcher_script(install_dir: Path, venv_path: Path) -> None:
"""Create launcher scripts for easy execution."""
print_header("Creating Launcher Scripts")
# Create bin directory
bin_dir = install_dir / "bin"
bin_dir.mkdir(exist_ok=True)
# Create main launcher
launcher = bin_dir / "lbry-downloader"
launcher_content = f"""#!/bin/bash
# LBRY Downloader Launcher
INSTALL_DIR="{install_dir}"
VENV_DIR="{venv_path}"
cd "$INSTALL_DIR"
source "$VENV_DIR/bin/activate"
python main.py "$@"
"""
launcher.write_text(launcher_content)
launcher.chmod(0o755)
print_success(f"Created launcher: {launcher}")
# Create quick test script
test_script = bin_dir / "lbry-test"
test_content = f"""#!/bin/bash
# Quick test script
INSTALL_DIR="{install_dir}"
VENV_DIR="{venv_path}"
cd "$INSTALL_DIR"
source "$VENV_DIR/bin/activate"
echo "Testing LBRY Downloader..."
python main.py --dry-run "$@"
"""
test_script.write_text(test_content)
test_script.chmod(0o755)
print_success(f"Created test script: {test_script}")
# Check if we should add to PATH
shell_rc = Path.home() / ".bashrc"
if not shell_rc.exists():
shell_rc = Path.home() / ".zshrc"
if shell_rc.exists():
print_info(
f"To use 'lbry-downloader' from anywhere, add this to your {shell_rc.name}:"
)
print(f' export PATH="{bin_dir}:$PATH"')
def run_setup() -> None:
"""Main setup function."""
print(f"""
{Colors.BOLD}╔══════════════════════════════════════════════════════════════╗
║ LBRY Downloader - Interactive Setup ║
║ ║
║ This script will help you install and configure the ║
║ LBRY Downloader tool. ║
╚══════════════════════════════════════════════════════════════╝{Colors.END}
""")
# Check Python version
if not check_python_version():
print_error("Python 3.8+ is required. Please upgrade Python.")
sys.exit(1)
# Check system dependencies
if not check_system_dependencies():
print_info("Some system dependencies are missing.")
response = input("Install system dependencies? (Y/n): ").strip().lower()
if response != "n":
if not install_system_dependencies():
print_warning("Failed to install some dependencies")
response = input("Continue anyway? (y/N): ").strip().lower()
if response != "y":
sys.exit(1)
# Get installation directory
install_dir = get_installation_directory()
# Copy files if not running from install directory
current_dir = Path(__file__).parent.resolve()
if current_dir != install_dir:
print_header("Copying Files")
print_info(f"Copying from {current_dir} to {install_dir}")
# Copy Python files
for pattern in ["*.py", "*.md", "*.txt", "*.toml", "*.example", "Makefile"]:
for file in current_dir.glob(pattern):
if file.name != "setup.py": # Don't copy setup script itself
shutil.copy2(file, install_dir)
print_success(f"Copied {file.name}")
# Copy tests directory
tests_src = current_dir / "tests"
tests_dst = install_dir / "tests"
if tests_src.exists():
if tests_dst.exists():
shutil.rmtree(tests_dst)
shutil.copytree(tests_src, tests_dst)
print_success("Copied tests/")
# Setup virtual environment
venv_path = setup_virtual_environment(install_dir)
# Install Python dependencies
if not install_python_dependencies(venv_path, install_dir):
print_error("Failed to install Python dependencies")
sys.exit(1)
# Get configuration from user
download_location = get_download_location()
channels = get_channels()
download_limit = get_download_limit()
# Create config file
print_header("Creating Configuration")
config_content = create_config(download_location, channels, download_limit)
config_path = Path(download_location).expanduser() / "config.yaml"
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(config_content)
print_success(f"Created configuration: {config_path}")
# Create necessary directories
(config_path.parent / "state").mkdir(exist_ok=True)
(config_path.parent / "channels").mkdir(exist_ok=True)
print_success("Created directory structure")
# Create launcher scripts
create_launcher_script(install_dir, venv_path)
# Final instructions
print(f"""
{Colors.BOLD}{Colors.GREEN}╔══════════════════════════════════════════════════════════════╗
║ Setup Complete! ║
╚══════════════════════════════════════════════════════════════╝{Colors.END}
Next steps:
1. Start the LBRY daemon:
lbrynet start
(If lbrynet is not installed, see LINUX_SETUP.md)
2. Test the configuration:
cd {install_dir}
./bin/lbry-test
Or run directly:
source venv/bin/activate
python main.py --dry-run
3. Run the downloader:
./bin/lbry-downloader
Or:
python main.py
Configuration file:
{config_path}
To add more channels later, edit the config file:
nano {config_path}
Documentation:
- README.md - Project overview
- LINUX_SETUP.md - Detailed Linux setup
- QUICKSTART.md - Quick start guide
- config.yaml.example - Configuration examples
Happy downloading!
""")
if __name__ == "__main__":
try:
run_setup()
except KeyboardInterrupt:
print("\n\nSetup interrupted by user.")
sys.exit(1)
except Exception as e:
print_error(f"Setup failed: {e}")
import traceback
traceback.print_exc()
sys.exit(1)