Files
LBRY-Downloader/main.py
T
thePR0M3TH3AN e707400ffd 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
2026-03-15 18:10:56 -04:00

409 lines
12 KiB
Python

#!/usr/bin/env python3
"""
LBRY Downloader - Incrementally sync LBRY/Odysee channel content.
This tool downloads all downloadable file claims from configured channels,
maintaining persistent state to avoid re-downloading unchanged content.
"""
import argparse
import logging
import sys
from pathlib import Path
from typing import List, Optional
from config_loader import ConfigError, ensure_directories, load_config
from downloader import DownloadError, Downloader
from lbry_client import LbryClient, LbryClientError, check_daemon_health
from models import Channel, Config, DownloadAction
from planner import Planner
from state_db import StateDb
from utils import (
create_channel_folder_name,
expand_path,
format_summary,
normalize_odysee_url,
)
def setup_logging(log_level: str) -> None:
"""Configure logging."""
logging.basicConfig(
level=getattr(logging, log_level),
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
def resolve_channel(
client: LbryClient,
channel_input: str,
) -> Optional[Channel]:
"""
Resolve a channel input to a Channel object.
Args:
client: Lbry client instance.
channel_input: URL, URI, or claim ID.
Returns:
Resolved Channel or None if resolution failed.
"""
logger = logging.getLogger(__name__)
# Normalize input
if channel_input.startswith("http"):
uri = normalize_odysee_url(channel_input)
elif not channel_input.startswith("lbry://"):
uri = f"lbry://{channel_input}"
else:
uri = channel_input
try:
logger.debug(f"Resolving: {uri}")
result = client.resolve([uri])
if not result or uri not in result:
logger.error(f"Could not resolve channel: {channel_input}")
return None
resolved = result[uri]
# Check if it's a channel
if "claim" in resolved:
claim = resolved["claim"]
else:
claim = resolved
value_type = claim.get("value_type", "").lower()
if value_type != "channel":
logger.error(f"Resolved claim is not a channel: {channel_input}")
return None
channel_claim_id = claim.get("claim_id")
channel_name = claim.get("name")
if not channel_claim_id:
logger.error(f"No claim_id in resolved channel: {channel_input}")
return None
# Create folder name
folder = create_channel_folder_name(channel_name, channel_claim_id)
value = claim.get("value", {})
channel = Channel(
input=channel_input,
normalized_uri=uri,
channel_claim_id=channel_claim_id,
channel_name=channel_name,
folder=folder,
display_name=value.get("title"),
permanent_url=claim.get("permanent_url"),
short_url=claim.get("short_url"),
)
logger.info(f"Resolved channel: {channel_name} ({channel_claim_id})")
return channel
except LbryClientError as e:
logger.error(f"Failed to resolve channel {channel_input}: {e}")
return None
def enumerate_channel_claims(
client: LbryClient,
channel: Channel,
config: Config,
) -> List[dict]:
"""
Enumerate all downloadable claims from a channel.
Args:
client: Lbry client.
channel: Channel to enumerate.
config: Application config.
Returns:
List of claim metadata.
"""
logger = logging.getLogger(__name__)
all_claims = []
page = 1
logger.info(f"Scanning channel: {channel.channel_name}")
while True:
try:
result = client.get_channel_claims(
channel_claim_id=channel.channel_claim_id,
page=page,
page_size=config.general.channel_page_size,
include_reposts=config.general.include_reposts,
)
items = result.get("items", [])
if not items:
break
all_claims.extend(items)
logger.debug(f" Page {page}: {len(items)} claims")
# Check if we've reached the end
total_pages = result.get("total_pages", 1)
if page >= total_pages:
break
page += 1
except LbryClientError as e:
logger.error(f"Failed to enumerate page {page}: {e}")
break
logger.info(f" Total claims found: {len(all_claims)}")
return all_claims
def run_sync(config: Config, dry_run: bool = False) -> dict:
"""
Run the main synchronization process.
Args:
config: Application configuration.
dry_run: If True, don't actually download.
Returns:
Statistics dictionary.
"""
logger = logging.getLogger(__name__)
stats = {
"channels_scanned": 0,
"claims_examined": 0,
"new_downloads": 0,
"new_versions": 0,
"skipped_existing": 0,
"redownloaded_missing": 0,
"failures": 0,
}
# Initialize components
client = LbryClient(
api_url=config.lbrynet.api_url,
timeout=config.lbrynet.timeout_seconds,
)
# Check daemon health
logger.info(f"Checking daemon at {config.lbrynet.api_url}")
try:
check_daemon_health(client)
logger.info("Daemon is healthy")
except LbryClientError as e:
logger.error(str(e))
return stats
# Load state
state_db = StateDb(config.general.state_file)
state_db.load()
# Initialize planner and downloader
base_path = Path(config.general.base_dir)
planner = Planner(config, state_db.data, str(base_path))
downloader = Downloader(client, config, base_path)
# Process each enabled channel
enabled_channels = [c for c in config.channels if c.enabled]
for channel_config in enabled_channels:
stats["channels_scanned"] += 1
# Resolve channel
channel = resolve_channel(client, channel_config.input)
if not channel:
stats["failures"] += 1
continue
# Set custom download path if provided in config
if channel_config.download_path:
channel.download_path = channel_config.download_path
# Check if we already have this channel in state
existing = state_db.get_channel(channel.channel_claim_id)
if existing:
# Update with new info but keep folder and download_path
channel.folder = existing.folder
if existing.download_path:
channel.download_path = existing.download_path
# Enumerate claims
claims = enumerate_channel_claims(client, channel, config)
# Plan downloads
actions = planner.process_channel_claims(channel, claims)
# Apply download limit if configured
# Separate skip actions from download actions
skip_actions = [a for a in actions if a.action == "skip_existing"]
download_actions = [a for a in actions if a.action != "skip_existing"]
# Sort download actions by release_time (most recent first)
# Claims without release_time will be at the end
download_actions.sort(
key=lambda a: a.metadata.get("release_time") or 0, reverse=True
)
# Apply limit: 0 means "all" (no limit), otherwise limit to download_limit
if (
config.general.download_limit > 0
and len(download_actions) > config.general.download_limit
):
skipped_due_to_limit = download_actions[config.general.download_limit :]
download_actions = download_actions[: config.general.download_limit]
logger.info(
f"Download limit ({config.general.download_limit}) reached. "
f"Skipping {len(skipped_due_to_limit)} older items."
)
# Mark skipped items in stats
for action in skipped_due_to_limit:
stats["skipped_existing"] += 1
# Recombine actions
actions = skip_actions + download_actions
stats["claims_examined"] += len(actions)
# Execute downloads
for action in actions:
if action.action == "skip_existing":
stats["skipped_existing"] += 1
elif action.action == "redownload_missing":
stats["redownloaded_missing"] += 1
elif action.action in ("download_new", "download_new_version"):
if action.action == "download_new":
stats["new_downloads"] += 1
else:
stats["new_versions"] += 1
try:
success = downloader.download(action, dry_run=dry_run)
if success and not dry_run and action.action not in ("skip_existing",):
# Update state
planner.update_state_from_action(action, success=True)
# Log to history
history_file = base_path / "state" / "run-history.jsonl"
state_db.log_run_history(
str(history_file),
channel.channel_claim_id,
action.claim_id,
action.action,
action.version_token,
"success" if success else "failure",
)
except DownloadError as e:
logger.error(f"Download failed: {e}")
stats["failures"] += 1
# Log failure
history_file = base_path / "state" / "run-history.jsonl"
state_db.log_run_history(
str(history_file),
channel.channel_claim_id,
action.claim_id,
action.action,
action.version_token,
"failure",
str(e),
)
# Update channel last scan time
from datetime import datetime, timezone
channel.last_scan = datetime.now(timezone.utc).isoformat()
state_db.set_channel(channel)
# Save state
if not dry_run:
state_db.update_last_run()
state_db.save()
logger.info("State saved")
return stats
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(
description="LBRY/Odysee Channel Downloader",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python main.py # Run with default config
python main.py --config ./config.yaml # Use custom config
python main.py --dry-run # Plan actions but don't download
""",
)
parser.add_argument(
"--config",
"-c",
help="Path to configuration file (default: ~/Documents/lbry-downloads/config.yaml)",
)
parser.add_argument(
"--dry-run",
"-n",
action="store_true",
help="Show what would be downloaded without actually downloading",
)
args = parser.parse_args()
try:
# Load config
config = load_config(args.config)
# Setup logging
setup_logging(config.general.log_level)
logger = logging.getLogger(__name__)
logger.info("LBRY Downloader starting")
# Ensure directories exist
ensure_directories(config)
# Run sync
stats = run_sync(config, dry_run=args.dry_run)
# Print summary
summary = format_summary(
channels_scanned=stats["channels_scanned"],
claims_examined=stats["claims_examined"],
new_downloads=stats["new_downloads"],
new_versions=stats["new_versions"],
skipped_existing=stats["skipped_existing"],
redownloaded_missing=stats["redownloaded_missing"],
failures=stats["failures"],
)
print("\n" + summary)
# Exit with error code if there were failures
if stats["failures"] > 0:
sys.exit(1)
except ConfigError as e:
print(f"Configuration error: {e}", file=sys.stderr)
sys.exit(1)
except KeyboardInterrupt:
print("\nInterrupted by user", file=sys.stderr)
sys.exit(130)
except Exception as e:
print(f"Unexpected error: {e}", file=sys.stderr)
raise
if __name__ == "__main__":
main()