Complete LBRY Downloader implementation v1.0.0

Features:
- Incremental sync with version tracking (sd_hash)
- Per-channel download paths
- Download limit (default: 10 most recent per channel)
- Interactive setup script with user prompts
- Uninstall script for clean removal
- Comprehensive test suite (42 tests)
- Full documentation suite

Files:
- 9 Python core modules (main, models, utils, config, client, state, planner, downloader, init)
- 3 Setup scripts (setup.py, uninstall.py, install.sh)
- 5 Test files (42 tests, all passing)
- 9 Documentation files
- 4 Build/config files

Total: 31 files, ~4,500 lines
This commit is contained in:
thePR0M3TH3AN committed 2026-03-15 18:10:56 -04:00
1 parent db4f11ba6d
commit e707400ffd
31 files changed
+5825

No files matched your search

View File
Whitespace-only changes.
+137
View File
@@ -0,0 +1,137 @@
"""Tests for configuration loading."""
import pytest
from pathlib import Path
import tempfile
import os
from config_loader import load_config, ConfigError, create_default_config
from models import Config
class TestLoadConfig:
def test_missing_config_file(self):
with pytest.raises(ConfigError) as exc_info:
load_config("/nonexistent/path/config.yaml")
assert "not found" in str(exc_info.value)
def test_empty_channels_list(self, tmp_path):
config_file = tmp_path / "config.yaml"
config_file.write_text("""
lbrynet:
api_url: "http://127.0.0.1:5279"
general:
base_dir: "~/test"
channels: []
""")
with pytest.raises(ConfigError) as exc_info:
load_config(str(config_file))
assert "at least one channel" in str(exc_info.value)
def test_all_channels_disabled(self, tmp_path):
config_file = tmp_path / "config.yaml"
config_file.write_text("""
lbrynet:
api_url: "http://127.0.0.1:5279"
general:
base_dir: "~/test"
channels:
- input: "https://odysee.com/@Test:1"
enabled: false
""")
with pytest.raises(ConfigError) as exc_info:
load_config(str(config_file))
assert "at least one channel" in str(exc_info.value).lower()
def test_valid_config(self, tmp_path):
config_file = tmp_path / "config.yaml"
config_file.write_text("""
lbrynet:
api_url: "http://127.0.0.1:5279"
timeout_seconds: 120
general:
base_dir: "~/Documents/lbry-downloads"
max_workers: 3
log_level: "DEBUG"
channels:
- input: "https://odysee.com/@Test:1"
enabled: true
tags_include: ["tech"]
tags_exclude: ["spam"]
""")
config = load_config(str(config_file))
assert isinstance(config, Config)
assert config.lbrynet.api_url == "http://127.0.0.1:5279"
assert config.lbrynet.timeout_seconds == 120
assert config.general.max_workers == 3
assert config.general.log_level == "DEBUG"
assert len(config.channels) == 1
assert config.channels[0].input == "https://odysee.com/@Test:1"
assert config.channels[0].tags_include == ["tech"]
def test_invalid_log_level(self, tmp_path):
config_file = tmp_path / "config.yaml"
config_file.write_text("""
lbrynet:
api_url: "http://127.0.0.1:5279"
general:
base_dir: "~/test"
log_level: "INVALID"
channels:
- input: "https://odysee.com/@Test:1"
enabled: true
""")
with pytest.raises(ConfigError) as exc_info:
load_config(str(config_file))
assert "log_level" in str(exc_info.value)
def test_invalid_filename_mode(self, tmp_path):
config_file = tmp_path / "config.yaml"
config_file.write_text("""
lbrynet:
api_url: "http://127.0.0.1:5279"
general:
base_dir: "~/test"
filename_mode: "invalid"
channels:
- input: "https://odysee.com/@Test:1"
enabled: true
""")
with pytest.raises(ConfigError) as exc_info:
load_config(str(config_file))
assert "filename_mode" in str(exc_info.value)
class TestDefaultConfig:
def test_create_default_config(self):
config = create_default_config()
assert "lbrynet:" in config
assert "general:" in config
assert "channels:" in config
assert "http://127.0.0.1:5279" in config
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+50
View File
@@ -0,0 +1,50 @@
"""Tests for download limit feature."""
import pytest
from pathlib import Path
import tempfile
import shutil
from models import Channel, Config, GeneralConfig, StateDatabase, DownloadAction
class TestDownloadLimit:
"""Test the download limit functionality."""
def test_download_limit_zero_means_all(self):
"""Test that download_limit=0 means download all (no limit)."""
config = Config()
config.general = GeneralConfig()
config.general.download_limit = 0
# download_limit of 0 should mean no limit
assert config.general.download_limit == 0
def test_download_limit_default_is_10(self):
"""Test that default download_limit is 10."""
config = Config()
config.general = GeneralConfig()
# Default should be 10
assert config.general.download_limit == 10
def test_negative_download_limit_invalid(self):
"""Test that negative download_limit is invalid."""
from config_loader import ConfigError, _parse_config
raw_config = {
"lbrynet": {},
"general": {
"download_limit": -5 # Invalid
},
"channels": [{"input": "https://odysee.com/@Test:1", "enabled": True}],
}
with pytest.raises(ConfigError) as exc_info:
_parse_config(raw_config)
assert "download_limit" in str(exc_info.value).lower()
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+211
View File
@@ -0,0 +1,211 @@
"""Tests for planner module."""
import pytest
from pathlib import Path
import tempfile
import shutil
from planner import Planner
from models import Channel, Config, GeneralConfig, StateDatabase, Claim, ClaimVersion
from utils import create_channel_folder_name, create_claim_folder_name
class TestPlanner:
def setup_method(self):
self.temp_dir = tempfile.mkdtemp()
self.config = Config()
self.config.general = GeneralConfig()
self.config.general.verify_existing_files = True
self.state = StateDatabase()
self.planner = Planner(self.config, self.state, self.temp_dir)
def teardown_method(self):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_new_claim_action(self):
channel = Channel(
input="test",
normalized_uri="lbry://@Test#1",
channel_claim_id="channel123",
channel_name="@Test",
folder="Test__channel123",
)
claim_data = {
"claim_id": "claim456",
"name": "my-video",
"value_type": "stream",
"value": {"source": {"sd_hash": "abc123"}, "title": "My Video"},
"permanent_url": "lbry://@Test#1/my-video#claim456",
"txid": "tx789",
"nout": 0,
}
actions = self.planner.process_channel_claims(channel, [claim_data])
assert len(actions) == 1
assert actions[0].action == "download_new"
assert actions[0].claim_id == "claim456"
def test_skip_existing_claim(self):
channel = Channel(
input="test",
normalized_uri="lbry://@Test#1",
channel_claim_id="channel123",
channel_name="@Test",
folder="Test__channel123",
)
# Add existing claim to state
claim_folder = create_claim_folder_name("my-video", "claim456")
claim = Claim(
claim_id="claim456",
channel_claim_id="channel123",
name="my-video",
permanent_url="lbry://@Test#1/my-video#claim456",
claim_folder=f"channels/Test__channel123/claims/{claim_folder}",
)
# Create version token matching the claim data
from utils import generate_version_token
version_token = generate_version_token(sd_hash="abc123")
claim.versions[version_token] = ClaimVersion(
version_token=version_token,
txid="tx789",
nout=0,
sd_hash="abc123",
downloaded=True,
file_relpath=f"channels/Test__channel123/claims/{claim_folder}/versions/{version_token}/video.mp4",
)
self.state.claims["claim456"] = claim
# Create the "downloaded" file
version_dir = (
Path(self.temp_dir)
/ f"channels/Test__channel123/claims/{claim_folder}/versions/{version_token}"
)
version_dir.mkdir(parents=True)
(version_dir / "video.mp4").touch()
claim_data = {
"claim_id": "claim456",
"name": "my-video",
"value_type": "stream",
"value": {"source": {"sd_hash": "abc123"}, "title": "My Video"},
"permanent_url": "lbry://@Test#1/my-video#claim456",
"txid": "tx789",
"nout": 0,
}
actions = self.planner.process_channel_claims(channel, [claim_data])
assert len(actions) == 1
assert actions[0].action == "skip_existing"
def test_new_version_detected(self):
channel = Channel(
input="test",
normalized_uri="lbry://@Test#1",
channel_claim_id="channel123",
channel_name="@Test",
folder="Test__channel123",
)
# Add existing claim with old version
claim_folder = create_claim_folder_name("my-video", "claim456")
claim = Claim(
claim_id="claim456",
channel_claim_id="channel123",
name="my-video",
permanent_url="lbry://@Test#1/my-video#claim456",
claim_folder=f"channels/Test__channel123/claims/{claim_folder}",
)
old_version_token = "sd_oldhash123"
claim.versions[old_version_token] = ClaimVersion(
version_token=old_version_token,
txid="tx789",
nout=0,
sd_hash="oldhash123",
downloaded=True,
)
self.state.claims["claim456"] = claim
# New claim data with different sd_hash
claim_data = {
"claim_id": "claim456",
"name": "my-video",
"value_type": "stream",
"value": {
"source": {"sd_hash": "newhash456"}, # Different!
"title": "My Video Updated",
},
"permanent_url": "lbry://@Test#1/my-video#claim456",
"txid": "tx999",
"nout": 0,
}
actions = self.planner.process_channel_claims(channel, [claim_data])
assert len(actions) == 1
assert actions[0].action == "download_new_version"
def test_non_downloadable_claim_skipped(self):
channel = Channel(
input="test",
normalized_uri="lbry://@Test#1",
channel_claim_id="channel123",
channel_name="@Test",
folder="Test__channel123",
)
# Channel type claim (not downloadable)
claim_data = {
"claim_id": "claim456",
"name": "@SomeChannel",
"value_type": "channel",
"value": {},
"permanent_url": "lbry://@SomeChannel#claim456",
}
actions = self.planner.process_channel_claims(channel, [claim_data])
assert len(actions) == 0
def test_custom_download_path(self):
"""Test that custom download_path is used for channel."""
custom_path = "/custom/download/location"
channel = Channel(
input="test",
normalized_uri="lbry://@Test#1",
channel_claim_id="channel123",
channel_name="@Test",
folder="Test__channel123",
download_path=custom_path,
)
claim_data = {
"claim_id": "claim456",
"name": "my-video",
"value_type": "stream",
"value": {"source": {"sd_hash": "abc123"}, "title": "My Video"},
"permanent_url": "lbry://@Test#1/my-video#claim456",
"txid": "tx789",
"nout": 0,
}
actions = self.planner.process_channel_claims(channel, [claim_data])
assert len(actions) == 1
assert actions[0].action == "download_new"
# Check that target_dir uses custom path
assert custom_path in actions[0].target_dir
assert "Test__channel123" in actions[0].target_dir
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+135
View File
@@ -0,0 +1,135 @@
"""Tests for state database."""
import json
import pytest
from pathlib import Path
import tempfile
import shutil
from state_db import StateDb
from models import Channel, Claim, ClaimVersion
class TestStateDb:
def setup_method(self):
self.temp_dir = tempfile.mkdtemp()
self.state_file = Path(self.temp_dir) / "test_state.json"
def teardown_method(self):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_load_new_database(self):
db = StateDb(str(self.state_file))
state = db.load()
assert state.schema_version == 1
assert state.channels == {}
assert state.claims == {}
def test_save_and_load(self):
db = StateDb(str(self.state_file))
db.load()
# Add a channel
channel = Channel(
input="https://odysee.com/@Test:1",
normalized_uri="lbry://@Test#1",
channel_claim_id="abc123",
channel_name="@Test",
folder="Test__abc123",
)
db.set_channel(channel)
db.save()
# Load fresh
db2 = StateDb(str(self.state_file))
state = db2.load()
assert "abc123" in state.channels
assert state.channels["abc123"].channel_name == "@Test"
def test_channel_operations(self):
db = StateDb(str(self.state_file))
db.load()
channel = Channel(
input="test",
normalized_uri="lbry://@Test#1",
channel_claim_id="xyz789",
channel_name="@Test",
folder="Test__xyz789",
)
db.set_channel(channel)
retrieved = db.get_channel("xyz789")
assert retrieved is not None
assert retrieved.channel_name == "@Test"
assert db.get_channel("nonexistent") is None
def test_claim_operations(self):
db = StateDb(str(self.state_file))
db.load()
claim = Claim(
claim_id="claim123",
channel_claim_id="channel456",
name="my-video",
permanent_url="lbry://@Test#1/my-video#claim123",
claim_folder="channels/Test__channel456/claims/my-video__claim123",
)
db.set_claim(claim)
retrieved = db.get_claim("claim123")
assert retrieved is not None
assert retrieved.name == "my-video"
def test_run_history_logging(self):
db = StateDb(str(self.state_file))
db.load()
history_file = Path(self.temp_dir) / "history.jsonl"
db.log_run_history(
str(history_file),
"channel123",
"claim456",
"download_new",
"sd_abc123",
"success",
)
assert history_file.exists()
with open(history_file) as f:
line = json.loads(f.readline())
assert line["action"] == "download_new"
assert line["status"] == "success"
def test_get_stats(self):
db = StateDb(str(self.state_file))
db.load()
# Add test data
claim = Claim(
claim_id="c1",
channel_claim_id="ch1",
name="video1",
permanent_url="lbry://test",
claim_folder="test",
)
claim.versions["v1"] = ClaimVersion(
version_token="v1", txid="tx1", nout=0, downloaded=True
)
db.set_claim(claim)
stats = db.get_stats()
assert stats["claims"] == 1
assert stats["total_versions"] == 1
assert stats["downloaded_versions"] == 1
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+115
View File
@@ -0,0 +1,115 @@
"""Tests for utility functions."""
import pytest
from pathlib import Path
from utils import (
sanitize_filename,
slugify,
generate_version_token,
expand_path,
is_downloadable_claim,
normalize_odysee_url,
)
class TestSanitizeFilename:
def test_basic_sanitization(self):
assert sanitize_filename("hello/world") == "hello_world"
assert sanitize_filename("file:name") == "file_name"
assert sanitize_filename('file"name') == "file_name"
def test_whitespace_collapse(self):
assert sanitize_filename("hello world") == "hello world"
def test_trailing_cleanup(self):
assert sanitize_filename("hello.") == "hello"
assert sanitize_filename("hello ") == "hello"
def test_length_limit(self):
long_name = "a" * 200
result = sanitize_filename(long_name)
assert len(result) <= 120
def test_extension_preservation(self):
result = sanitize_filename("my/file:name.txt")
assert result.endswith(".txt")
class TestSlugify:
def test_basic_slug(self):
assert slugify("Hello World") == "hello-world"
assert slugify("Test!!!") == "test"
def test_special_chars_removed(self):
assert slugify("hello@world#123") == "helloworld123"
def test_multiple_dashes_collapsed(self):
assert slugify("hello---world") == "hello-world"
class TestGenerateVersionToken:
def test_sd_hash_priority(self):
token = generate_version_token(
sd_hash="abc123", stream_hash="def456", txid="ghi789", nout=0
)
assert token.startswith("sd_")
def test_stream_hash_fallback(self):
token = generate_version_token(stream_hash="def456", txid="ghi789", nout=0)
assert token.startswith("stream_")
def test_txid_fallback(self):
token = generate_version_token(txid="ghi789", nout=5)
assert token.startswith("tx_")
def test_metadata_fallback(self):
token = generate_version_token(metadata={"name": "test", "id": 123})
assert token.startswith("meta_")
class TestExpandPath:
def test_home_expansion(self):
path = expand_path("~/test")
assert not str(path).startswith("~")
assert path.is_absolute()
class TestIsDownloadableClaim:
def test_stream_claim_with_source(self):
claim = {"value_type": "stream", "value": {"source": {"sd_hash": "abc123"}}}
assert is_downloadable_claim(claim) is True
def test_stream_claim_with_stream_hash(self):
claim = {"value_type": "stream", "value": {}, "stream_hash": "abc123"}
assert is_downloadable_claim(claim) is True
def test_non_stream_claim(self):
claim = {"value_type": "channel"}
assert is_downloadable_claim(claim) is False
def test_stream_without_source(self):
claim = {"value_type": "stream", "value": {}}
assert is_downloadable_claim(claim) is False
class TestNormalizeOdyseeUrl:
def test_https_odysee(self):
result = normalize_odysee_url("https://odysee.com/@Channel:1")
assert result == "lbry://@Channel:1"
def test_http_odysee(self):
result = normalize_odysee_url("http://odysee.com/@Channel:1")
assert result == "lbry://@Channel:1"
def test_already_lbry(self):
result = normalize_odysee_url("lbry://@Channel:1")
assert result == "lbry://@Channel:1"
def test_short_form(self):
result = normalize_odysee_url("@Channel:1")
assert result == "lbry://@Channel:1"
if __name__ == "__main__":
pytest.main([__file__, "-v"])