fix(download): detect dotted torrent root folders

This commit is contained in:
yanjunpu
2026-09-07 16:25:21 +08:00
parent 471c624aee
commit 8a7e4b2fbc
2 changed files with 74 additions and 11 deletions
+54 -9
View File
@@ -1,7 +1,7 @@
"""下载器已有任务的媒体识别、资源归类与根目录重命名。"""
import re
from pathlib import PurePosixPath
from pathlib import Path, PurePosixPath
from typing import Any
from app.application.configuration import get_configured_system_config
@@ -150,12 +150,59 @@ def _downloader_kind(name: str) -> str | None:
return str(match.get("type") or "").casefold() if match else None
def _qb_module(chain: Any) -> Any:
"""返回当前运行的 qBittorrent 模块。"""
return chain.modulemanager.get_running_module("QbittorrentModule")
def _qb_root_folder(
chain: Any,
downloader: str,
hash_value: str,
current: PurePosixPath,
content: PurePosixPath | None,
) -> str | None:
"""确认 qB 任务是否拥有一个独立顶层目录。
文件夹名称允许包含点号;不能使用 ``Path.suffix`` 猜测它是不是文件。
优先检查已挂载文件系统,路径尚不存在时再用 qB 文件清单确认。
"""
if _downloader_kind(downloader) != "qbittorrent" or not content:
return None
if not content.is_absolute() or not content.is_relative_to(current):
return None
relative_content = content.relative_to(current)
if len(relative_content.parts) != 1:
return None
local_content = Path(content.as_posix())
if local_content.is_dir():
return relative_content.name
if local_content.is_file():
return None
module = _qb_module(chain)
try:
torrent_files = module.torrent_files(tid=hash_value, downloader=downloader) if module else None
except Exception:
torrent_files = None
file_paths = [
PurePosixPath(str(getattr(item, "name", "")).replace("\\", "/"))
for item in (torrent_files or [])
if str(getattr(item, "name", "")).strip()
]
if not file_paths or not all(len(path.parts) >= 2 for path in file_paths):
return None
top_levels = {path.parts[0] for path in file_paths}
return relative_content.name if top_levels == {relative_content.name} else None
def _rename_qb_root(chain: Any, downloader: str, hash_value: str, old_name: str, new_name: str) -> bool:
"""通过已运行的 qBittorrent 模块调用官方 renameFolder API。
此变更由下载器维护任务与文件的对应关系,不直接操作文件系统。
"""
module = chain.modulemanager.get_running_module("QbittorrentModule")
module = _qb_module(chain)
server = module.get_instance(downloader) if module else None
client = getattr(server, "qbc", None)
if client is None:
@@ -219,12 +266,8 @@ def organize_existing_source(hash_value: str, request: Any, chain: Any, media_ch
content_text = str(torrent.content_path or torrent.path or "").strip()
content = PurePosixPath(content_text) if content_text else None
current_root_name = None
if content and content.is_absolute() and content.is_relative_to(current):
relative_content = content.relative_to(current)
if len(relative_content.parts) == 1 and not content.suffix:
current_root_name = relative_content.name
rename_supported = _downloader_kind(downloader) == "qbittorrent" and current_root_name is not None
current_root_name = _qb_root_folder(chain, downloader, hash_value, current, content)
rename_supported = current_root_name is not None
proposed_root_name = _root_name(media) if request.smart_rename and media else current_root_name
rename_required = bool(
request.smart_rename
@@ -233,7 +276,9 @@ def organize_existing_source(hash_value: str, request: Any, chain: Any, media_ch
and proposed_root_name != current_root_name
)
if request.smart_rename and not rename_supported:
raise ValueError("当前任务不是 qBittorrent 的单根目录任务,无法安全智能重命名")
if _downloader_kind(downloader) != "qbittorrent":
raise ValueError("智能根目录重命名目前仅支持 qBittorrent")
raise ValueError("该 qBittorrent 任务没有可重命名的独立顶层目录;单文件或散列文件任务请关闭智能重命名")
target_text = target.as_posix()
changed = current.as_posix() != target_text or rename_required
+20 -2
View File
@@ -115,7 +115,11 @@ class SourceOrganizationTests(unittest.TestCase):
self.media_chain = Mock()
self.media_chain.recognize_by_meta.return_value = self.media
self.qbc = Mock()
module = NS(get_instance=lambda _name: NS(qbc=self.qbc))
self.torrent_files = [NS(name="Karen Mok - Loving Gaze 2002 FLAC/01.flac")]
module = NS(
get_instance=lambda _name: NS(qbc=self.qbc),
torrent_files=lambda **_kwargs: self.torrent_files,
)
self.chain = Mock()
self.chain.download_history_repository.get_by_hash.return_value = self.history
self.chain.list_torrents.return_value = [self.torrent]
@@ -210,7 +214,21 @@ class SourceOrganizationTests(unittest.TestCase):
def test_smart_rename_rejects_multi_root_tasks(self):
self.torrent.content_path = self.torrent.save_path
with self.assertRaisesRegex(ValueError, "无法安全智能重命名"):
with self.assertRaisesRegex(ValueError, "单文件或散列文件"):
self.preview()
def test_dotted_folder_name_is_not_treated_as_a_file(self):
folder = "Eagles.2011 - Hotel California SACD"
self.torrent.content_path = f"/volume1/UT/Musics/{folder}"
self.torrent_files = [NS(name=f"{folder}/01.dsf"), NS(name=f"{folder}/02.dsf")]
result = self.preview()
self.assertEqual(result["current_root_name"], folder)
self.assertTrue(result["rename_supported"])
def test_single_file_task_is_not_treated_as_a_folder(self):
self.torrent.content_path = "/volume1/UT/Musics/Hotel California.dsf"
self.torrent_files = [NS(name="Hotel California.dsf")]
with self.assertRaisesRegex(ValueError, "单文件或散列文件"):
self.preview()