import sys import types from enum import Enum from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock, patch def _load_downloader_base(): repo_root = Path(__file__).resolve().parents[1] app_module = types.ModuleType("app") app_module.__path__ = [] helper_module = types.ModuleType("app.helper") helper_module.__path__ = [] service_module = types.ModuleType("app.helper.service") schemas_module = types.ModuleType("app.schemas") schema_types_module = types.ModuleType("app.schemas.types") utils_module = types.ModuleType("app.utils") utils_module.__path__ = [] mixins_module = types.ModuleType("app.utils.mixins") class StorageSchema(Enum): Local = "local" Rclone = "rclone" class _ConfigReloadMixin: pass class _ServiceConfigHelper: @staticmethod def get_downloader_configs(): return [] @staticmethod def get_notification_configs(): return [] @staticmethod def get_mediaserver_configs(): return [] schema_types_module.StorageSchema = StorageSchema schema_types_module.ModuleType = Enum("ModuleType", {"Downloader": "downloader"}) schema_types_module.DownloaderType = Enum("DownloaderType", {"Qbittorrent": "Qbittorrent"}) schema_types_module.MediaServerType = Enum("MediaServerType", {"Emby": "Emby"}) schema_types_module.MessageChannel = Enum("MessageChannel", {"Telegram": "telegram"}) schema_types_module.OtherModulesType = Enum("OtherModulesType", {"Subtitle": "subtitle"}) schema_types_module.SystemConfigKey = Enum( "SystemConfigKey", { "Downloaders": "Downloaders", "Notifications": "Notifications", "MediaServers": "MediaServers", }, ) service_module.ServiceConfigHelper = _ServiceConfigHelper mixins_module.ConfigReloadMixin = _ConfigReloadMixin schemas_module.Notification = object schemas_module.NotificationConf = object schemas_module.MediaServerConf = object schemas_module.DownloaderConf = object app_module.helper = helper_module app_module.schemas = schemas_module app_module.utils = utils_module helper_module.service = service_module schemas_module.types = schema_types_module utils_module.mixins = mixins_module stub_modules = { "app": app_module, "app.helper": helper_module, "app.helper.service": service_module, "app.schemas": schemas_module, "app.schemas.types": schema_types_module, "app.utils": utils_module, "app.utils.mixins": mixins_module, } module_path = repo_root / "app" / "modules" / "__init__.py" module_spec = __import__("importlib.util").util.spec_from_file_location( "_test_downloader_base_module", module_path, ) module = __import__("importlib.util").util.module_from_spec(module_spec) assert module_spec and module_spec.loader with patch.dict(sys.modules, stub_modules): module_spec.loader.exec_module(module) return module._DownloaderBase def _load_transmission_module(): repo_root = Path(__file__).resolve().parents[1] app_module = types.ModuleType("app") app_module.__path__ = [] core_module = types.ModuleType("app.core") core_module.__path__ = [] cache_module = types.ModuleType("app.core.cache") modules_module = types.ModuleType("app.modules") modules_module.__path__ = [] transmission_package_module = types.ModuleType("app.modules.transmission") transmission_package_module.__path__ = [] transmission_client_module = types.ModuleType("app.modules.transmission.transmission") schemas_module = types.ModuleType("app.schemas") schema_types_module = types.ModuleType("app.schemas.types") config_module = types.ModuleType("app.core.config") metainfo_module = types.ModuleType("app.core.metainfo") log_module = types.ModuleType("app.log") utils_module = types.ModuleType("app.utils") utils_module.__path__ = [] string_module = types.ModuleType("app.utils.string") transmission_rpc_module = types.ModuleType("transmission_rpc") torrentool_module = types.ModuleType("torrentool") torrentool_module.__path__ = [] torrentool_torrent_module = types.ModuleType("torrentool.torrent") class _ModuleBase: pass class _DownloaderBase: def __class_getitem__(cls, _item): return cls class _TransferTorrent: def __init__(self, **kwargs): self.__dict__.update(kwargs) class _DownloadingTorrent: def __init__(self, **kwargs): self.__dict__.update(kwargs) class _DownloaderTorrent: def __init__(self, **kwargs): self.__dict__.update(kwargs) class TorrentStatus(Enum): TRANSFER = "transfer" DOWNLOADING = "downloading" class TorrentQueryStatus(Enum): ALL = "all" TRANSFER = "transfer" DOWNLOADING = "downloading" COMPLETED = "completed" PAUSED = "paused" class DownloadTaskState(Enum): DOWNLOADING = "downloading" PAUSED = "paused" COMPLETED = "completed" class _Logger: def debug(self, *_args, **_kwargs): pass def info(self, *_args, **_kwargs): pass def error(self, *_args, **_kwargs): pass class _MetaInfo: def __init__(self, name): self.name = name self.year = None self.season_episode = "" self.episode_list = [] class _StringUtils: @staticmethod def is_magnet_link(value): return isinstance(value, str) and value.startswith("magnet:") @staticmethod def generate_random_str(_length): return "tmp-tag-01" @staticmethod def str_filesize(value): return str(value) @staticmethod def str_secends(value): return str(value) class _FileCache: def get(self, *_args, **_kwargs): return None transmission_client_module.Transmission = object cache_module.FileCache = _FileCache schemas_module.TransferTorrent = _TransferTorrent schemas_module.DownloadingTorrent = _DownloadingTorrent schemas_module.DownloaderTorrent = _DownloaderTorrent schemas_module.DownloaderInfo = object schema_types_module.TorrentStatus = TorrentStatus schema_types_module.TorrentQueryStatus = TorrentQueryStatus schema_types_module.DownloadTaskState = DownloadTaskState schema_types_module.ModuleType = Enum("ModuleType", {"Downloader": "downloader"}) schema_types_module.DownloaderType = Enum( "DownloaderType", {"Transmission": "Transmission"} ) config_module.settings = SimpleNamespace(TORRENT_TAG="moviepilot-tag") metainfo_module.MetaInfo = _MetaInfo log_module.logger = _Logger() modules_module._ModuleBase = _ModuleBase modules_module._DownloaderBase = _DownloaderBase string_module.StringUtils = _StringUtils transmission_rpc_module.File = object torrentool_torrent_module.Torrent = SimpleNamespace( from_string=lambda _content: SimpleNamespace(name="test", total_size=1) ) app_module.core = core_module app_module.modules = modules_module app_module.schemas = schemas_module app_module.utils = utils_module core_module.cache = cache_module core_module.config = config_module core_module.metainfo = metainfo_module modules_module.transmission = transmission_package_module transmission_package_module.transmission = transmission_client_module schemas_module.types = schema_types_module utils_module.string = string_module torrentool_module.torrent = torrentool_torrent_module stub_modules = { "app": app_module, "app.core": core_module, "app.core.cache": cache_module, "app.core.config": config_module, "app.core.metainfo": metainfo_module, "app.log": log_module, "app.modules": modules_module, "app.modules.transmission": transmission_package_module, "app.modules.transmission.transmission": transmission_client_module, "app.schemas": schemas_module, "app.schemas.types": schema_types_module, "app.utils": utils_module, "app.utils.string": string_module, "transmission_rpc": transmission_rpc_module, "torrentool": torrentool_module, "torrentool.torrent": torrentool_torrent_module, } module_path = repo_root / "app" / "modules" / "transmission" / "__init__.py" module_spec = __import__("importlib.util").util.spec_from_file_location( "_test_transmission_module", module_path, ) module = __import__("importlib.util").util.module_from_spec(module_spec) assert module_spec and module_spec.loader with patch.dict(sys.modules, stub_modules): module_spec.loader.exec_module(module) return module.TransmissionModule, TorrentStatus DownloaderBase = _load_downloader_base() TransmissionModule, TransmissionTorrentStatus = _load_transmission_module() def _build_base(path_mapping): downloader = DownloaderBase.__new__(DownloaderBase) downloader.get_config = MagicMock( return_value=SimpleNamespace(path_mapping=path_mapping) ) return downloader def _build_transmission_module(server): module = TransmissionModule.__new__(TransmissionModule) module.get_instances = MagicMock(return_value={"tr": server}) module.get_instance = MagicMock(return_value=server) module.normalize_return_path = MagicMock( side_effect=lambda path, _downloader: str(path).replace( "/mnt/raid5/home_lt999lt", "/media", 1 ) ) return module def test_normalize_path_maps_moviepilot_path_to_downloader_path(): """MoviePilot 访问路径应转换为下载器容器内路径。""" downloader = _build_base([("/media", "/mnt/raid5/home_lt999lt")]) result = downloader.normalize_path(Path("/media/video/downloads/movie"), "tr") assert result == "/mnt/raid5/home_lt999lt/video/downloads/movie" def test_normalize_return_path_maps_downloader_path_back_to_moviepilot_path(): """下载器容器内路径应转换回 MoviePilot 可访问路径。""" downloader = _build_base([("/media", "/mnt/raid5/home_lt999lt")]) result = downloader.normalize_return_path( Path("/mnt/raid5/home_lt999lt/video/downloads/TV/Show.mkv"), "tr" ) assert result == "/media/video/downloads/TV/Show.mkv" def test_path_mapping_matches_complete_path_segment_only(): """路径映射只应命中完整路径段,避免误伤相似前缀。""" downloader = _build_base([("/media", "/mnt/media")]) result = downloader.normalize_return_path(Path("/mnt/media2/Show.mkv"), "tr") assert result == "/mnt/media2/Show.mkv" def test_blank_path_mapping_entry_is_ignored(): """空路径映射项应被忽略,继续使用后续有效配置。""" downloader = _build_base( [("", "/downloads"), ("/media2", ""), ("/media", "/mnt/media")] ) result = downloader.normalize_return_path(Path("/mnt/media/Show.mkv"), "tr") assert result == "/media/Show.mkv" def test_normalize_path_strips_storage_prefix_after_mapping(): """带存储类型前缀的路径映射后应返回下载器原生路径。""" downloader = _build_base([("local:/media", "/downloads")]) result = downloader.normalize_path(Path("local:/media/movie"), "qb") assert result == "/downloads/movie" def test_completed_torrents_return_moviepilot_accessible_path(): """Transmission 已完成任务返回的路径字段均应为 MoviePilot 可访问路径。""" server = MagicMock() server.get_completed_torrents.return_value = [ SimpleNamespace( name="Show.S01E01.mkv", download_dir="/mnt/raid5/home_lt999lt/video/downloads/TV", hashString="hash-tr", labels=[], progress=100, status="seeding", ) ] module = _build_transmission_module(server) torrents = module.list_torrents(status=TransmissionTorrentStatus.TRANSFER) assert torrents[0].path == Path("/media/video/downloads/TV/Show.S01E01.mkv") assert torrents[0].save_path == "/media/video/downloads/TV" assert torrents[0].content_path == "/media/video/downloads/TV/Show.S01E01.mkv" def test_hash_lookup_return_moviepilot_accessible_path(): """Transmission 按 Hash 查询时返回的路径字段均应完成路径映射。""" server = MagicMock() server.get_torrents.return_value = ( [ SimpleNamespace( name="Movie", download_dir="/mnt/raid5/home_lt999lt/video/downloads/movie", hashString="hash-tr", total_size=1024, labels=[], progress=100, status="seeding", ) ], False, ) module = _build_transmission_module(server) torrents = module.list_torrents(hashs=["hash-tr"], downloader="tr") assert torrents[0].path == Path("/media/video/downloads/movie/Movie") assert torrents[0].save_path == "/media/video/downloads/movie" assert torrents[0].content_path == "/media/video/downloads/movie/Movie" def test_list_torrents_ignores_missing_transmission_limit_fields(): """Transmission 任务缺少做种限制字段时不应中断列表查询。""" class _TorrentWithMissingLimitFields: """ 模拟 transmission-rpc 属性访问缺失字段时抛出 KeyError 的任务对象。 """ name = "Movie" download_dir = "/mnt/raid5/home_lt999lt/video/downloads/movie" hashString = "hash-missing-limit" total_size = 1024 labels = [] progress = 100 status = "seeding" @property def seed_ratio_limit(self): """ 模拟 seedRatioLimit 原始字段缺失。 """ raise KeyError("seedRatioLimit") @property def seed_idle_limit(self): """ 模拟 seedIdleLimit 原始字段缺失。 """ raise KeyError("seedIdleLimit") server = MagicMock() server.get_torrents.return_value = ( [_TorrentWithMissingLimitFields()], False, ) module = _build_transmission_module(server) torrents = module.list_torrents() assert torrents[0].hash == "hash-missing-limit" assert torrents[0].ratio_limit is None assert torrents[0].seeding_time_limit is None def test_all_torrents_include_completed_and_downloading_states(): """Transmission 默认列表应同时包含已完成和下载中的任务状态。""" server = MagicMock() server.get_torrents.return_value = ( [ SimpleNamespace( name="Completed", download_dir="/mnt/raid5/home_lt999lt/video/downloads/movie", hashString="hash-completed", total_size=1024, labels=[], progress=100, status="seed_pending", ), SimpleNamespace( name="Downloading", download_dir="/mnt/raid5/home_lt999lt/video/downloads/movie", hashString="hash-downloading", total_size=2048, labels=[], progress=50, status="downloading", rate_download=1024, rate_upload=0, left_until_done=1024, ), ], False, ) module = _build_transmission_module(server) torrents = module.list_torrents() assert ["completed", "downloading"] == [torrent.state for torrent in torrents] assert ["hash-completed", "hash-downloading"] == [ torrent.hash for torrent in torrents ] server.get_torrents.assert_called_once_with(tags="moviepilot-tag") def test_include_all_tags_removes_builtin_tag_filter(): """查询全部标签任务时不应附加 MoviePilot 内置标签过滤。""" server = MagicMock() server.get_torrents.return_value = ( [ SimpleNamespace( name="External", download_dir="/mnt/raid5/home_lt999lt/video/downloads/movie", hashString="hash-external", total_size=1024, labels=["external"], progress=100, status="seeding", ) ], False, ) module = _build_transmission_module(server) torrents = module.list_torrents(include_all_tags=True) assert ["hash-external"] == [torrent.hash for torrent in torrents] server.get_torrents.assert_called_once_with(tags=None)