mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 07:56:52 +08:00
fix(download): restore classification for configured roots (#6135)
This commit is contained in:
+50
-37
@@ -137,9 +137,23 @@ class DownloadChain(ChainBase):
|
|||||||
logger.warn(str(err))
|
logger.warn(str(err))
|
||||||
return None, None, str(err)
|
return None, None, str(err)
|
||||||
if re.match(r"^[A-Za-z]:/", validated_save_path):
|
if re.match(r"^[A-Za-z]:/", validated_save_path):
|
||||||
return storage, Path(validated_save_path), ""
|
target_dir = Path(validated_save_path)
|
||||||
file_uri = FileURI.from_uri(validated_save_path)
|
else:
|
||||||
return file_uri.storage or storage, Path(file_uri.path), ""
|
file_uri = FileURI.from_uri(validated_save_path)
|
||||||
|
storage = file_uri.storage or storage
|
||||||
|
target_dir = Path(file_uri.path)
|
||||||
|
|
||||||
|
dir_info = DirectoryHelper().get_download_dir_by_save_path(
|
||||||
|
media=media_info,
|
||||||
|
save_path=validated_save_path,
|
||||||
|
)
|
||||||
|
if dir_info:
|
||||||
|
target_dir = DownloadChain._append_download_classification(
|
||||||
|
root_path=target_dir,
|
||||||
|
dir_info=dir_info,
|
||||||
|
media_info=media_info,
|
||||||
|
)
|
||||||
|
return storage, target_dir, ""
|
||||||
|
|
||||||
dir_info = DirectoryHelper().get_dir(media_info, include_unsorted=True)
|
dir_info = DirectoryHelper().get_dir(media_info, include_unsorted=True)
|
||||||
storage = dir_info.storage if dir_info else storage
|
storage = dir_info.storage if dir_info else storage
|
||||||
@@ -147,15 +161,33 @@ class DownloadChain(ChainBase):
|
|||||||
logger.error(f"未找到下载目录:{media_info.type.value} {media_info.title_year}")
|
logger.error(f"未找到下载目录:{media_info.type.value} {media_info.title_year}")
|
||||||
return None, None, "未找到下载目录"
|
return None, None, "未找到下载目录"
|
||||||
|
|
||||||
if not dir_info.media_type and dir_info.download_type_folder:
|
download_dir = DownloadChain._append_download_classification(
|
||||||
download_dir = Path(dir_info.download_path) / media_info.type.value
|
root_path=Path(dir_info.download_path),
|
||||||
else:
|
dir_info=dir_info,
|
||||||
download_dir = Path(dir_info.download_path)
|
media_info=media_info,
|
||||||
|
)
|
||||||
|
return storage, download_dir, ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _append_download_classification(
|
||||||
|
root_path: Path,
|
||||||
|
dir_info: schemas.TransferDirectoryConf,
|
||||||
|
media_info: MediaInfo,
|
||||||
|
) -> Path:
|
||||||
|
"""
|
||||||
|
按下载目录配置拼装媒体类型和类别子目录。
|
||||||
|
|
||||||
|
:param root_path: 下载根目录
|
||||||
|
:param dir_info: 下载目录配置
|
||||||
|
:param media_info: 媒体信息
|
||||||
|
:return: 应传给存储或下载器的媒体下载目录
|
||||||
|
"""
|
||||||
|
download_dir = root_path
|
||||||
|
if not dir_info.media_type and dir_info.download_type_folder:
|
||||||
|
download_dir = download_dir / media_info.type.value
|
||||||
if not dir_info.media_category and dir_info.download_category_folder and media_info.category:
|
if not dir_info.media_category and dir_info.download_category_folder and media_info.category:
|
||||||
download_dir = download_dir / media_info.category
|
download_dir = download_dir / media_info.category
|
||||||
|
return download_dir
|
||||||
return storage, download_dir, ""
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _upload_subtitle_file(
|
def _upload_subtitle_file(
|
||||||
@@ -785,36 +817,17 @@ class DownloadChain(ChainBase):
|
|||||||
# 获取种子文件的文件夹名和文件清单
|
# 获取种子文件的文件夹名和文件清单
|
||||||
_folder_name, _file_list = TorrentHelper().get_fileinfo_from_torrent_content(torrent_content)
|
_folder_name, _file_list = TorrentHelper().get_fileinfo_from_torrent_content(torrent_content)
|
||||||
|
|
||||||
storage = 'local'
|
storage, download_dir, error_msg = self._resolve_media_download_dir(
|
||||||
# 下载目录
|
media_info=_media,
|
||||||
if save_path is not None:
|
save_path=save_path,
|
||||||
download_dir = Path(save_path)
|
)
|
||||||
else:
|
if not download_dir:
|
||||||
# 根据媒体信息查询下载目录配置
|
if error_msg == "未找到下载目录":
|
||||||
dir_info = DirectoryHelper().get_dir(_media, include_unsorted=True)
|
|
||||||
storage = dir_info.storage if dir_info else storage
|
|
||||||
# 拼装子目录
|
|
||||||
if dir_info:
|
|
||||||
# 一级目录
|
|
||||||
if not dir_info.media_type and dir_info.download_type_folder:
|
|
||||||
# 一级自动分类
|
|
||||||
download_dir = Path(dir_info.download_path) / _media.type.value
|
|
||||||
else:
|
|
||||||
# 一级不分类
|
|
||||||
download_dir = Path(dir_info.download_path)
|
|
||||||
|
|
||||||
# 二级目录
|
|
||||||
if not dir_info.media_category and dir_info.download_category_folder and _media and _media.category:
|
|
||||||
# 二级自动分类
|
|
||||||
download_dir = download_dir / _media.category
|
|
||||||
else:
|
|
||||||
# 未找到下载目录,且没有自定义下载目录
|
|
||||||
logger.error(f"未找到下载目录:{_media.type.value} {_media.title_year}")
|
|
||||||
self.messagehelper.put(f"{_media.type.value} {_media.title_year} 未找到下载目录!",
|
self.messagehelper.put(f"{_media.type.value} {_media.title_year} 未找到下载目录!",
|
||||||
title="下载失败", role="system")
|
title="下载失败", role="system")
|
||||||
return (None, "未找到下载目录") if return_detail else None
|
return (None, error_msg or "未找到下载目录") if return_detail else None
|
||||||
fileURI = FileURI(storage=storage, path=download_dir.as_posix())
|
file_uri = FileURI(storage=storage, path=download_dir.as_posix())
|
||||||
download_dir = Path(fileURI.uri)
|
download_dir = Path(file_uri.uri)
|
||||||
|
|
||||||
# 添加下载
|
# 添加下载
|
||||||
result: Optional[tuple] = self.download(content=torrent_content,
|
result: Optional[tuple] = self.download(content=torrent_content,
|
||||||
|
|||||||
@@ -41,6 +41,43 @@ class DirectoryHelper:
|
|||||||
"""
|
"""
|
||||||
return [d for d in self.get_download_dirs() if d.storage == "local"]
|
return [d for d in self.get_download_dirs() if d.storage == "local"]
|
||||||
|
|
||||||
|
def get_download_dir_by_save_path(
|
||||||
|
self,
|
||||||
|
media: Optional[MediaInfo],
|
||||||
|
save_path: str,
|
||||||
|
) -> Optional[schemas.TransferDirectoryConf]:
|
||||||
|
"""
|
||||||
|
按媒体信息和精确保存根路径匹配下载目录配置。
|
||||||
|
|
||||||
|
仅配置根目录本身继承自动分类规则;根目录下的自定义子目录保持调用方指定的完整路径。
|
||||||
|
|
||||||
|
:param media: 媒体信息
|
||||||
|
:param save_path: 已选择的下载保存目录,支持本地路径或远端 FileURI
|
||||||
|
:return: 匹配的下载目录配置
|
||||||
|
"""
|
||||||
|
value = str(save_path or "").strip()
|
||||||
|
try:
|
||||||
|
storage, raw_path = _split_file_uri(value)
|
||||||
|
target_style, target_path = _normalize_download_path(raw_path, storage)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
media_type = media.type.value if media else None
|
||||||
|
for dir_info in self.get_download_dirs():
|
||||||
|
root = _normalize_download_root(dir_info)
|
||||||
|
if not root:
|
||||||
|
continue
|
||||||
|
root_storage, root_style, root_path = root
|
||||||
|
if storage != root_storage or target_style != root_style or target_path != root_path:
|
||||||
|
continue
|
||||||
|
if not media_type or not dir_info.media_type:
|
||||||
|
return dir_info
|
||||||
|
if dir_info.media_type == media_type and not dir_info.media_category:
|
||||||
|
return dir_info
|
||||||
|
if dir_info.media_type == media_type and dir_info.media_category == media.category:
|
||||||
|
return dir_info
|
||||||
|
return None
|
||||||
|
|
||||||
def get_library_dirs(self) -> List[schemas.TransferDirectoryConf]:
|
def get_library_dirs(self) -> List[schemas.TransferDirectoryConf]:
|
||||||
"""
|
"""
|
||||||
获取所有媒体库目录
|
获取所有媒体库目录
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
@@ -45,6 +46,75 @@ def _windows_download_dirs():
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _classified_download_dirs():
|
||||||
|
return [
|
||||||
|
TransferDirectoryConf(
|
||||||
|
name="分类下载",
|
||||||
|
priority=1,
|
||||||
|
storage="local",
|
||||||
|
download_path="/downloads",
|
||||||
|
download_type_folder=True,
|
||||||
|
download_category_folder=True,
|
||||||
|
),
|
||||||
|
TransferDirectoryConf(
|
||||||
|
name="远程分类下载",
|
||||||
|
priority=2,
|
||||||
|
storage="rclone",
|
||||||
|
download_path="/media",
|
||||||
|
download_type_folder=True,
|
||||||
|
download_category_folder=True,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _media_specific_download_dirs():
|
||||||
|
return [
|
||||||
|
TransferDirectoryConf(
|
||||||
|
name="电影下载",
|
||||||
|
priority=1,
|
||||||
|
storage="local",
|
||||||
|
download_path="/downloads",
|
||||||
|
media_type=MediaType.MOVIE.value,
|
||||||
|
download_category_folder=True,
|
||||||
|
),
|
||||||
|
TransferDirectoryConf(
|
||||||
|
name="电视剧下载",
|
||||||
|
priority=2,
|
||||||
|
storage="local",
|
||||||
|
download_path="/downloads",
|
||||||
|
media_type=MediaType.TV.value,
|
||||||
|
download_category_folder=True,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _nested_download_dirs():
|
||||||
|
return [
|
||||||
|
TransferDirectoryConf(
|
||||||
|
name="A",
|
||||||
|
priority=1,
|
||||||
|
storage="local",
|
||||||
|
download_path="/downloads",
|
||||||
|
download_type_folder=True,
|
||||||
|
),
|
||||||
|
TransferDirectoryConf(
|
||||||
|
name="B",
|
||||||
|
priority=2,
|
||||||
|
storage="local",
|
||||||
|
download_path="/downloads/tv",
|
||||||
|
download_category_folder=True,
|
||||||
|
),
|
||||||
|
TransferDirectoryConf(
|
||||||
|
name="C",
|
||||||
|
priority=3,
|
||||||
|
storage="local",
|
||||||
|
download_path="/downloads/tv/collection",
|
||||||
|
download_type_folder=True,
|
||||||
|
download_category_folder=True,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def patch_download_dirs(monkeypatch):
|
def patch_download_dirs(monkeypatch):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -129,6 +199,109 @@ def test_validate_download_save_path_rejects_paths_outside_configured_roots(save
|
|||||||
validate_download_save_path(save_path)
|
validate_download_save_path(save_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_tv_media() -> MediaInfo:
|
||||||
|
return MediaInfo(
|
||||||
|
type=MediaType.TV,
|
||||||
|
title="Demo Show",
|
||||||
|
year="2026",
|
||||||
|
tmdb_id=2,
|
||||||
|
genre_ids=[16],
|
||||||
|
category="动漫",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_media_download_dir_applies_configured_root_classification(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.helper.directory.DirectoryHelper.get_download_dirs",
|
||||||
|
lambda _self: _classified_download_dirs(),
|
||||||
|
)
|
||||||
|
|
||||||
|
storage, target_dir, error_msg = DownloadChain._resolve_media_download_dir(
|
||||||
|
media_info=_build_tv_media(),
|
||||||
|
save_path="/downloads",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert storage == "local"
|
||||||
|
assert target_dir == Path("/downloads/电视剧/动漫")
|
||||||
|
assert error_msg == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_media_download_dir_keeps_configured_child_path_exact(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.helper.directory.DirectoryHelper.get_download_dirs",
|
||||||
|
lambda _self: _classified_download_dirs(),
|
||||||
|
)
|
||||||
|
|
||||||
|
storage, target_dir, error_msg = DownloadChain._resolve_media_download_dir(
|
||||||
|
media_info=_build_tv_media(),
|
||||||
|
save_path="/downloads/收藏区",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert storage == "local"
|
||||||
|
assert target_dir == Path("/downloads/收藏区")
|
||||||
|
assert error_msg == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_media_download_dir_applies_remote_root_classification(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.helper.directory.DirectoryHelper.get_download_dirs",
|
||||||
|
lambda _self: _classified_download_dirs(),
|
||||||
|
)
|
||||||
|
|
||||||
|
storage, target_dir, error_msg = DownloadChain._resolve_media_download_dir(
|
||||||
|
media_info=_build_tv_media(),
|
||||||
|
save_path="rclone:/media",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert storage == "rclone"
|
||||||
|
assert target_dir == Path("/media/电视剧/动漫")
|
||||||
|
assert error_msg == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_media_download_dir_uses_matching_media_specific_root(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.helper.directory.DirectoryHelper.get_download_dirs",
|
||||||
|
lambda _self: _media_specific_download_dirs(),
|
||||||
|
)
|
||||||
|
|
||||||
|
storage, target_dir, error_msg = DownloadChain._resolve_media_download_dir(
|
||||||
|
media_info=_build_tv_media(),
|
||||||
|
save_path="/downloads",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert storage == "local"
|
||||||
|
assert target_dir == Path("/downloads/动漫")
|
||||||
|
assert error_msg == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("save_path", "expected"),
|
||||||
|
[
|
||||||
|
("/downloads", "/downloads/电视剧"),
|
||||||
|
("/downloads/tv", "/downloads/tv/动漫"),
|
||||||
|
("/downloads/tv/collection", "/downloads/tv/collection/电视剧/动漫"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_resolve_media_download_dir_uses_exact_nested_root_configuration(
|
||||||
|
monkeypatch,
|
||||||
|
save_path,
|
||||||
|
expected,
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.helper.directory.DirectoryHelper.get_download_dirs",
|
||||||
|
lambda _self: _nested_download_dirs(),
|
||||||
|
)
|
||||||
|
|
||||||
|
storage, target_dir, error_msg = DownloadChain._resolve_media_download_dir(
|
||||||
|
media_info=_build_tv_media(),
|
||||||
|
save_path=save_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert storage == "local"
|
||||||
|
assert target_dir == Path(expected)
|
||||||
|
assert error_msg == ""
|
||||||
|
|
||||||
|
|
||||||
def _build_context() -> Context:
|
def _build_context() -> Context:
|
||||||
return Context(
|
return Context(
|
||||||
meta_info=MetaInfo("Demo Movie 2026"),
|
meta_info=MetaInfo("Demo Movie 2026"),
|
||||||
@@ -193,6 +366,26 @@ def test_download_single_rejects_event_overridden_bad_save_path_before_downloade
|
|||||||
chain.download.assert_not_called()
|
chain.download.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_single_applies_configured_root_classification(monkeypatch):
|
||||||
|
monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.helper.directory.DirectoryHelper.get_download_dirs",
|
||||||
|
lambda _self: _classified_download_dirs(),
|
||||||
|
)
|
||||||
|
chain = _build_download_chain()
|
||||||
|
chain.download.return_value = ("qb", None, "Original", "test stop")
|
||||||
|
context = _build_context()
|
||||||
|
context.media_info = _build_tv_media()
|
||||||
|
|
||||||
|
chain.download_single(
|
||||||
|
context=context,
|
||||||
|
torrent_content=b"torrent-content",
|
||||||
|
save_path="/downloads",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert chain.download.call_args.kwargs["download_dir"] == Path("/downloads/电视剧/动漫")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("save_path", ["", " "])
|
@pytest.mark.parametrize("save_path", ["", " "])
|
||||||
def test_download_single_rejects_explicit_empty_save_path_before_default_fallback(monkeypatch, save_path):
|
def test_download_single_rejects_explicit_empty_save_path_before_default_fallback(monkeypatch, save_path):
|
||||||
monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None)
|
monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None)
|
||||||
|
|||||||
Reference in New Issue
Block a user