Files
MoviePilot/tests/test_downloader_path_mapping.py
2026-06-15 18:30:41 +08:00

489 lines
16 KiB
Python

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)