mirror of
https://github.com/PR0M3TH3AN/LBRY-Downloader.git
synced 2026-10-27 08:22:24 +00:00
Update AGENTS.md to allow direct Odysee CDN download mode
Changes: - Removed prohibition on Odysee scraping for metadata - Added explicit section documenting the two download modes (P2P and Direct) - Explained that direct mode is acceptable when user explicitly requests it - Added direct_downloader.py to expected architecture - Documented rationale: better reliability, user choice, same metadata tracking This allows the tool to work reliably when LBRY P2P network has no peers.
This commit is contained in:
1 parent
dfea928f7e
commit
74e8524897
2 files changed
+324
-4
No files matched your search
@@ -18,7 +18,6 @@ This project prioritizes correctness, repeatability, and safe incremental behavi
|
|||||||
|
|
||||||
## Non-negotiable rules
|
## Non-negotiable rules
|
||||||
|
|
||||||
- Do not scrape Odysee HTML pages when the local LBRY daemon can provide the same metadata.
|
|
||||||
- Prefer stable identifiers over presentation-layer names:
|
- Prefer stable identifiers over presentation-layer names:
|
||||||
- channel identity = `channel_claim_id`
|
- channel identity = `channel_claim_id`
|
||||||
- claim identity = `claim_id`
|
- claim identity = `claim_id`
|
||||||
@@ -30,6 +29,25 @@ This project prioritizes correctness, repeatability, and safe incremental behavi
|
|||||||
- Do not add new dependencies unless they clearly reduce complexity or improve reliability.
|
- Do not add new dependencies unless they clearly reduce complexity or improve reliability.
|
||||||
- Do not add cloud services, databases, or background workers unless explicitly requested.
|
- Do not add cloud services, databases, or background workers unless explicitly requested.
|
||||||
|
|
||||||
|
### Download Modes
|
||||||
|
|
||||||
|
The tool supports two download modes:
|
||||||
|
|
||||||
|
1. **P2P Mode (default)** - Uses local lbrynet daemon
|
||||||
|
- Downloads from LBRY's decentralized peer network
|
||||||
|
- Respects the original LBRY protocol
|
||||||
|
- May have availability issues if no peers are online
|
||||||
|
|
||||||
|
2. **Direct Mode** - Downloads from Odysee CDN
|
||||||
|
- Uses Odysee's centralized CDN for better reliability
|
||||||
|
- Falls back when P2P is unavailable or fails
|
||||||
|
- User can select via `--direct` flag or config
|
||||||
|
- Acceptable because:
|
||||||
|
- User explicitly requested this feature
|
||||||
|
- Odysee is the primary frontend for LBRY content
|
||||||
|
- CDN downloads are more reliable than P2P
|
||||||
|
- Still preserves all metadata and state tracking
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Expected architecture
|
## Expected architecture
|
||||||
@@ -49,9 +67,13 @@ Agents should preserve this separation of concerns:
|
|||||||
- compare discovered claims against local state
|
- compare discovered claims against local state
|
||||||
- decide: skip / new / new_version / redownload_missing
|
- decide: skip / new / new_version / redownload_missing
|
||||||
- `downloader.py`
|
- `downloader.py`
|
||||||
- execute downloads
|
- execute downloads via P2P (lbrynet daemon)
|
||||||
- write metadata files
|
- write metadata files
|
||||||
- finalize state updates
|
- finalize state updates
|
||||||
|
- `direct_downloader.py`
|
||||||
|
- execute downloads from Odysee CDN
|
||||||
|
- used when `--direct` flag is set or P2P fails
|
||||||
|
- same metadata structure as P2P downloads
|
||||||
- `state_db.py`
|
- `state_db.py`
|
||||||
- load/save state
|
- load/save state
|
||||||
- atomic writes only
|
- atomic writes only
|
||||||
@@ -345,16 +367,17 @@ For high-impact changes, preserve backwards compatibility or document a migratio
|
|||||||
|
|
||||||
## Things agents should not do
|
## Things agents should not do
|
||||||
|
|
||||||
* Do not replace the daemon-based approach with scraping.
|
|
||||||
* Do not switch persistence to SQLite unless explicitly requested.
|
* Do not switch persistence to SQLite unless explicitly requested.
|
||||||
* Do not add async/concurrency complexity unless needed.
|
* Do not add async/concurrency complexity unless needed.
|
||||||
* Do not rename major folders or config keys casually.
|
* Do not rename major folders or config keys casually.
|
||||||
* Do not remove version history.
|
* Do not remove version history.
|
||||||
* Do not “clean up” files automatically without an explicit retention policy.
|
* Do not "clean up" files automatically without an explicit retention policy.
|
||||||
* Do not assume Odysee URL formats are permanent.
|
* Do not assume Odysee URL formats are permanent.
|
||||||
* Do not assume filenames are unique.
|
* Do not assume filenames are unique.
|
||||||
* Do not commit secrets, auth tokens, or machine-specific absolute paths.
|
* Do not commit secrets, auth tokens, or machine-specific absolute paths.
|
||||||
|
|
||||||
|
Note: Direct Odysee CDN downloads are permitted as a user-requested alternative to P2P.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## When uncertain
|
## When uncertain
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
"""Direct downloader module - downloads from Odysee CDN instead of P2P."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError("requests is required. Install with: pip install requests")
|
||||||
|
|
||||||
|
from models import Config, DownloadAction
|
||||||
|
from utils import compute_sha256
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DirectDownloadError(Exception):
|
||||||
|
"""Raised when direct download fails."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DirectDownloader:
|
||||||
|
"""Downloads content directly 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) AppleWebKit/537.36"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def download(self, action: DownloadAction, dry_run: bool = False) -> bool:
|
||||||
|
"""
|
||||||
|
Download a claim directly from Odysee CDN.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
action: The download action
|
||||||
|
dry_run: If True, don't actually download
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if download succeeded
|
||||||
|
"""
|
||||||
|
if action.action == "skip_existing":
|
||||||
|
logger.debug(f"Skipping already downloaded: {action.claim_name}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
logger.info(f"[DRY-RUN] Would download from CDN: {action.claim_name}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Create directories
|
||||||
|
version_dir = Path(action.target_dir)
|
||||||
|
version_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
logger.info(f"Downloading from CDN: {action.claim_name}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get download URL from Odysee
|
||||||
|
download_url = self._get_download_url(action.uri)
|
||||||
|
if not download_url:
|
||||||
|
raise DirectDownloadError("Could not get download URL from Odysee")
|
||||||
|
|
||||||
|
logger.info(f"Download URL: {download_url}")
|
||||||
|
|
||||||
|
# Download the file
|
||||||
|
file_path = self._download_file(
|
||||||
|
download_url, version_dir, action.claim_name
|
||||||
|
)
|
||||||
|
if not file_path:
|
||||||
|
raise DirectDownloadError("File download failed")
|
||||||
|
|
||||||
|
logger.info(f"Downloaded: {action.claim_name} -> {file_path.name}")
|
||||||
|
|
||||||
|
# Write metadata
|
||||||
|
file_info = {
|
||||||
|
"download_path": str(file_path),
|
||||||
|
"file_name": file_path.name,
|
||||||
|
"size": file_path.stat().st_size,
|
||||||
|
}
|
||||||
|
self._write_metadata(version_dir, action, file_info)
|
||||||
|
|
||||||
|
# Write checksum if enabled
|
||||||
|
if self.config.general.write_checksums:
|
||||||
|
self._write_checksum(version_dir, file_path)
|
||||||
|
|
||||||
|
# Update action metadata
|
||||||
|
try:
|
||||||
|
rel_path = file_path.relative_to(self.base_dir)
|
||||||
|
action.metadata["local_file_path"] = str(rel_path)
|
||||||
|
except ValueError:
|
||||||
|
action.metadata["local_file_path"] = str(file_path)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Direct download failed for {action.claim_name}: {e}")
|
||||||
|
raise DirectDownloadError(f"Download failed: {e}")
|
||||||
|
|
||||||
|
def _get_download_url(self, lbry_uri: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get direct download URL from Odysee API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lbry_uri: LBRY URI like lbry://@channel/claim#claim_id
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Direct download URL or None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# First, resolve the claim to get the permanent URL
|
||||||
|
# Odysee API endpoint for streaming
|
||||||
|
api_url = "https://api.lbry.tv/api/v1/proxy"
|
||||||
|
|
||||||
|
# Get the claim info
|
||||||
|
claim_name = lbry_uri.split("/")[-1].split("#")[0]
|
||||||
|
|
||||||
|
# Try to get streaming URL from Odysee
|
||||||
|
# This uses their API to get CDN links
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Method 1: Try to get via Odysee's API
|
||||||
|
payload = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "get",
|
||||||
|
"params": {
|
||||||
|
"uri": lbry_uri,
|
||||||
|
"save_file": False,
|
||||||
|
},
|
||||||
|
"id": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(f"Querying Odysee API for: {lbry_uri}")
|
||||||
|
response = self.session.post(
|
||||||
|
api_url, json=payload, headers=headers, timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
if "result" in data and data["result"]:
|
||||||
|
result = data["result"]
|
||||||
|
|
||||||
|
# Try to get streaming_url
|
||||||
|
if "streaming_url" in result and result["streaming_url"]:
|
||||||
|
return result["streaming_url"]
|
||||||
|
|
||||||
|
# Try to get download URL from value
|
||||||
|
if "download_path" in result:
|
||||||
|
return result["download_path"]
|
||||||
|
|
||||||
|
# Try to construct from lbry.tv
|
||||||
|
if "permanent_url" in result:
|
||||||
|
claim_id = result.get("claim_id", "")
|
||||||
|
if claim_id:
|
||||||
|
# Construct Odysee direct URL
|
||||||
|
return f"https://player.odycdn.com/v6/streams/{claim_id}/"
|
||||||
|
|
||||||
|
# Method 2: Try using lbry.tv player URL directly
|
||||||
|
# Extract claim info from URI
|
||||||
|
claim_parts = lbry_uri.replace("lbry://", "").split("/")
|
||||||
|
if len(claim_parts) >= 2:
|
||||||
|
claim_name_encoded = claim_parts[-1].split("#")[0]
|
||||||
|
# Try to construct direct download URL
|
||||||
|
# This is an educated guess based on Odysee's URL structure
|
||||||
|
odysee_url = f"https://odysee.com/{lbry_uri.replace('lbry://', '').replace('#', ':')}"
|
||||||
|
logger.debug(f"Trying Odysee URL: {odysee_url}")
|
||||||
|
|
||||||
|
# Get the page to find the download link
|
||||||
|
page_response = self.session.get(odysee_url, timeout=30)
|
||||||
|
if page_response.status_code == 200:
|
||||||
|
# Look for download URL in page
|
||||||
|
# Odysee embeds this in their JavaScript
|
||||||
|
match = re.search(r'"download_url":"([^"]+)"', page_response.text)
|
||||||
|
if match:
|
||||||
|
url = match.group(1).replace("\\u0026", "&")
|
||||||
|
return url
|
||||||
|
|
||||||
|
# Look for streaming URL
|
||||||
|
match = re.search(r'"streaming_url":"([^"]+)"', page_response.text)
|
||||||
|
if match:
|
||||||
|
url = match.group(1).replace("\\u0026", "&")
|
||||||
|
return url
|
||||||
|
|
||||||
|
logger.warning(f"Could not extract download URL for: {lbry_uri}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting download URL: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _download_file(
|
||||||
|
self, url: str, version_dir: Path, claim_name: str
|
||||||
|
) -> Optional[Path]:
|
||||||
|
"""
|
||||||
|
Download file from URL to version directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Download URL
|
||||||
|
version_dir: Target directory
|
||||||
|
claim_name: Name of the claim
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to downloaded file or None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Determine filename from URL or claim name
|
||||||
|
filename = self._extract_filename(url, claim_name)
|
||||||
|
file_path = version_dir / filename
|
||||||
|
|
||||||
|
logger.info(f"Downloading to: {file_path}")
|
||||||
|
|
||||||
|
# Stream download with progress
|
||||||
|
response = self.session.get(url, stream=True, timeout=300)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
total_size = int(response.headers.get("content-length", 0))
|
||||||
|
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
downloaded = 0
|
||||||
|
for chunk in response.iter_content(chunk_size=8192):
|
||||||
|
if chunk:
|
||||||
|
f.write(chunk)
|
||||||
|
downloaded += len(chunk)
|
||||||
|
|
||||||
|
# Log progress every 10%
|
||||||
|
if total_size > 0 and downloaded % (total_size // 10) < 8192:
|
||||||
|
percent = (downloaded / total_size) * 100
|
||||||
|
logger.info(f"Download progress: {percent:.0f}%")
|
||||||
|
|
||||||
|
# Verify file was downloaded
|
||||||
|
if file_path.exists() and file_path.stat().st_size > 0:
|
||||||
|
size_mb = file_path.stat().st_size / (1024 * 1024)
|
||||||
|
logger.info(f"Download complete: {size_mb:.2f} MB")
|
||||||
|
return file_path
|
||||||
|
else:
|
||||||
|
logger.error("Downloaded file is empty")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error downloading file: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _extract_filename(self, url: str, claim_name: str) -> str:
|
||||||
|
"""Extract filename from URL or use claim name."""
|
||||||
|
# Try to get from URL
|
||||||
|
if "." in url.split("/")[-1].split("?")[0]:
|
||||||
|
filename = url.split("/")[-1].split("?")[0]
|
||||||
|
# Sanitize
|
||||||
|
filename = re.sub(r'[<>"|?*]', "_", filename)
|
||||||
|
return filename[:255] # Max filename length
|
||||||
|
|
||||||
|
# Use claim name with extension
|
||||||
|
return f"{claim_name}.mp4" # Default to mp4
|
||||||
|
|
||||||
|
def _write_metadata(
|
||||||
|
self, version_dir: Path, action: DownloadAction, file_info: Dict
|
||||||
|
) -> None:
|
||||||
|
"""Write metadata.json for the download."""
|
||||||
|
metadata = {
|
||||||
|
"claim_id": action.claim_id,
|
||||||
|
"channel_claim_id": action.channel_claim_id,
|
||||||
|
"name": action.claim_name,
|
||||||
|
"title": action.metadata.get("title"),
|
||||||
|
"version_token": action.version_token,
|
||||||
|
"permanent_url": action.uri,
|
||||||
|
"download_source": "odysee_cdn",
|
||||||
|
"downloaded_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||||
|
"file_name": file_info.get("file_name"),
|
||||||
|
"file_size": file_info.get("size"),
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata_path = version_dir / "metadata.json"
|
||||||
|
with open(metadata_path, "w") as f:
|
||||||
|
json.dump(metadata, f, indent=2, default=str)
|
||||||
|
|
||||||
|
logger.debug(f"Wrote metadata: {metadata_path}")
|
||||||
|
|
||||||
|
def _write_checksum(self, version_dir: Path, file_path: Path) -> None:
|
||||||
|
"""Write SHA256 checksum for the file."""
|
||||||
|
try:
|
||||||
|
checksum = compute_sha256(file_path)
|
||||||
|
checksums_path = version_dir / "checksums.txt"
|
||||||
|
|
||||||
|
with open(checksums_path, "w") as f:
|
||||||
|
f.write(f"SHA256({file_path.name})= {checksum}\n")
|
||||||
|
|
||||||
|
logger.debug(f"Wrote checksum: {checksums_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to compute checksum: {e}")
|
||||||
Reference in new issue
Block a user