From 08331697812d93bdce0bd6aedb92dabe9d0aa7b7 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Wed, 30 Sep 2026 13:12:25 +0800 Subject: [PATCH] fix(smb): restore multi-share capacity and diagnose unmapped paths --- app/chain/download/subtitle.py | 1 + app/chain/transfer/workflow.py | 14 ++++++++++- app/modules/filemanager/storages/smb.py | 9 +++---- docs/mcp-api.md | 8 +++++-- tests/test_download_chain.py | 15 ++++++++++++ tests/test_smb_storage.py | 29 +++++++++++++++++++---- tests/test_transfer_downloader_storage.py | 16 +++++++++++++ 7 files changed, 79 insertions(+), 13 deletions(-) diff --git a/app/chain/download/subtitle.py b/app/chain/download/subtitle.py index adfabcfed..b008d095e 100644 --- a/app/chain/download/subtitle.py +++ b/app/chain/download/subtitle.py @@ -106,6 +106,7 @@ def _resolve_site_subtitle_target_dir( if not file_uri.path: logger.error("下载目录路径为空,无法保存字幕") return None, None + download_dir = Path(file_uri.path) content_storage, content_dir = _resolve_torrent_content_dir( list_torrents, download_hash=download_hash, diff --git a/app/chain/transfer/workflow.py b/app/chain/transfer/workflow.py index 9b3bb517e..024adc171 100644 --- a/app/chain/transfer/workflow.py +++ b/app/chain/transfer/workflow.py @@ -124,7 +124,19 @@ class TransferWorkflowOwner(_TransferOwnerBase): and file_path.is_relative_to(Path(directory.download_path)) for directory in download_dirs ): - logger.debug(f"文件 {file_uri.uri} 不在下载器监控目录中,不通过下载器进行整理") + if file_uri.storage == "local" and any( + directory.monitor_type == "downloader" + and directory.storage != "local" + and directory.download_path + and file_path.is_relative_to(Path(directory.download_path)) + for directory in download_dirs + ): + logger.warning( + f"下载器 {torrent.downloader} 返回的路径 {file_uri.uri} 缺少远程存储标识," + "请在下载器设置中配置含存储类型前缀的路径映射(如 smb:/downloads → /downloads)" + ) + else: + logger.debug(f"文件 {file_uri.uri} 不在下载器监控目录中,不通过下载器进行整理") return None try: fileitem = StorageChain().get_file_item_strict( diff --git a/app/modules/filemanager/storages/smb.py b/app/modules/filemanager/storages/smb.py index 2cc6b9066..1bb3927cb 100644 --- a/app/modules/filemanager/storages/smb.py +++ b/app/modules/filemanager/storages/smb.py @@ -800,15 +800,12 @@ class SMB(StorageBase, metaclass=WeakSingleton): pass def usage(self) -> Optional[_SchemaStorageUsage]: - """ - 存储使用情况 - """ + """返回首个共享报告的卷容量和当前账号可用空间,不累计可能重叠的共享。""" import smbclient try: self._check_connection() - # 多个共享可能指向同一卷,也可能分属不同卷;现有用量合同不能准确聚合。 - if self._multi_share and len(self._shares) > 1: - return None + # 多个共享可能共用存储池;SMB 的卷序列号也可能按共享生成,不能据此累加。 + # 沿用单卷用量合同,以配置首项作为容量查询入口,保持卡片和仪表板可用。 share = next(iter(self._shares.values())) volume_stat = smbclient.stat_volume(f"\\\\{self._host}\\{share}") return _SchemaStorageUsage( diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 6a91744e2..6d60f320e 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -438,13 +438,17 @@ SMB 配置支持两种明确的路径模式:旧字段 `share: "data"` 保持 新字段 `shares: ["video", "downloads"]` 使用虚拟根目录,路径首段为共享名称。 即使多共享模式只剩一项,也不会自动退回旧路径语义。所有共享使用同一主机、 账号和 SMB 服务;浏览 `/` 返回 `/video/`、`/downloads/`,只允许访问已配置的共享。 -虚拟根和共享根不可删除、重命名或作为文件整理目标;多个共享的磁盘容量不相加, -因为它们可能指向同一卷,多共享用量返回不可用。 +虚拟根和共享根不可删除、重命名;虚拟根不能作为媒体库目录,共享根(例如 `/video`)可以。 +存储卡片和仪表板显示配置中首个共享报告的卷容量及当前账号可用空间,不累加其他共享。 +多个共享可能共用存储池,SMB 卷序列号也不一定代表独立磁盘;若共享属于不同卷, +显示的是首项所在卷的容量,并非整台服务器的总容量。 对 `10.10.10.11` 的 `downloads` 和 `video` 共享,可选择源存储 `smb`、 源目录 `/downloads`,目标存储 `smb`、目标目录 `/video/电影`;下载器映射使用 `["smb:/downloads", "/downloads"]`。切换新旧模式后须更新目录设置、下载器映射及 其他已保存路径;应先处理完旧路径的在途整理任务,不会自动重写历史任务。 +若开启按媒体类型、类别自动建目录,媒体库根目录可设为 `/video`,由整理规则追加子目录。 +同路径的远程监控目录收到缺少存储前缀的下载器路径时,日志会提示检查路径映射。 同一 SMB 服务内,`copy` 对共享内和跨共享文件都使用服务端 CopyChunk; `move` 在同一共享内使用重命名,跨共享使用服务端复制成功后删除源文件。 diff --git a/tests/test_download_chain.py b/tests/test_download_chain.py index 3dc05964a..e6cf6a389 100644 --- a/tests/test_download_chain.py +++ b/tests/test_download_chain.py @@ -317,6 +317,21 @@ def test_download_site_subtitles_uses_downloader_content_path_without_creating_s ) +@pytest.mark.parametrize("download_hash", [None, "not-yet-visible"]) +@pytest.mark.parametrize("storage, prefix", [("smb", "smb:"), ("local", "")]) +def test_subtitle_fallback_separates_storage_identity_from_path(download_hash, storage, prefix): + """下载器尚未返回内容目录时,字幕存储查询也只能接收去掉协议前缀的路径。""" + list_torrents = MagicMock(return_value=[]) + + result = download_subtitle._resolve_site_subtitle_target_dir( + download_dir=Path(f"{prefix}/downloads/TV"), + folder_name="Demo.Show", download_hash=download_hash, + downloader="qb", list_torrents=list_torrents, + ) + + assert result == (storage, Path("/downloads/TV/Demo.Show")) + + def test_download_site_subtitles_does_not_create_missing_save_folder(monkeypatch): """找不到实际内容目录时应放弃字幕写入,不能制造迁移冲突目录。""" class _FakeTorrentHelper: diff --git a/tests/test_smb_storage.py b/tests/test_smb_storage.py index ab718614f..3a080d755 100644 --- a/tests/test_smb_storage.py +++ b/tests/test_smb_storage.py @@ -305,9 +305,30 @@ def test_cross_share_hardlink_fails_without_side_effects(multi_share): multi_share.client.makedirs.assert_not_called() -def test_multi_share_usage_is_not_double_counted(multi_share, monkeypatch): - """多个共享可能共用磁盘,不把容量相加伪装成准确的服务用量。""" - stat_volume = Mock() +@pytest.mark.parametrize("conf, share", [ + ({"share": "video"}, "video"), + ({"shares": ["video"]}, "video"), + ({"shares": ["video", "downloads"]}, "video"), + ({"shares": ["downloads", "video"]}, "downloads"), +]) +def test_storage_usage_reports_first_share_without_double_counting(multi_share, monkeypatch, conf, share): + """单共享兼容旧容量合同,多共享按首项返回可用容量,不能重复累计卷空间。""" + multi_share.storage._configure_shares(conf) + stat_volume = Mock(return_value=SimpleNamespace( + total_size=4096, caller_available_size=1024, actual_available_size=2048, + )) + monkeypatch.setattr(smbclient, "stat_volume", stat_volume) + + result = multi_share.storage.usage() + + assert result.total == 4096 + assert result.available == 1024 + stat_volume.assert_called_once_with(f"\\\\10.10.10.11\\{share}") + + +def test_multi_share_usage_query_failure_remains_unavailable(multi_share, monkeypatch): + """首项不可查询时保留失败语义,不能悄悄改用另一共享的容量。""" + stat_volume = Mock(side_effect=PermissionError("denied")) monkeypatch.setattr(smbclient, "stat_volume", stat_volume) assert multi_share.storage.usage() is None - stat_volume.assert_not_called() + stat_volume.assert_called_once_with(r"\\10.10.10.11\video") diff --git a/tests/test_transfer_downloader_storage.py b/tests/test_transfer_downloader_storage.py index 3de4bcbec..45938a64b 100644 --- a/tests/test_transfer_downloader_storage.py +++ b/tests/test_transfer_downloader_storage.py @@ -93,3 +93,19 @@ def test_unavailable_source_is_not_enqueued_and_can_retry(monitored_source, fail state.chain.do_transfer.assert_called_once() assert state.storage.get_file_item_strict.call_count == 2 + + +def test_unmapped_remote_directory_reports_actionable_warning(monitored_source, monkeypatch): + """远程监控目录收到未映射的本地路径时,提示配置映射且不能误读本地文件。""" + state = monitored_source + state.torrent.path = Path("/incoming/Movie/movie.mkv") + warning = Mock() + monkeypatch.setattr(workflow_module.logger, "warning", warning) + + queue_module.TransferQueueOwner.process(state.chain) + + warning.assert_called_once() + assert "路径映射" in warning.call_args.args[0] + assert "remote-qb" in warning.call_args.args[0] + state.storage.get_file_item_strict.assert_not_called() + state.chain.do_transfer.assert_not_called()