mirror of
https://github.com/PR0M3TH3AN/LBRY-Downloader.git
synced 2026-10-27 08:22:24 +00:00
Add proxy client tests and auxiliary direct downloader
This commit is contained in:
1 parent
7b50110f2a
commit
5a8a36b060
4 files changed
+404
-8
No files matched your search
+47
-8
@@ -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]:
|
||||||
|
|||||||
@@ -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}")
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
|
||||||
Reference in new issue
Block a user