From 5246a2fc4b4a8873a015ff7f5b54849d123b9668 Mon Sep 17 00:00:00 2001 From: Benexl Date: Thu, 24 Jul 2025 23:37:00 +0300 Subject: [PATCH] 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. --- fastanime/core/downloader/torrents.py | 356 +++++++++++++++++++++++++- pyproject.toml | 1 + tests/test_torrent_downloader.py | 205 +++++++++++++++ uv.lock | 45 ++++ 4 files changed, 599 insertions(+), 8 deletions(-) create mode 100644 tests/test_torrent_downloader.py diff --git a/fastanime/core/downloader/torrents.py b/fastanime/core/downloader/torrents.py index fa8a6af..735b378 100644 --- a/fastanime/core/downloader/torrents.py +++ b/fastanime/core/downloader/torrents.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index e34ca47..3602660 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/test_torrent_downloader.py b/tests/test_torrent_downloader.py new file mode 100644 index 0000000..9da8fac --- /dev/null +++ b/tests/test_torrent_downloader.py @@ -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() diff --git a/uv.lock b/uv.lock index ad750fd..5660a4a 100644 --- a/uv.lock +++ b/uv.lock @@ -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"