diff --git a/backend/src/module/downloader/client/aria2_downloader.py b/backend/src/module/downloader/client/aria2_downloader.py index fdd6e3e3..dfa78a5f 100644 --- a/backend/src/module/downloader/client/aria2_downloader.py +++ b/backend/src/module/downloader/client/aria2_downloader.py @@ -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") diff --git a/backend/src/module/downloader/client/mock_downloader.py b/backend/src/module/downloader/client/mock_downloader.py index dd0e895c..117a92c2 100644 --- a/backend/src/module/downloader/client/mock_downloader.py +++ b/backend/src/module/downloader/client/mock_downloader.py @@ -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 diff --git a/backend/src/module/downloader/download_client.py b/backend/src/module/downloader/download_client.py index 93572029..c90ee2bf 100644 --- a/backend/src/module/downloader/download_client.py +++ b/backend/src/module/downloader/download_client.py @@ -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] + ) diff --git a/backend/src/module/downloader/path.py b/backend/src/module/downloader/path.py index a9c41880..6e6caf74 100644 --- a/backend/src/module/downloader/path.py +++ b/backend/src/module/downloader/path.py @@ -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 diff --git a/backend/src/module/manager/renamer.py b/backend/src/module/manager/renamer.py index 72985802..df64a6a2 100644 --- a/backend/src/module/manager/renamer.py +++ b/backend/src/module/manager/renamer.py @@ -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) diff --git a/backend/src/test/test_renamer.py b/backend/src/test/test_renamer.py index 517ac934..260283d1 100644 --- a/backend/src/test/test_renamer.py +++ b/backend/src/test/test_renamer.py @@ -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