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:
thePR0M3TH3AN committed 2026-03-15 22:04:10 -04:00
1 parent dfea928f7e
commit 74e8524897
2 files changed
+324 -4

No files matched your search

+27 -4
View File
@@ -18,7 +18,6 @@ This project prioritizes correctness, repeatability, and safe incremental behavi
## 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:
- channel identity = `channel_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 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
@@ -49,9 +67,13 @@ Agents should preserve this separation of concerns:
- compare discovered claims against local state
- decide: skip / new / new_version / redownload_missing
- `downloader.py`
- execute downloads
- execute downloads via P2P (lbrynet daemon)
- write metadata files
- 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`
- load/save state
- atomic writes only
@@ -345,16 +367,17 @@ For high-impact changes, preserve backwards compatibility or document a migratio
## 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 add async/concurrency complexity unless needed.
* Do not rename major folders or config keys casually.
* 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 filenames are unique.
* 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
+297
View File
@@ -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}")