feat: Implement TorrentDownloader class with libtorrent and webtorrent CLI support

- Added TorrentDownloader class for robust torrent downloading.
- Integrated libtorrent for torrent management when available.
- Implemented fallback to webtorrent CLI for downloading torrents.
- Added methods for downloading via libtorrent and webtorrent CLI.
- Included progress tracking and callback functionality.
- Updated pyproject.toml and uv.lock to include libtorrent as a dependency.
- Created unit tests for TorrentDownloader and legacy function for backward compatibility.
This commit is contained in:
Benexl
2025-07-24 23:37:00 +03:00
parent 4bbfe221f2
commit 5246a2fc4b
4 changed files with 599 additions and 8 deletions

View File

@@ -1,15 +1,355 @@
import logging
import shutil
import subprocess
import tempfile
import time
from pathlib import Path
from typing import Optional, Dict, Any, Callable, Union
from urllib.parse import urlparse
from ..exceptions import FastAnimeError
from ..exceptions import FastAnimeError, DependencyNotFoundError
try:
import libtorrent as lt
LIBTORRENT_AVAILABLE = True
except ImportError:
LIBTORRENT_AVAILABLE = False
lt = None # type: ignore
logger = logging.getLogger(__name__)
class TorrentDownloadError(FastAnimeError):
"""Raised when torrent download fails."""
pass
class TorrentDownloader:
"""
A robust torrent downloader that uses libtorrent when available,
with fallback to webtorrent CLI.
"""
def __init__(
self,
download_path: Path,
max_upload_rate: int = -1, # -1 means unlimited
max_download_rate: int = -1, # -1 means unlimited
max_connections: int = 200,
listen_port: int = 6881,
progress_callback: Optional[Callable[[Dict[str, Any]], None]] = None
):
"""
Initialize the torrent downloader.
Args:
download_path: Directory to download torrents to
max_upload_rate: Maximum upload rate in KB/s (-1 for unlimited)
max_download_rate: Maximum download rate in KB/s (-1 for unlimited)
max_connections: Maximum number of connections
listen_port: Port to listen on for incoming connections
progress_callback: Optional callback function for download progress updates
"""
self.download_path = Path(download_path)
self.download_path.mkdir(parents=True, exist_ok=True)
self.max_upload_rate = max_upload_rate
self.max_download_rate = max_download_rate
self.max_connections = max_connections
self.listen_port = listen_port
self.progress_callback = progress_callback
self.session: Optional[Any] = None
def _setup_libtorrent_session(self) -> Any:
"""Setup and configure libtorrent session."""
if not LIBTORRENT_AVAILABLE or lt is None:
raise DependencyNotFoundError("libtorrent is not available")
session = lt.session() # type: ignore
# Configure session settings
settings = {
'user_agent': 'FastAnime/1.0',
'listen_interfaces': f'0.0.0.0:{self.listen_port}',
'enable_outgoing_utp': True,
'enable_incoming_utp': True,
'enable_outgoing_tcp': True,
'enable_incoming_tcp': True,
'connections_limit': self.max_connections,
'dht_bootstrap_nodes': 'dht.transmissionbt.com:6881',
}
if self.max_upload_rate > 0:
settings['upload_rate_limit'] = self.max_upload_rate * 1024
if self.max_download_rate > 0:
settings['download_rate_limit'] = self.max_download_rate * 1024
session.apply_settings(settings)
# Start DHT
session.start_dht()
# Add trackers
session.add_dht_router('router.bittorrent.com', 6881)
session.add_dht_router('router.utorrent.com', 6881)
logger.info("Libtorrent session configured successfully")
return session
def _get_torrent_info(self, torrent_source: str) -> Any:
"""Get torrent info from magnet link or torrent file."""
if not LIBTORRENT_AVAILABLE or lt is None:
raise DependencyNotFoundError("libtorrent is not available")
if torrent_source.startswith('magnet:'):
# Parse magnet link
return lt.parse_magnet_uri(torrent_source) # type: ignore
elif torrent_source.startswith(('http://', 'https://')):
# Download torrent file
import urllib.request
with tempfile.NamedTemporaryFile(suffix='.torrent', delete=False) as tmp_file:
urllib.request.urlretrieve(torrent_source, tmp_file.name)
torrent_info = lt.torrent_info(tmp_file.name) # type: ignore
Path(tmp_file.name).unlink() # Clean up temp file
return {'ti': torrent_info}
else:
# Local torrent file
torrent_path = Path(torrent_source)
if not torrent_path.exists():
raise TorrentDownloadError(f"Torrent file not found: {torrent_source}")
return {'ti': lt.torrent_info(str(torrent_path))} # type: ignore
def download_with_libtorrent(
self,
torrent_source: str,
timeout: int = 3600,
sequential: bool = False
) -> Path:
"""
Download torrent using libtorrent.
Args:
torrent_source: Magnet link, torrent file URL, or local torrent file path
timeout: Download timeout in seconds
sequential: Whether to download files sequentially
Returns:
Path to the downloaded content
Raises:
TorrentDownloadError: If download fails
DependencyNotFoundError: If libtorrent is not available
"""
if not LIBTORRENT_AVAILABLE or lt is None:
raise DependencyNotFoundError(
"libtorrent is not available. Please install python-libtorrent: "
"pip install python-libtorrent"
)
try:
self.session = self._setup_libtorrent_session()
torrent_params = self._get_torrent_info(torrent_source)
# Set save path
torrent_params['save_path'] = str(self.download_path)
if sequential and lt is not None:
torrent_params['flags'] = lt.torrent_flags.sequential_download # type: ignore
# Add torrent to session
if self.session is None:
raise TorrentDownloadError("Session is not initialized")
handle = self.session.add_torrent(torrent_params)
logger.info(f"Starting torrent download: {handle.name()}")
# Monitor download progress
start_time = time.time()
last_log_time = start_time
while not handle.is_seed():
current_time = time.time()
# Check timeout
if current_time - start_time > timeout:
raise TorrentDownloadError(f"Download timeout after {timeout} seconds")
status = handle.status()
# Prepare progress info
progress_info = {
'name': handle.name(),
'progress': status.progress * 100,
'download_rate': status.download_rate / 1024, # KB/s
'upload_rate': status.upload_rate / 1024, # KB/s
'num_peers': status.num_peers,
'total_size': status.total_wanted,
'downloaded': status.total_wanted_done,
'state': str(status.state),
}
# Call progress callback if provided
if self.progress_callback:
self.progress_callback(progress_info)
# Log progress periodically (every 10 seconds)
if current_time - last_log_time >= 10:
logger.info(
f"Download progress: {progress_info['progress']:.1f}% "
f"({progress_info['download_rate']:.1f} KB/s) "
f"- {progress_info['num_peers']} peers"
)
last_log_time = current_time
# Check for errors
if status.error:
raise TorrentDownloadError(f"Torrent error: {status.error}")
time.sleep(1)
# Download completed
download_path = self.download_path / handle.name()
logger.info(f"Torrent download completed: {download_path}")
# Remove torrent from session
if self.session is not None:
self.session.remove_torrent(handle)
return download_path
except Exception as e:
if isinstance(e, (TorrentDownloadError, DependencyNotFoundError)):
raise
raise TorrentDownloadError(f"Failed to download torrent: {str(e)}") from e
finally:
if self.session:
# Clean up session
self.session = None
def download_with_webtorrent_cli(self, torrent_source: str) -> Path:
"""
Download torrent using webtorrent CLI as fallback.
Args:
torrent_source: Magnet link, torrent file URL, or local torrent file path
Returns:
Path to the downloaded content
Raises:
TorrentDownloadError: If download fails
DependencyNotFoundError: If webtorrent CLI is not available
"""
webtorrent_cli = shutil.which("webtorrent")
if not webtorrent_cli:
raise DependencyNotFoundError(
"webtorrent CLI is not available. Please install it: npm install -g webtorrent-cli"
)
try:
cmd = [webtorrent_cli, "download", torrent_source, "--out", str(self.download_path)]
logger.info(f"Running webtorrent command: {' '.join(cmd)}")
result = subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=3600)
# Try to determine the download path from the output
# This is a best-effort approach since webtorrent output format may vary
output_lines = result.stdout.split('\n')
for line in output_lines:
if 'Downloaded' in line and 'to' in line:
# Extract path from output
parts = line.split('to')
if len(parts) > 1:
path_str = parts[-1].strip().strip('"\'') # Remove quotes
download_path = Path(path_str)
if download_path.exists():
logger.info(f"Successfully downloaded to: {download_path}")
return download_path
# If we can't parse the output, scan the download directory for new files
logger.warning("Could not parse webtorrent output, scanning download directory")
download_candidates = list(self.download_path.iterdir())
if download_candidates:
# Return the most recently modified item
newest_path = max(download_candidates, key=lambda p: p.stat().st_mtime)
logger.info(f"Found downloaded content: {newest_path}")
return newest_path
# Fallback: return the download directory
logger.warning(f"No specific download found, returning download directory: {self.download_path}")
return self.download_path
except subprocess.CalledProcessError as e:
error_msg = e.stderr or e.stdout or "Unknown error"
raise TorrentDownloadError(
f"webtorrent CLI failed (exit code {e.returncode}): {error_msg}"
) from e
except subprocess.TimeoutExpired as e:
raise TorrentDownloadError(
f"webtorrent CLI timeout after {e.timeout} seconds"
) from e
except Exception as e:
raise TorrentDownloadError(f"Failed to download with webtorrent: {str(e)}") from e
def download(
self,
torrent_source: str,
prefer_libtorrent: bool = True,
**kwargs
) -> Path:
"""
Download torrent using the best available method.
Args:
torrent_source: Magnet link, torrent file URL, or local torrent file path
prefer_libtorrent: Whether to prefer libtorrent over webtorrent CLI
**kwargs: Additional arguments passed to the download method
Returns:
Path to the downloaded content
Raises:
TorrentDownloadError: If all download methods fail
"""
methods = []
if prefer_libtorrent and LIBTORRENT_AVAILABLE:
methods.extend([
('libtorrent', self.download_with_libtorrent),
('webtorrent-cli', self.download_with_webtorrent_cli)
])
else:
methods.extend([
('webtorrent-cli', self.download_with_webtorrent_cli),
('libtorrent', self.download_with_libtorrent)
])
last_exception = None
for method_name, method_func in methods:
try:
logger.info(f"Attempting download with {method_name}")
if method_name == 'libtorrent':
return method_func(torrent_source, **kwargs)
else:
return method_func(torrent_source)
except DependencyNotFoundError as e:
logger.warning(f"{method_name} not available: {e}")
last_exception = e
continue
except Exception as e:
logger.error(f"{method_name} failed: {e}")
last_exception = e
continue
# All methods failed
raise TorrentDownloadError(
f"All torrent download methods failed. Last error: {last_exception}"
) from last_exception
def download_torrent_with_webtorrent_cli(path: Path, url: str) -> Path:
"""Download torrent using webtorrent CLI and return the download path."""
WEBTORRENT_CLI = shutil.which("webtorrent")
if not WEBTORRENT_CLI:
raise FastAnimeError("Please install webtorrent cli inorder to download torrents")
cmd = [WEBTORRENT_CLI, "download", url, "--out", str(path.parent)]
subprocess.run(cmd, check=False)
return path
"""
Legacy function for backward compatibility.
Download torrent using webtorrent CLI and return the download path.
"""
downloader = TorrentDownloader(download_path=path.parent)
return downloader.download_with_webtorrent_cli(url)

View File

@@ -10,6 +10,7 @@ dependencies = [
"click>=8.1.7",
"httpx>=0.28.1",
"inquirerpy>=0.3.4",
"libtorrent>=2.0.11",
"lxml>=6.0.0",
"pycryptodome>=3.21.0",
"pydantic>=2.11.7",

View File

@@ -0,0 +1,205 @@
"""
Tests for the TorrentDownloader class.
"""
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock, patch, MagicMock
import pytest
from fastanime.core.downloader.torrents import (
TorrentDownloader,
TorrentDownloadError,
LIBTORRENT_AVAILABLE
)
from fastanime.core.exceptions import DependencyNotFoundError
class TestTorrentDownloader(unittest.TestCase):
"""Test cases for TorrentDownloader class."""
def setUp(self):
"""Set up test fixtures."""
self.temp_dir = Path(tempfile.mkdtemp())
self.downloader = TorrentDownloader(
download_path=self.temp_dir,
max_upload_rate=100,
max_download_rate=200,
max_connections=50
)
def tearDown(self):
"""Clean up test fixtures."""
import shutil
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_init(self):
"""Test TorrentDownloader initialization."""
self.assertEqual(self.downloader.download_path, self.temp_dir)
self.assertEqual(self.downloader.max_upload_rate, 100)
self.assertEqual(self.downloader.max_download_rate, 200)
self.assertEqual(self.downloader.max_connections, 50)
self.assertTrue(self.temp_dir.exists())
def test_init_creates_download_directory(self):
"""Test that download directory is created if it doesn't exist."""
non_existent_dir = self.temp_dir / "new_dir"
self.assertFalse(non_existent_dir.exists())
downloader = TorrentDownloader(download_path=non_existent_dir)
self.assertTrue(non_existent_dir.exists())
@patch('fastanime.core.downloader.torrents.shutil.which')
def test_download_with_webtorrent_cli_not_available(self, mock_which):
"""Test webtorrent CLI fallback when not available."""
mock_which.return_value = None
with self.assertRaises(DependencyNotFoundError) as context:
self.downloader.download_with_webtorrent_cli("magnet:test")
self.assertIn("webtorrent CLI is not available", str(context.exception))
@patch('fastanime.core.downloader.torrents.subprocess.run')
@patch('fastanime.core.downloader.torrents.shutil.which')
def test_download_with_webtorrent_cli_success(self, mock_which, mock_run):
"""Test successful webtorrent CLI download."""
mock_which.return_value = "/usr/bin/webtorrent"
mock_result = Mock()
mock_result.stdout = f"Downloaded test-file to {self.temp_dir}/test-file"
mock_run.return_value = mock_result
# Create a dummy file to simulate download
test_file = self.temp_dir / "test-file"
test_file.touch()
result = self.downloader.download_with_webtorrent_cli("magnet:test")
mock_run.assert_called_once()
self.assertEqual(result, test_file)
@patch('fastanime.core.downloader.torrents.subprocess.run')
@patch('fastanime.core.downloader.torrents.shutil.which')
def test_download_with_webtorrent_cli_failure(self, mock_which, mock_run):
"""Test webtorrent CLI download failure."""
mock_which.return_value = "/usr/bin/webtorrent"
mock_run.side_effect = subprocess.CalledProcessError(1, "webtorrent", stderr="Error")
with self.assertRaises(TorrentDownloadError) as context:
self.downloader.download_with_webtorrent_cli("magnet:test")
self.assertIn("webtorrent CLI failed", str(context.exception))
@unittest.skipUnless(LIBTORRENT_AVAILABLE, "libtorrent not available")
def test_setup_libtorrent_session(self):
"""Test libtorrent session setup when available."""
session = self.downloader._setup_libtorrent_session()
self.assertIsNotNone(session)
@unittest.skipIf(LIBTORRENT_AVAILABLE, "libtorrent is available")
def test_setup_libtorrent_session_not_available(self):
"""Test libtorrent session setup when not available."""
with self.assertRaises(DependencyNotFoundError):
self.downloader._setup_libtorrent_session()
@patch('fastanime.core.downloader.torrents.LIBTORRENT_AVAILABLE', False)
def test_download_with_libtorrent_not_available(self):
"""Test libtorrent download when not available."""
with self.assertRaises(DependencyNotFoundError) as context:
self.downloader.download_with_libtorrent("magnet:test")
self.assertIn("libtorrent is not available", str(context.exception))
def test_progress_callback(self):
"""Test progress callback functionality."""
callback_mock = Mock()
downloader = TorrentDownloader(
download_path=self.temp_dir,
progress_callback=callback_mock
)
# The callback should be stored
self.assertEqual(downloader.progress_callback, callback_mock)
@patch.object(TorrentDownloader, 'download_with_webtorrent_cli')
@patch.object(TorrentDownloader, 'download_with_libtorrent')
def test_download_prefers_libtorrent(self, mock_libtorrent, mock_webtorrent):
"""Test that download method prefers libtorrent by default."""
mock_libtorrent.return_value = self.temp_dir / "test"
with patch('fastanime.core.downloader.torrents.LIBTORRENT_AVAILABLE', True):
result = self.downloader.download("magnet:test", prefer_libtorrent=True)
mock_libtorrent.assert_called_once()
mock_webtorrent.assert_not_called()
@patch.object(TorrentDownloader, 'download_with_webtorrent_cli')
@patch.object(TorrentDownloader, 'download_with_libtorrent')
def test_download_fallback_to_webtorrent(self, mock_libtorrent, mock_webtorrent):
"""Test fallback to webtorrent when libtorrent fails."""
mock_libtorrent.side_effect = DependencyNotFoundError("libtorrent not found")
mock_webtorrent.return_value = self.temp_dir / "test"
with patch('fastanime.core.downloader.torrents.LIBTORRENT_AVAILABLE', True):
result = self.downloader.download("magnet:test")
mock_libtorrent.assert_called_once()
mock_webtorrent.assert_called_once()
self.assertEqual(result, self.temp_dir / "test")
@patch.object(TorrentDownloader, 'download_with_webtorrent_cli')
@patch.object(TorrentDownloader, 'download_with_libtorrent')
def test_download_all_methods_fail(self, mock_libtorrent, mock_webtorrent):
"""Test when all download methods fail."""
mock_libtorrent.side_effect = DependencyNotFoundError("libtorrent not found")
mock_webtorrent.side_effect = DependencyNotFoundError("webtorrent not found")
with self.assertRaises(TorrentDownloadError) as context:
self.downloader.download("magnet:test")
self.assertIn("All torrent download methods failed", str(context.exception))
def test_magnet_link_detection(self):
"""Test detection of magnet links."""
magnet_link = "magnet:?xt=urn:btih:test"
http_link = "http://example.com/test.torrent"
file_path = "/path/to/test.torrent"
# These would be tested in integration tests with actual libtorrent
# Here we just verify the method exists and handles different input types
self.assertTrue(magnet_link.startswith('magnet:'))
self.assertTrue(http_link.startswith(('http://', 'https://')))
self.assertFalse(file_path.startswith(('magnet:', 'http://', 'https://')))
class TestLegacyFunction(unittest.TestCase):
"""Test the legacy function for backward compatibility."""
def setUp(self):
"""Set up test fixtures."""
self.temp_dir = Path(tempfile.mkdtemp())
def tearDown(self):
"""Clean up test fixtures."""
import shutil
shutil.rmtree(self.temp_dir, ignore_errors=True)
@patch.object(TorrentDownloader, 'download_with_webtorrent_cli')
def test_legacy_function(self, mock_download):
"""Test the legacy download_torrent_with_webtorrent_cli function."""
from fastanime.core.downloader.torrents import download_torrent_with_webtorrent_cli
test_path = self.temp_dir / "test.mkv"
mock_download.return_value = test_path
result = download_torrent_with_webtorrent_cli(test_path, "magnet:test")
mock_download.assert_called_once_with("magnet:test")
self.assertEqual(result, test_path)
if __name__ == "__main__":
# Add subprocess import for the test
import subprocess
unittest.main()

45
uv.lock generated
View File

@@ -349,6 +349,7 @@ dependencies = [
{ name = "click" },
{ name = "httpx" },
{ name = "inquirerpy" },
{ name = "libtorrent" },
{ name = "lxml" },
{ name = "pycryptodome" },
{ name = "pydantic" },
@@ -393,6 +394,7 @@ requires-dist = [
{ name = "fastapi", extras = ["standard"], marker = "extra == 'standard'", specifier = ">=0.115.0" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "inquirerpy", specifier = ">=0.3.4" },
{ name = "libtorrent", specifier = ">=2.0.11" },
{ name = "lxml", specifier = ">=6.0.0" },
{ name = "mpv", marker = "extra == 'mpv'", specifier = ">=1.0.7" },
{ name = "mpv", marker = "extra == 'standard'", specifier = ">=1.0.7" },
@@ -614,6 +616,49 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "libtorrent"
version = "2.0.11"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/a5/29efe1dba2d0e6eb18ceab7875be8ae186da5dc5c08aeb8cfe9e7713d935/libtorrent-2.0.11-cp310-cp310-macosx_13_0_x86_64.whl", hash = "sha256:32abb3a0488d489f5d74a3c5fb164f0e445a9890af8d8bed5b617f5b145b3cf5", size = 5547129, upload-time = "2025-01-29T11:33:26.897Z" },
{ url = "https://files.pythonhosted.org/packages/bd/82/a0741411bb1ed718a9fd4da7638aceb2d1a8cacadf11b4019239334ae511/libtorrent-2.0.11-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:684d9a1f06d4437735ea7aa1efb744cf9c8be28bf54f033e854516b8640f3eb4", size = 5648784, upload-time = "2025-01-29T11:33:31.508Z" },
{ url = "https://files.pythonhosted.org/packages/8b/62/24927c44925fe530236f1f668a21227321af0f1a19f932324500bb879acf/libtorrent-2.0.11-cp310-cp310-macosx_15_0_arm64.whl", hash = "sha256:6639904a349532bbd785344f83849cd03bfc671f272a6701fc994ec77eaa9230", size = 5647654, upload-time = "2025-01-29T11:33:33.991Z" },
{ url = "https://files.pythonhosted.org/packages/13/31/4fa8ce41a45759e231e64af3c01fbaa390abc843aeaa7b2a05fd41df543f/libtorrent-2.0.11-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:932e519299442e8dcd1b85cec84170b967e05ebc048e6b91b3212de8276b4f51", size = 8259893, upload-time = "2025-01-29T11:33:36.153Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f3/dad03aa0cbfbbe57dcba7ac8ab4bdc5110d1559b1129ff1bf2dd9a715ec9/libtorrent-2.0.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68d739dd51e5c2a0deb6822fdf4434113016fd942a70569813bfee3f1184a15b", size = 8519612, upload-time = "2025-01-29T11:33:39.518Z" },
{ url = "https://files.pythonhosted.org/packages/ad/06/a8adc75b71c179fd81d4b58347c2ce58a296952e358cb9eb0aeb9d2a34ed/libtorrent-2.0.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1a09b0287ea545b3bc31552792db31d3c8999b179b0dbae6090b7986913d97ed", size = 12086901, upload-time = "2025-01-29T11:33:43.519Z" },
{ url = "https://files.pythonhosted.org/packages/ac/1b/8e7ceb19bedc19a6474b52f18ad17643bb84307d3d97e47f10ca2b078df0/libtorrent-2.0.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5f2354c596bd85b212c81daa84ea017b87fbe35aafbf167eb3b73d4be10a8e52", size = 12062955, upload-time = "2025-01-29T11:33:48.01Z" },
{ url = "https://files.pythonhosted.org/packages/58/d1/6074ddb437719d92059e5294266a85c0a996e17bbc7ac0cec8993f204e91/libtorrent-2.0.11-cp310-cp310-win32.whl", hash = "sha256:f29bbf659272c46c14b333c945e38282907c6c1784fd73af370fa475361bff84", size = 1772634, upload-time = "2025-01-29T11:33:51.782Z" },
{ url = "https://files.pythonhosted.org/packages/3a/d1/bf1317d4c604078a23535701ca666cc7df35b2587756965a8ccbf10f63f7/libtorrent-2.0.11-cp310-cp310-win_amd64.whl", hash = "sha256:cf681e540d9c71d580262b64573be67ffe98e85a2fa22cf68cdbc9766878a7ef", size = 1969854, upload-time = "2025-01-29T11:33:54.65Z" },
{ url = "https://files.pythonhosted.org/packages/2a/32/11c1343e3f64859acba2448d1fc857af94b336f7e857e607abf24c191eb3/libtorrent-2.0.11-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:5041f69119a4c0dc2c586a4cfa72b75bd3591561369da97abaa6fab9ed8bb0db", size = 5547062, upload-time = "2025-01-29T11:33:57.4Z" },
{ url = "https://files.pythonhosted.org/packages/a0/e4/e472f2d0e8623399dbfcf0048f68f90760520bb08665c0cadc0a16cda346/libtorrent-2.0.11-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4463ffa568cbfcc348098f15c7cfc8afccdbae2a63d929de9c45a929a0865b56", size = 5648816, upload-time = "2025-01-29T11:34:00.5Z" },
{ url = "https://files.pythonhosted.org/packages/2b/96/15a4c4ebc9a86b7bb4d348c1a7064710adfb5d379419e1879f0a6df3f314/libtorrent-2.0.11-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:93627846dd1567cbd961b23e4314d1661a1c64797338518837a35c5bb702a61c", size = 5647624, upload-time = "2025-01-29T11:34:03.522Z" },
{ url = "https://files.pythonhosted.org/packages/41/83/60d5f9e18be1b4d251e2135c14294ca1893ee3074130542f4b72751b6642/libtorrent-2.0.11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92f5154527171ab48aa13116d6571fb167cde859b3169e8c28ae86989f301fd", size = 8259209, upload-time = "2025-01-29T11:34:07.105Z" },
{ url = "https://files.pythonhosted.org/packages/c3/9f/a6c9cb4eb88a0223de83396383c503fcec8d22ee85aa7452b0b74101219c/libtorrent-2.0.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffc0a821ea9df5ce33efcd9c08f34fcf7302d0eaa772cf12d9245c69fed32aeb", size = 8518820, upload-time = "2025-01-29T11:34:10.64Z" },
{ url = "https://files.pythonhosted.org/packages/e2/63/b688df15c74d86bf609368ee4b8525b8c2654fc7d187bbc477dd2a8329fd/libtorrent-2.0.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ac9ecf07a155fb2c2ac3da6596ca71d4bc3886e17bff1ab7c7594e2b663f99f1", size = 12086823, upload-time = "2025-01-29T11:34:14.616Z" },
{ url = "https://files.pythonhosted.org/packages/d0/5b/e356a04a76b0df140d5c46dbc11484d100d5dc1957cd2d4890d61c3f84aa/libtorrent-2.0.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a61a7b203d38a43db8a895437deef6c9b601fbb3444b972a8b2f4c7dac8ede7b", size = 12062529, upload-time = "2025-01-29T11:34:17.926Z" },
{ url = "https://files.pythonhosted.org/packages/3c/7d/ed8ac3426de0a5e8883c76747bc0cc280fc2ea78d7d75f49d85313cd408c/libtorrent-2.0.11-cp311-cp311-win32.whl", hash = "sha256:285e9e14771a3a0f4a49ad5c8e31f18a123e3a770d1f66b1376f72cc07bace99", size = 1772105, upload-time = "2025-01-29T11:34:20.825Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5b/30713e1a9bf5768cdfcdb6e22187f670cd39a9c3d42cf0e0efcbc8d9545e/libtorrent-2.0.11-cp311-cp311-win_amd64.whl", hash = "sha256:1c6520d43593da16aaac7dd49e5778be59a4f975e2566eb571779c5527433b24", size = 1969476, upload-time = "2025-01-29T11:34:24.691Z" },
{ url = "https://files.pythonhosted.org/packages/31/1b/b0cd5352b15df6e1fe5dcc3d900da3a09f4738b9558f02ea87f98404fe05/libtorrent-2.0.11-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:392f8879911efc7c87f3bc7d6e2cff42be5c17abfc77d766f56083d07c9b6c8b", size = 5555225, upload-time = "2025-01-29T11:34:27.124Z" },
{ url = "https://files.pythonhosted.org/packages/46/72/ccc462046630f99087ddbc690cfe9f1aab597da8093b91a1928c5f47a396/libtorrent-2.0.11-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:e372dd7bf042296365d42b4bdf266f58626066f6ceb62bbd8ac23b376ab26d30", size = 5654166, upload-time = "2025-01-29T11:34:29.393Z" },
{ url = "https://files.pythonhosted.org/packages/57/5a/5329d8dc6ccd45c91272ecb3fc9c7c2b01b683335ebb4dfc204ec934ab4b/libtorrent-2.0.11-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:31e2a990a055cefb296ed52d2998bea406ee4db538df8e919af9c6878f4d4c5b", size = 5653459, upload-time = "2025-01-29T11:34:32.611Z" },
{ url = "https://files.pythonhosted.org/packages/37/99/a4e5711fa26882165e653dd3c661ce1b3542ac9e51c3b660bfd134b43d36/libtorrent-2.0.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fea392e3e0b2f5e84000ec87fd2b3fa11477216875809737ce00c9ba07db4c55", size = 8251384, upload-time = "2025-01-29T11:34:35.699Z" },
{ url = "https://files.pythonhosted.org/packages/58/bd/475332a05b25de3b6d350144d8c5bfe74c23412c8e7f34dff43f8ded2cdb/libtorrent-2.0.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fbf4418471a96158d995c4573697ce2dcf27ca42d0b03c3bf97b0165015bf641", size = 8514680, upload-time = "2025-01-29T11:34:39.211Z" },
{ url = "https://files.pythonhosted.org/packages/dc/24/178cd82b11e0c989d24123cb0512d15164f7447ffb05f6a631818bd00f68/libtorrent-2.0.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:db0bd26b803c21f3d02a455c9521c8aef4418c457b5751493cd464ca336ae11e", size = 12076821, upload-time = "2025-01-29T11:34:42.273Z" },
{ url = "https://files.pythonhosted.org/packages/ae/a0/f8c79175be62c66e486d5ec72b4d318191583e30fa52b530add07562bc39/libtorrent-2.0.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:545bf693844477c42e8d24e0d461c3bec4ab3a71914cb6aea105044994e40a89", size = 12065535, upload-time = "2025-01-29T11:34:46.64Z" },
{ url = "https://files.pythonhosted.org/packages/09/75/cf1988d3fe2f933e858449bc3a55c3fa3edba4292f8d3d15a777e1afb568/libtorrent-2.0.11-cp312-cp312-win32.whl", hash = "sha256:00c626742e03447703bc726bb430a0f3e2f0661ae5a5cd06f5d7cd1b81eb92c2", size = 1772291, upload-time = "2025-01-29T11:34:49.452Z" },
{ url = "https://files.pythonhosted.org/packages/7a/f5/a0e16855d73ea8dc33351a12dfa16dcad12bbacfc2e6632247484fbbed82/libtorrent-2.0.11-cp312-cp312-win_amd64.whl", hash = "sha256:5b8a9a5943c304d1c8093d2a6ec222d81dd84d080861cd5aaaa040e31034417a", size = 1973825, upload-time = "2025-01-29T11:34:54.371Z" },
{ url = "https://files.pythonhosted.org/packages/92/74/b689c070c1649c69b6f70c27cd9f3418fb3cba7532b6b5f857c1689edcdc/libtorrent-2.0.11-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:f4489732bbc44114c7fb1893b71c578052d1aca352852002938b23ac8a6fb5c3", size = 5555119, upload-time = "2025-01-29T11:34:57.644Z" },
{ url = "https://files.pythonhosted.org/packages/c1/b8/24e072c2270fc0193332987f1c3082a1cc8276bc462167b3e45fa0bb9a07/libtorrent-2.0.11-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:758662c88a186c6018e4062d1b1b5e58ed55e17189ad4a7389c87b8b2b5efeb4", size = 5654645, upload-time = "2025-01-29T11:35:00.79Z" },
{ url = "https://files.pythonhosted.org/packages/07/62/5e3bbebfcf19c963f3f960b8a28ad2bf8cad1e56d8dce3ff9387084ef705/libtorrent-2.0.11-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:55aa67cdc8cbf777e41998d5432a3ab01f198c955ec6d2fb14c500b309db3f37", size = 5653493, upload-time = "2025-01-29T11:35:03.139Z" },
{ url = "https://files.pythonhosted.org/packages/5c/85/e067ca5544e79525b80a7a986cb58e28977f274c4f341d1294c0ea35d7c7/libtorrent-2.0.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:474e792c52319cc918a007aa61cb34fb7f9236fa6c9603953e8c0fb3e6d922c7", size = 8251482, upload-time = "2025-01-29T11:35:05.522Z" },
{ url = "https://files.pythonhosted.org/packages/a4/4c/86d834a8bbdd34276e4d87400d105ddab1dd4b8938f2c1229accc03a5b0d/libtorrent-2.0.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e234f665c987262d4102f6d2e099d76b12dfa20bcc77b77891184b075ab84ea8", size = 8514771, upload-time = "2025-01-29T11:35:09.604Z" },
{ url = "https://files.pythonhosted.org/packages/f6/7e/e369802ae56bad9de629d9ad7c719dc4328aecf9aac8f61d33755d20b269/libtorrent-2.0.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8be0e27f40594bfee42e60e7785261834fdaf938267dceee8a08eae08a12a3c2", size = 12076824, upload-time = "2025-01-29T11:35:12.773Z" },
{ url = "https://files.pythonhosted.org/packages/51/c6/b78e0f1515e65ca1a22383124cd867a1c9eee40053e7971064dd937c4802/libtorrent-2.0.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51daa58d0958ac99d88a1749d6848c6023ea899da98e503b12a1a311189d79fd", size = 12065489, upload-time = "2025-01-29T11:35:16.533Z" },
{ url = "https://files.pythonhosted.org/packages/db/4a/cac02c0d907ec37f22131207f69e78a97045d8e8cab6c6b0a29647dbf732/libtorrent-2.0.11-cp313-cp313-win32.whl", hash = "sha256:63fcb829d2b9fed566771426c7b1473a4acb7ccb4957ef07793b28de8468cd3e", size = 1772278, upload-time = "2025-01-29T11:35:19.333Z" },
{ url = "https://files.pythonhosted.org/packages/3d/7b/49ebb7df766398d49d59335481dafd7f4a3e8669a1aee5a6d22f9338b09c/libtorrent-2.0.11-cp313-cp313-win_amd64.whl", hash = "sha256:f72f0075b84aa18d25ee3ab3c39ba5193777d34d016c9a38294c92ffc60f6225", size = 1973890, upload-time = "2025-01-29T11:35:22.157Z" },
]
[[package]]
name = "lxml"
version = "6.0.0"