fix(downloader): improve client interfaces and renamer reliability

- Aria2: add stub methods for full duck-typing compatibility
- MockDownloader: add verify parameter to rename_file signature
- DownloadClient: raise ConnectionError on auth failure
- Path: fallback bangumi_name from torrent_name when path is flat
- Renamer: remove unused check_pool and dead compare_ep_version
- Pass torrent_name to _path_to_bangumi for better name resolution
- Remove check_pool unit test (feature removed)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Estrella Pan
2026-02-23 11:46:44 +01:00
parent 41298f2f8e
commit 52580d08c8
6 changed files with 137 additions and 77 deletions

View File

@@ -35,7 +35,9 @@ class Aria2Downloader:
return result.get("result")
async def auth(self, retry=3):
self._client = httpx.AsyncClient(timeout=httpx.Timeout(connect=3.1, read=10.0, write=10.0, pool=10.0))
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=3.1, read=10.0, write=10.0, pool=10.0)
)
times = 0
while times < retry:
try:
@@ -57,16 +59,86 @@ class Aria2Downloader:
async def torrents_files(self, torrent_hash: str):
return []
async def add_torrents(self, torrent_urls, torrent_files, save_path, category, tags=None):
async def add_torrents(
self, torrent_urls, torrent_files, save_path, category, tags=None
):
import base64
options = {"dir": save_path}
if torrent_urls:
urls = torrent_urls if isinstance(torrent_urls, list) else [torrent_urls]
for url in urls:
await self._call("addUri", [[url], options])
if torrent_files:
files = torrent_files if isinstance(torrent_files, list) else [torrent_files]
files = (
torrent_files if isinstance(torrent_files, list) else [torrent_files]
)
for f in files:
b64 = base64.b64encode(f).decode()
await self._call("addTorrent", [b64, [], options])
return True
async def check_host(self):
raise NotImplementedError("Aria2 does not support this operation")
async def prefs_init(self, prefs):
raise NotImplementedError("Aria2 does not support this operation")
async def get_app_prefs(self):
raise NotImplementedError("Aria2 does not support this operation")
async def add_category(self, category):
raise NotImplementedError("Aria2 does not support this operation")
async def torrents_info(self, status_filter, category, tag=None):
raise NotImplementedError("Aria2 does not support this operation")
async def get_torrents_by_tag(self, tag: str) -> list[dict]:
raise NotImplementedError("Aria2 does not support this operation")
async def torrents_delete(self, hash, delete_files: bool = True):
raise NotImplementedError("Aria2 does not support this operation")
async def torrents_pause(self, hashes: str):
raise NotImplementedError("Aria2 does not support this operation")
async def torrents_resume(self, hashes: str):
raise NotImplementedError("Aria2 does not support this operation")
async def torrents_rename_file(
self, torrent_hash, old_path, new_path, verify: bool = True
) -> bool:
raise NotImplementedError("Aria2 does not support this operation")
async def rss_add_feed(self, url, item_path):
raise NotImplementedError("Aria2 does not support this operation")
async def rss_remove_item(self, item_path):
raise NotImplementedError("Aria2 does not support this operation")
async def rss_get_feeds(self):
raise NotImplementedError("Aria2 does not support this operation")
async def rss_set_rule(self, rule_name, rule_def):
raise NotImplementedError("Aria2 does not support this operation")
async def move_torrent(self, hashes, new_location):
raise NotImplementedError("Aria2 does not support this operation")
async def get_download_rule(self):
raise NotImplementedError("Aria2 does not support this operation")
async def get_torrent_path(self, _hash):
raise NotImplementedError("Aria2 does not support this operation")
async def set_category(self, _hash, category):
raise NotImplementedError("Aria2 does not support this operation")
async def check_connection(self):
raise NotImplementedError("Aria2 does not support this operation")
async def remove_rule(self, rule_name):
raise NotImplementedError("Aria2 does not support this operation")
async def add_tag(self, _hash, tag):
raise NotImplementedError("Aria2 does not support this operation")

View File

@@ -59,7 +59,10 @@ class MockDownloader:
) -> list[dict]:
"""Return list of torrents matching the filter."""
logger.debug(
"[MockDownloader] torrents_info(filter=%s, category=%s, tag=%s)", status_filter, category, tag
"[MockDownloader] torrents_info(filter=%s, category=%s, tag=%s)",
status_filter,
category,
tag,
)
result = []
for hash_, torrent in self._torrents.items():
@@ -111,7 +114,9 @@ class MockDownloader:
hashes = hash.split("|") if "|" in hash else [hash]
for h in hashes:
self._torrents.pop(h, None)
logger.debug("[MockDownloader] torrents_delete(%s, delete_files=%s)", hash, delete_files)
logger.debug(
"[MockDownloader] torrents_delete(%s, delete_files=%s)", hash, delete_files
)
async def torrents_pause(self, hashes: str):
for h in hashes.split("|"):
@@ -126,7 +131,7 @@ class MockDownloader:
logger.debug("[MockDownloader] torrents_resume(%s)", hashes)
async def torrents_rename_file(
self, torrent_hash: str, old_path: str, new_path: str
self, torrent_hash: str, old_path: str, new_path: str, verify: bool = True
) -> bool:
logger.info(f"[MockDownloader] rename: {old_path} -> {new_path}")
return True

View File

@@ -43,6 +43,8 @@ class DownloadClient(TorrentPath):
async def __aenter__(self):
if not self.authed:
await self.auth()
if not self.authed:
raise ConnectionError("Download client authentication failed")
else:
logger.error("[Downloader] Already authed.")
return self
@@ -237,4 +239,6 @@ class DownloadClient(TorrentPath):
async def add_tag(self, torrent_hash: str, tag: str):
"""Add a tag to a torrent."""
await self.client.add_tag(torrent_hash, tag)
logger.debug("[Downloader] Added tag '%s' to torrent %s...", tag, torrent_hash[:8])
logger.debug(
"[Downloader] Added tag '%s' to torrent %s...", tag, torrent_hash[:8]
)

View File

@@ -35,7 +35,7 @@ class TorrentPath:
return media_list, subtitle_list
@staticmethod
def _path_to_bangumi(save_path: PathLike[str] | str):
def _path_to_bangumi(save_path: PathLike[str] | str, torrent_name: str = ""):
# Split save path and download path
save_parts = Path(save_path).parts
download_parts = Path(settings.downloader.path).parts
@@ -47,6 +47,8 @@ class TorrentPath:
season = int(re.findall(r"\d+", part)[0])
elif part not in download_parts:
bangumi_name = part
if not bangumi_name:
bangumi_name = torrent_name
return bangumi_name, season
@staticmethod

View File

@@ -1,6 +1,5 @@
import asyncio
import logging
import re
import time
from module.conf import settings
@@ -24,7 +23,6 @@ class Renamer(DownloadClient):
def __init__(self):
super().__init__()
self._parser = TitleParser()
self.check_pool = {}
self._offset_cache: dict[str, tuple[int, int]] = {}
@staticmethod
@@ -119,42 +117,43 @@ class Renamer(DownloadClient):
season_offset=season_offset,
)
if media_path != new_path:
if new_path not in self.check_pool.keys():
# Check if this rename was recently attempted but didn't take effect
# (qBittorrent can return 200 but delay actual rename while seeding)
pending_key = (_hash, media_path, new_path)
last_attempt = _pending_renames.get(pending_key)
if (
last_attempt
and (time.time() - last_attempt) < _PENDING_RENAME_COOLDOWN
):
logger.debug(
"[Renamer] Skipping rename (pending cooldown): %s", media_path
)
return None
# Check if this rename was recently attempted but didn't take effect
# (qBittorrent can return 200 but delay actual rename while seeding)
pending_key = (_hash, media_path, new_path)
last_attempt = _pending_renames.get(pending_key)
if (
last_attempt
and (time.time() - last_attempt) < _PENDING_RENAME_COOLDOWN
):
logger.debug(
"[Renamer] Skipping rename (pending cooldown): %s", media_path
)
return None
if await self.rename_torrent_file(
_hash=_hash, old_path=media_path, new_path=new_path
if await self.rename_torrent_file(
_hash=_hash, old_path=media_path, new_path=new_path
):
# Rename verified successful, remove from pending cache
_pending_renames.pop(pending_key, None)
# Season comes from folder which already has offset applied
# Only apply episode offset
original_ep = int(ep.episode)
adjusted_episode = original_ep + episode_offset
if adjusted_episode < 0 or (
adjusted_episode == 0 and original_ep > 0
):
# Rename verified successful, remove from pending cache
_pending_renames.pop(pending_key, None)
# Season comes from folder which already has offset applied
# Only apply episode offset
original_ep = int(ep.episode)
adjusted_episode = original_ep + episode_offset
if adjusted_episode < 0 or (adjusted_episode == 0 and original_ep > 0):
adjusted_episode = original_ep
return Notification(
official_title=bangumi_name,
season=ep.season,
episode=adjusted_episode,
)
else:
# Rename API returned success but file wasn't actually renamed
# Add to pending cache to avoid spamming
_pending_renames[pending_key] = time.time()
# Periodic cleanup of expired entries (at most once per minute)
self._cleanup_pending_cache()
adjusted_episode = original_ep
return Notification(
official_title=bangumi_name,
season=ep.season,
episode=adjusted_episode,
)
else:
# Rename API returned success but file wasn't actually renamed
# Add to pending cache to avoid spamming
_pending_renames[pending_key] = time.time()
# Periodic cleanup of expired entries (at most once per minute)
self._cleanup_pending_cache()
else:
logger.warning(f"[Renamer] {media_path} parse failed")
if settings.bangumi_manage.remove_bad_torrent:
@@ -384,7 +383,9 @@ class Renamer(DownloadClient):
bangumi = db.bangumi.search_id(torrent_record.bangumi_id)
if bangumi and not bangumi.deleted:
logger.debug(
"[Renamer] Found offsets via qb_hash: ep=%s, season=%s", bangumi.episode_offset, bangumi.season_offset
"[Renamer] Found offsets via qb_hash: ep=%s, season=%s",
bangumi.episode_offset,
bangumi.season_offset,
)
return bangumi.episode_offset, bangumi.season_offset
@@ -394,7 +395,10 @@ class Renamer(DownloadClient):
bangumi = db.bangumi.search_id(bangumi_id)
if bangumi and not bangumi.deleted:
logger.debug(
"[Renamer] Found offsets via tag ab:%s: ep=%s, season=%s", bangumi_id, bangumi.episode_offset, bangumi.season_offset
"[Renamer] Found offsets via tag ab:%s: ep=%s, season=%s",
bangumi_id,
bangumi.episode_offset,
bangumi.season_offset,
)
return bangumi.episode_offset, bangumi.season_offset
@@ -445,7 +449,7 @@ class Renamer(DownloadClient):
torrent_name = info["name"]
save_path = info["save_path"]
media_list, subtitle_list = self.check_files(files)
bangumi_name, season = self._path_to_bangumi(save_path)
bangumi_name, season = self._path_to_bangumi(save_path, torrent_name)
# Use pre-fetched offsets
episode_offset, season_offset = offset_map.get(torrent_hash, (0, 0))
kwargs = {
@@ -476,9 +480,3 @@ class Renamer(DownloadClient):
logger.warning(f"[Renamer] {torrent_name} has no media file")
logger.debug("[Renamer] Rename process finished.")
return renamed_info
def compare_ep_version(self, torrent_name: str, torrent_hash: str):
if re.search(r"v\d.", torrent_name):
pass
else:
self.delete_torrent(hashes=torrent_hash)

View File

@@ -1,11 +1,11 @@
"""Tests for Renamer: gen_path, rename_file, rename_collection, rename flow."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from module.models import EpisodeFile, Notification, SubtitleFile
from module.manager.renamer import Renamer
from module.models import EpisodeFile, Notification, SubtitleFile
# ---------------------------------------------------------------------------
# gen_path
@@ -219,27 +219,6 @@ class TestRenameFile:
assert result is None
renamer.client.torrents_rename_file.assert_not_called()
async def test_duplicate_in_check_pool_skipped(self, renamer):
"""When new_path is already in check_pool, skip rename."""
ep = EpisodeFile(
media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv"
)
# Pre-populate check_pool with the expected new path
renamer.check_pool["My Anime S01E05.mkv"] = True
with patch.object(renamer._parser, "torrent_parser", return_value=ep):
result = await renamer.rename_file(
torrent_name="test",
media_path="old.mkv",
bangumi_name="My Anime",
method="pn",
season=1,
_hash="hash123",
)
assert result is None
renamer.client.torrents_rename_file.assert_not_called()
# ---------------------------------------------------------------------------
# rename_collection