diff --git a/lbry_client.py b/lbry_client.py index 84a7405..8429e90 100644 --- a/lbry_client.py +++ b/lbry_client.py @@ -1,4 +1,4 @@ -"""LBRY daemon JSON-RPC client.""" +"""LBRY JSON-RPC client for local daemon or Odysee public proxy.""" import json import logging @@ -20,13 +20,33 @@ class LbryClientError(Exception): class LbryClient: - """JSON-RPC client for lbrynet daemon.""" + """JSON-RPC client for lbrynet daemon or Odysee's public proxy.""" - def __init__(self, api_url: str = "http://127.0.0.1:5279", timeout: int = 60): + ODYSEE_PROXY_URL = "https://api.na-backend.odysee.com/api/v1/proxy" + + def __init__( + self, + api_url: str = "http://127.0.0.1:5279", + timeout: int = 60, + backend_name: str = "LBRY daemon", + supports_file_ops: bool = True, + ): self.api_url = api_url self.timeout = timeout + self.backend_name = backend_name + self.supports_file_ops = supports_file_ops self.session = requests.Session() + @classmethod + def create_odysee_proxy(cls, timeout: int = 60) -> "LbryClient": + """Create a client backed by Odysee's public JSON-RPC proxy.""" + return cls( + api_url=cls.ODYSEE_PROXY_URL, + timeout=timeout, + backend_name="Odysee public proxy", + supports_file_ops=False, + ) + def _call(self, method: str, **params) -> Dict[str, Any]: """ Make a JSON-RPC call to the daemon. @@ -50,15 +70,15 @@ class LbryClient: response.raise_for_status() except requests.exceptions.ConnectionError as e: raise LbryClientError( - f"Could not connect to LBRY daemon at {self.api_url}. " - f"Make sure lbrynet is installed and running. Error: {e}" + f"Could not connect to {self.backend_name} at {self.api_url}. " + f"Error: {e}" ) except requests.exceptions.Timeout: raise LbryClientError( - f"Request to LBRY daemon timed out after {self.timeout} seconds" + f"Request to {self.backend_name} timed out after {self.timeout} seconds" ) except requests.exceptions.RequestException as e: - raise LbryClientError(f"Request to LBRY daemon failed: {e}") + raise LbryClientError(f"Request to {self.backend_name} failed: {e}") try: data = response.json() @@ -68,7 +88,7 @@ class LbryClient: if "error" in data and data["error"]: error = data["error"] raise LbryClientError( - f"Daemon returned error: {error.get('message', error)}" + f"{self.backend_name} returned error: {error.get('message', error)}" ) return data.get("result", {}) @@ -169,6 +189,25 @@ class LbryClient: return self._call("claim_search", **params) + def find_controlling_channel(self, channel_name: str) -> Optional[Dict[str, Any]]: + """ + Find the controlling channel claim for a channel name. + + This is needed for Odysee's public proxy because user-friendly channel + URLs can resolve to non-controlling channel claims that do not enumerate + stream uploads correctly. + """ + result = self.claim_search( + name=channel_name, + claim_type=["channel"], + is_controlling=True, + order_by=["effective_amount"], + page=1, + page_size=1, + ) + items = result.get("items", []) + return items[0] if items else None + def get( self, uri: str, download_directory: Optional[str] = None, **kwargs ) -> Dict[str, Any]: diff --git a/simple_direct_downloader.py b/simple_direct_downloader.py new file mode 100644 index 0000000..687df54 --- /dev/null +++ b/simple_direct_downloader.py @@ -0,0 +1,254 @@ +"""Simple direct downloader - uses Odysee's public API.""" + +import json +import logging +import time +from pathlib import Path +from typing import Dict, Optional + +try: + import requests +except ImportError: + raise ImportError("requests is required") + +from models import Config, DownloadAction + +logger = logging.getLogger(__name__) + + +class SimpleDirectDownloader: + """Simple downloader that gets files from Odysee CDN.""" + + def __init__(self, config: Config, base_dir: Path): + self.config = config + self.base_dir = base_dir + self.session = requests.Session() + self.session.headers.update( + { + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0" + } + ) + + def download(self, action: DownloadAction, dry_run: bool = False) -> bool: + """Download a file from Odysee.""" + if action.action == "skip_existing": + return True + + if dry_run: + print(f"[DRY-RUN] Would download: {action.claim_name}") + return True + + version_dir = Path(action.target_dir) + version_dir.mkdir(parents=True, exist_ok=True) + + try: + print(f"\nšŸ“„ Downloading: {action.claim_name}") + + # Get the Odysee URL from the LBRY URI + odysee_url = self._get_odysee_url(action.uri) + print(f" URL: {odysee_url}") + + # Method 1: Try to get the download link from the page + download_url = self._get_download_link_from_page(odysee_url) + + if not download_url: + # Method 2: Try API + download_url = self._get_from_api(action.uri) + + if not download_url: + print(f" āš ļø Could not find download URL") + return False + + print(f" Download URL found!") + + # Download the file + return self._download_file( + download_url, version_dir, action.claim_name, action + ) + + except Exception as e: + print(f" āŒ Error: {e}") + logger.error(f"Download failed: {e}") + return False + + def _get_odysee_url(self, lbry_uri: str) -> str: + """Convert LBRY URI to Odysee URL.""" + # lbry://@channel/claim#id -> https://odysee.com/@channel:claim + uri = lbry_uri.replace("lbry://", "") + parts = uri.split("/") + if len(parts) >= 2: + channel = parts[0].replace("#", ":") + claim = parts[1].split("#")[0] + return f"https://odysee.com/{channel}/{claim}" + return f"https://odysee.com/{uri.replace('#', ':')}" + + def _get_download_link_from_page(self, odysee_url: str) -> Optional[str]: + """Try to extract download link from Odysee page.""" + try: + response = self.session.get(odysee_url, timeout=30) + if response.status_code == 200: + # Look for various patterns + patterns = [ + r'"content\":\{\"url\":\"([^\"]+)\"', + r'"streaming_url\":\"([^\"]+)\"', + r'"download_url\":\"([^\"]+)\"', + r'"url\":\"(https://player\.odycdn\.com/[^\"]+)\"', + ] + + for pattern in patterns: + match = re.search(pattern, response.text) + if match: + url = match.group(1).replace("\\u0026", "&") + if url.startswith("http"): + return url + + return None + except Exception as e: + logger.debug(f"Could not get from page: {e}") + return None + + def _get_from_api(self, lbry_uri: str) -> Optional[str]: + """Try to get download URL from Odysee API.""" + try: + api_url = "https://api.lbry.tv/api/v1/proxy" + + payload = { + "jsonrpc": "2.0", + "method": "resolve", + "params": {"urls": [lbry_uri]}, + "id": 1, + } + + response = self.session.post(api_url, json=payload, timeout=30) + + if response.status_code == 200: + data = response.json() + result = data.get("result", {}).get(lbry_uri, {}) + + # Get claim ID + claim_id = result.get("claim_id", "") + if claim_id: + # Try common Odysee CDN patterns + urls_to_try = [ + f"https://player.odycdn.com/v6/streams/{claim_id}", + f"https://player.odycdn.com/v6/streams/{claim_id}/", + f"https://cdn.lbryplayer.xyz/content/claims/{claim_id}/stream", + ] + + for url in urls_to_try: + # Test if URL works + head_response = self.session.head( + url, timeout=10, allow_redirects=True + ) + if head_response.status_code == 200: + return url + + return None + except Exception as e: + logger.debug(f"API error: {e}") + return None + + def _download_file( + self, url: str, version_dir: Path, claim_name: str, action: DownloadAction + ) -> bool: + """Download the file with progress.""" + try: + # Try to determine filename + filename = f"{claim_name}.mp4" # Default + + response = self.session.get(url, stream=True, timeout=300) + + # Try to get filename from headers + cd_header = response.headers.get("content-disposition", "") + if "filename=" in cd_header: + import re + + fname_match = re.search(r'filename="?([^"]+)"?', cd_header) + if fname_match: + filename = fname_match.group(1) + + file_path = version_dir / filename + + total_size = int(response.headers.get("content-length", 0)) + + if total_size > 0: + size_mb = total_size / (1024 * 1024) + print(f" Size: {size_mb:.2f} MB") + + print(f" Saving to: {filename}") + print() + + # Download with progress + downloaded = 0 + start_time = time.time() + last_update = start_time + + with open(file_path, "wb") as f: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + downloaded += len(chunk) + + current_time = time.time() + if current_time - last_update >= 0.5: + self._print_progress(downloaded, total_size, start_time) + last_update = current_time + + self._print_progress(downloaded, total_size, start_time, final=True) + print() + + # Verify + if file_path.exists() and file_path.stat().st_size > 0: + size_mb = file_path.stat().st_size / (1024 * 1024) + elapsed = time.time() - start_time + speed = size_mb / elapsed if elapsed > 0 else 0 + print(f" āœ… Complete: {size_mb:.2f} MB @ {speed:.2f} MB/s") + + # Write metadata + self._write_metadata(version_dir, action, file_path) + return True + else: + print(f" āŒ Download failed") + return False + + except Exception as e: + print(f" āŒ Error: {e}") + return False + + def _print_progress( + self, downloaded: int, total: int, start_time: float, final: bool = False + ): + """Print progress bar.""" + if total > 0: + percent = min(100, int((downloaded / total) * 100)) + filled = int(40 * percent / 100) + bar = "ā–ˆ" * filled + "ā–‘" * (40 - filled) + + elapsed = time.time() - start_time + if elapsed > 0: + speed = (downloaded / (1024 * 1024)) / elapsed + print(f"\r [{bar}] {percent}% | {speed:.2f} MB/s", end="", flush=True) + else: + mb = downloaded / (1024 * 1024) + print(f"\r Downloaded: {mb:.2f} MB", end="", flush=True) + + def _write_metadata( + self, version_dir: Path, action: DownloadAction, file_path: Path + ): + """Write metadata file.""" + metadata = { + "claim_id": action.claim_id, + "claim_name": action.claim_name, + "version_token": action.version_token, + "uri": action.uri, + "downloaded_at": time.strftime("%Y-%m-%dT%H:%M:%S"), + "file_name": file_path.name, + "file_size": file_path.stat().st_size, + "download_source": "odysee_direct", + } + + meta_path = version_dir / "metadata.json" + with open(meta_path, "w") as f: + json.dump(metadata, f, indent=2) + + logger.info(f"Wrote metadata: {meta_path}") diff --git a/tests/test_lbry_client.py b/tests/test_lbry_client.py new file mode 100644 index 0000000..3a32517 --- /dev/null +++ b/tests/test_lbry_client.py @@ -0,0 +1,34 @@ +"""Tests for LBRY client helper behavior.""" + +from lbry_client import LbryClient + + +def test_create_odysee_proxy_client_defaults(): + client = LbryClient.create_odysee_proxy(timeout=42) + + assert client.api_url == LbryClient.ODYSEE_PROXY_URL + assert client.timeout == 42 + assert client.backend_name == "Odysee public proxy" + assert client.supports_file_ops is False + + +def test_find_controlling_channel_uses_claim_search(monkeypatch): + client = LbryClient.create_odysee_proxy() + captured = {} + + def fake_claim_search(**kwargs): + captured.update(kwargs) + return {"items": [{"claim_id": "control123", "name": "@example"}]} + + monkeypatch.setattr(client, "claim_search", fake_claim_search) + + result = client.find_controlling_channel("@example") + + assert result == {"claim_id": "control123", "name": "@example"} + assert captured["name"] == "@example" + assert captured["claim_type"] == ["channel"] + assert captured["is_controlling"] is True + assert captured["order_by"] == ["effective_amount"] + assert captured["page"] == 1 + assert captured["page_size"] == 1 + diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..9a7b62a --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,69 @@ +"""Tests for main module channel resolution behavior.""" + +from main import resolve_channel + + +class FakeProxyClient: + """Minimal client stub for proxy-backed channel resolution tests.""" + + supports_file_ops = False + + def resolve(self, urls): + uri = urls[0] + return { + uri: { + "claim_id": "vanity123", + "name": "@veritasium", + "value_type": "channel", + "value": {"title": "Veritasium"}, + "permanent_url": "lbry://@veritasium#1", + "short_url": "lbry://@veritasium#1", + } + } + + def find_controlling_channel(self, channel_name): + assert channel_name == "@veritasium" + return { + "claim_id": "control999", + "name": "@veritasium", + "value_type": "channel", + "value": {"title": "Veritasium"}, + "permanent_url": "lbry://@veritasium#f", + "short_url": "lbry://@veritasium#f", + } + + +class FakeDaemonClient: + """Minimal client stub for daemon-backed resolution tests.""" + + supports_file_ops = True + + def resolve(self, urls): + uri = urls[0] + return { + uri: { + "claim_id": "daemon123", + "name": "@example", + "value_type": "channel", + "value": {"title": "Example"}, + "permanent_url": "lbry://@example#1", + "short_url": "lbry://@example#1", + } + } + + +def test_resolve_channel_uses_controlling_claim_for_proxy_client(): + channel = resolve_channel(FakeProxyClient(), "https://odysee.com/@veritasium:1") + + assert channel is not None + assert channel.channel_claim_id == "control999" + assert channel.channel_name == "@veritasium" + + +def test_resolve_channel_keeps_daemon_claim_id(): + channel = resolve_channel(FakeDaemonClient(), "https://odysee.com/@example:1") + + assert channel is not None + assert channel.channel_claim_id == "daemon123" + assert channel.channel_name == "@example" +