mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-03-20 11:57:46 +08:00
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:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user