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 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]:
|
||||
|
||||
@@ -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