Add proxy client tests and auxiliary direct downloader

This commit is contained in:
thePR0M3TH3AN committed 2026-04-01 20:37:30 -04:00
1 parent 7b50110f2a
commit 5a8a36b060
4 files changed
+404 -8

No files matched your search

+47 -8
View File
@@ -1,4 +1,4 @@
"""LBRY daemon JSON-RPC client.""" """LBRY JSON-RPC client for local daemon or Odysee public proxy."""
import json import json
import logging import logging
@@ -20,13 +20,33 @@ class LbryClientError(Exception):
class LbryClient: 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.api_url = api_url
self.timeout = timeout self.timeout = timeout
self.backend_name = backend_name
self.supports_file_ops = supports_file_ops
self.session = requests.Session() 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]: def _call(self, method: str, **params) -> Dict[str, Any]:
""" """
Make a JSON-RPC call to the daemon. Make a JSON-RPC call to the daemon.
@@ -50,15 +70,15 @@ class LbryClient:
response.raise_for_status() response.raise_for_status()
except requests.exceptions.ConnectionError as e: except requests.exceptions.ConnectionError as e:
raise LbryClientError( raise LbryClientError(
f"Could not connect to LBRY daemon at {self.api_url}. " f"Could not connect to {self.backend_name} at {self.api_url}. "
f"Make sure lbrynet is installed and running. Error: {e}" f"Error: {e}"
) )
except requests.exceptions.Timeout: except requests.exceptions.Timeout:
raise LbryClientError( 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: 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: try:
data = response.json() data = response.json()
@@ -68,7 +88,7 @@ class LbryClient:
if "error" in data and data["error"]: if "error" in data and data["error"]:
error = data["error"] error = data["error"]
raise LbryClientError( raise LbryClientError(
f"Daemon returned error: {error.get('message', error)}" f"{self.backend_name} returned error: {error.get('message', error)}"
) )
return data.get("result", {}) return data.get("result", {})
@@ -169,6 +189,25 @@ class LbryClient:
return self._call("claim_search", **params) 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( def get(
self, uri: str, download_directory: Optional[str] = None, **kwargs self, uri: str, download_directory: Optional[str] = None, **kwargs
) -> Dict[str, Any]: ) -> Dict[str, Any]:
+254
View File
@@ -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}")
+34
View File
@@ -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
+69
View File
@@ -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"