diff --git a/app/modules/__init__.py b/app/modules/__init__.py index 97ce7b9c..a82110f3 100644 --- a/app/modules/__init__.py +++ b/app/modules/__init__.py @@ -291,7 +291,38 @@ class _DownloaderBase(ServiceBase[TService, DownloaderConf]): 重置默认配置名称 """ self._default_config_name = None - + + @staticmethod + def __replace_path_prefix(path: Union[Path, str], source: str, target: str) -> Optional[str]: + """ + 按完整路径段替换路径前缀,避免 /media 误匹配 /media2 这类相邻目录。 + """ + if not source or not source.strip() or not target or not target.strip(): + return None + + path_text = Path(path).as_posix() + source_path = Path(source.strip()).as_posix() + target_path = Path(target.strip()).as_posix() + if path_text == source_path: + return target_path + + source_prefix = f"{source_path.rstrip('/')}/" + if path_text.startswith(source_prefix): + suffix = path_text[len(source_prefix):] + return (Path(target_path) / suffix).as_posix() + return None + + @staticmethod + def __strip_storage_prefix(path: str) -> str: + """ + 去掉存储协议前缀 if any,下载器无法识别本地存储协议。 + """ + for s in StorageSchema: + prefix = f"{s.value}:" + if path.startswith(prefix): + return path[len(prefix):] + return path + def normalize_path(self, path: Path, downloader: Optional[str]) -> str: """ 根据下载器配置和路径映射,规范化下载路径 @@ -300,21 +331,33 @@ class _DownloaderBase(ServiceBase[TService, DownloaderConf]): :param downloader: 下载器名称 :return: 规范化后发送给下载器的路径 """ - dir = path.as_posix() + normalized_path = path.as_posix() conf = self.get_config(downloader) if conf and conf.path_mapping: for (storage_path, download_path) in conf.path_mapping: - storage_path = Path(storage_path.strip()).as_posix() - download_path = Path(download_path.strip()).as_posix() - if dir.startswith(storage_path): - dir = dir.replace(storage_path, download_path, 1) + mapped_path = self.__replace_path_prefix(normalized_path, storage_path, download_path) + if mapped_path: + normalized_path = mapped_path break - # 去掉存储协议前缀 if any, 下载器无法识别 - for s in StorageSchema: - prefix = f"{s.value}:" - if dir.startswith(prefix): - return dir[len(prefix):] - return dir + return self.__strip_storage_prefix(normalized_path) + + def normalize_return_path(self, path: Path, downloader: Optional[str]) -> str: + """ + 将下载器返回的路径反向映射为 MoviePilot 可访问的存储路径。 + + :param path: 下载器返回的路径 + :param downloader: 下载器名称 + :return: MoviePilot 可访问的路径 + """ + normalized_path = path.as_posix() + conf = self.get_config(downloader) + if conf and conf.path_mapping: + for (storage_path, download_path) in conf.path_mapping: + mapped_path = self.__replace_path_prefix(normalized_path, download_path, storage_path) + if mapped_path: + normalized_path = mapped_path + break + return self.__strip_storage_prefix(normalized_path) class _MediaServerBase(ServiceBase[TService, MediaServerConf]): diff --git a/app/modules/qbittorrent/__init__.py b/app/modules/qbittorrent/__init__.py index d004e6e7..31585d3b 100644 --- a/app/modules/qbittorrent/__init__.py +++ b/app/modules/qbittorrent/__init__.py @@ -268,7 +268,7 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]): ret_torrents.append(TransferTorrent( downloader=name, title=torrent.get('name'), - path=torrent_path, + path=Path(self.normalize_return_path(torrent_path, name)), hash=torrent.get('hash'), size=torrent.get('total_size'), tags=torrent.get('tags'), @@ -296,7 +296,7 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]): ret_torrents.append(TransferTorrent( downloader=name, title=torrent.get('name'), - path=torrent_path, + path=Path(self.normalize_return_path(torrent_path, name)), hash=torrent.get('hash'), tags=torrent.get('tags') )) diff --git a/app/modules/rtorrent/__init__.py b/app/modules/rtorrent/__init__.py index ebed2313..92016735 100644 --- a/app/modules/rtorrent/__init__.py +++ b/app/modules/rtorrent/__init__.py @@ -319,7 +319,7 @@ class RtorrentModule(_ModuleBase, _DownloaderBase[Rtorrent]): TransferTorrent( downloader=name, title=torrent.get("name"), - path=torrent_path, + path=Path(self.normalize_return_path(torrent_path, name)), hash=torrent.get("hash"), size=torrent.get("total_size"), tags=torrent.get("tags"), @@ -355,7 +355,7 @@ class RtorrentModule(_ModuleBase, _DownloaderBase[Rtorrent]): TransferTorrent( downloader=name, title=torrent.get("name"), - path=torrent_path, + path=Path(self.normalize_return_path(torrent_path, name)), hash=torrent.get("hash"), tags=torrent.get("tags"), ) diff --git a/app/modules/transmission/__init__.py b/app/modules/transmission/__init__.py index 0da127e9..4bb84d55 100644 --- a/app/modules/transmission/__init__.py +++ b/app/modules/transmission/__init__.py @@ -249,10 +249,11 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]): torrents, _ = server.get_torrents(ids=hashs, tags=settings.TORRENT_TAG) or [] try: for torrent in torrents: + torrent_path = Path(torrent.download_dir) / torrent.name ret_torrents.append(TransferTorrent( downloader=name, title=torrent.name, - path=Path(torrent.download_dir) / torrent.name, + path=Path(self.normalize_return_path(torrent_path, name)), hash=torrent.hashString, size=torrent.total_size, tags=",".join(torrent.labels or []), @@ -276,10 +277,11 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]): if not path: logger.debug(f"未获取到 {torrent.name} 下载保存路径") continue + torrent_path = Path(torrent.download_dir) / torrent.name ret_torrents.append(TransferTorrent( downloader=name, title=torrent.name, - path=Path(torrent.download_dir) / torrent.name, + path=Path(self.normalize_return_path(torrent_path, name)), hash=torrent.hashString, tags=",".join(torrent.labels or []), progress=torrent.progress, diff --git a/tests/test_downloader_path_mapping.py b/tests/test_downloader_path_mapping.py new file mode 100644 index 00000000..e58e1204 --- /dev/null +++ b/tests/test_downloader_path_mapping.py @@ -0,0 +1,365 @@ +import sys +import types +import unittest +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 TorrentStatus(Enum): + TRANSFER = "transfer" + DOWNLOADING = "downloading" + + 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.DownloaderInfo = object + schema_types_module.TorrentStatus = TorrentStatus + 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() + + +class DownloaderPathMappingTest(unittest.TestCase): + def _build_base(self, path_mapping): + downloader = DownloaderBase.__new__(DownloaderBase) + downloader.get_config = MagicMock( + return_value=SimpleNamespace(path_mapping=path_mapping) + ) + return downloader + + def test_normalize_path_maps_moviepilot_path_to_downloader_path(self): + downloader = self._build_base( + [("/media", "/mnt/raid5/home_lt999lt")] + ) + + result = downloader.normalize_path( + Path("/media/video/downloads/movie"), "tr" + ) + + self.assertEqual(result, "/mnt/raid5/home_lt999lt/video/downloads/movie") + + def test_normalize_return_path_maps_downloader_path_back_to_moviepilot_path(self): + downloader = self._build_base( + [("/media", "/mnt/raid5/home_lt999lt")] + ) + + result = downloader.normalize_return_path( + Path("/mnt/raid5/home_lt999lt/video/downloads/TV/Show.mkv"), "tr" + ) + + self.assertEqual(result, "/media/video/downloads/TV/Show.mkv") + + def test_path_mapping_matches_complete_path_segment_only(self): + downloader = self._build_base([("/media", "/mnt/media")]) + + result = downloader.normalize_return_path( + Path("/mnt/media2/Show.mkv"), "tr" + ) + + self.assertEqual(result, "/mnt/media2/Show.mkv") + + def test_blank_path_mapping_entry_is_ignored(self): + downloader = self._build_base( + [("", "/downloads"), ("/media2", ""), ("/media", "/mnt/media")] + ) + + result = downloader.normalize_return_path(Path("/mnt/media/Show.mkv"), "tr") + + self.assertEqual(result, "/media/Show.mkv") + + def test_normalize_path_strips_storage_prefix_after_mapping(self): + downloader = self._build_base([("local:/media", "/downloads")]) + + result = downloader.normalize_path(Path("local:/media/movie"), "qb") + + self.assertEqual(result, "/downloads/movie") + + +class TransmissionPathMappingTest(unittest.TestCase): + def _build_module(self, 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_completed_torrents_return_moviepilot_accessible_path(self): + 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 = self._build_module(server) + + torrents = module.list_torrents(status=TransmissionTorrentStatus.TRANSFER) + + self.assertEqual(torrents[0].path, Path("/media/video/downloads/TV/Show.S01E01.mkv")) + module.normalize_return_path.assert_called_once_with( + Path("/mnt/raid5/home_lt999lt/video/downloads/TV/Show.S01E01.mkv"), + "tr", + ) + + def test_hash_lookup_return_moviepilot_accessible_path(self): + 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 = self._build_module(server) + + torrents = module.list_torrents(hashs=["hash-tr"], downloader="tr") + + self.assertEqual(torrents[0].path, Path("/media/video/downloads/movie/Movie")) + module.normalize_return_path.assert_called_once_with( + Path("/mnt/raid5/home_lt999lt/video/downloads/movie/Movie"), + "tr", + ) + + +if __name__ == "__main__": + unittest.main()