fix(network): improve torrent fetch reliability and error handling

- Add browser-like headers and full Chrome User-Agent to avoid Cloudflare blocking
- Use appropriate Accept headers for torrent files (application/x-bittorrent)
- Increase timeouts (connect: 5s→10s, read: 10s→30s) for slow responses
- Filter out None values from failed torrent fetches before sending to qBittorrent
- Add try-catch around add_torrents to prevent request crashes
- Improve logging from DEBUG to WARNING level for better visibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Estrella Pan
2026-01-25 14:11:11 +01:00
parent 6ce3f92c74
commit da6e578404
3 changed files with 57 additions and 19 deletions

View File

@@ -31,6 +31,11 @@ class DownloadClient(TorrentPath):
from .client.aria2_downloader import Aria2Downloader
return Aria2Downloader(host, username, password)
elif type == "mock":
from .client.mock_downloader import MockDownloader
logger.info("[Downloader] Using MockDownloader for local development")
return MockDownloader()
else:
logger.error(f"[Downloader] Unsupported downloader type: {type}")
raise Exception(f"Unsupported downloader type: {type}")
@@ -141,6 +146,11 @@ class DownloadClient(TorrentPath):
torrent_file = await asyncio.gather(
*[req.get_content(t.url) for t in torrent]
)
# Filter out None values (failed fetches)
torrent_file = [f for f in torrent_file if f is not None]
if not torrent_file:
logger.warning(f"[Downloader] Failed to fetch torrent files for: {bangumi.official_title}")
return False
torrent_url = None
else:
if "magnet" in torrent.url:
@@ -148,17 +158,24 @@ class DownloadClient(TorrentPath):
torrent_file = None
else:
torrent_file = await req.get_content(torrent.url)
if torrent_file is None:
logger.warning(f"[Downloader] Failed to fetch torrent file for: {bangumi.official_title}")
return False
torrent_url = None
if await self.client.add_torrents(
torrent_urls=torrent_url,
torrent_files=torrent_file,
save_path=bangumi.save_path,
category="Bangumi",
):
logger.debug(f"[Downloader] Add torrent: {bangumi.official_title}")
return True
else:
logger.debug(f"[Downloader] Torrent added before: {bangumi.official_title}")
try:
if await self.client.add_torrents(
torrent_urls=torrent_url,
torrent_files=torrent_file,
save_path=bangumi.save_path,
category="Bangumi",
):
logger.debug(f"[Downloader] Add torrent: {bangumi.official_title}")
return True
else:
logger.debug(f"[Downloader] Torrent added before: {bangumi.official_title}")
return False
except Exception as e:
logger.error(f"[Downloader] Failed to add torrent for {bangumi.official_title}: {e}")
return False
async def move_torrent(self, hashes, location):

View File

@@ -67,6 +67,8 @@ class RequestContent(RequestURL):
req = await self.get_url(_url)
if req:
return req.content
logger.warning(f"[Network] Failed to get content from {_url}")
return None
async def check_connection(self, _url):
return await self.check_url(_url)

View File

@@ -26,7 +26,7 @@ async def get_shared_client() -> httpx.AsyncClient:
return _shared_client
if _shared_client is not None:
await _shared_client.aclose()
timeout = httpx.Timeout(connect=5.0, read=10.0, write=10.0, pool=10.0)
timeout = httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0)
if settings.proxy.enable:
if "http" in settings.proxy.type:
if settings.proxy.username:
@@ -50,31 +50,50 @@ async def get_shared_client() -> httpx.AsyncClient:
class RequestURL:
# More complete User-Agent to avoid Cloudflare blocking
DEFAULT_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
def __init__(self):
self.header = {"user-agent": "Mozilla/5.0", "Accept": "application/xml"}
self.header = {"User-Agent": self.DEFAULT_UA, "Accept": "application/xml"}
self._client: httpx.AsyncClient | None = None
def _get_headers(self, url: str) -> dict:
"""Get appropriate headers based on URL type."""
base_headers = {
"User-Agent": self.DEFAULT_UA,
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive",
}
# For torrent files, use different Accept header
if url.endswith(".torrent") or "/download/" in url:
base_headers["Accept"] = "application/x-bittorrent, application/octet-stream, */*"
else:
base_headers["Accept"] = "application/xml, text/xml, */*"
return base_headers
async def get_url(self, url, retry=3):
try_time = 0
headers = self._get_headers(url)
while True:
try:
req = await self._client.get(url=url, headers=self.header)
req = await self._client.get(url=url, headers=headers)
logger.debug(f"[Network] Successfully connected to {url}. Status: {req.status_code}")
req.raise_for_status()
return req
except httpx.HTTPStatusError:
logger.debug(f"[Network] HTTP error from {url}.")
except httpx.HTTPStatusError as e:
logger.warning(f"[Network] HTTP {e.response.status_code} from {url}")
break
except httpx.RequestError:
logger.debug(
f"[Network] Cannot connect to {url}. Wait for 5 seconds."
except httpx.RequestError as e:
logger.warning(
f"[Network] Request error for {url}: {type(e).__name__}. Retry {try_time + 1}/{retry}"
)
try_time += 1
if try_time >= retry:
break
await asyncio.sleep(5)
except Exception as e:
logger.debug(e)
logger.warning(f"[Network] Unexpected error for {url}: {e}")
break
logger.error(f"[Network] Unable to connect to {url}, Please check your network settings")
return None