From a697ce8852037ca5e93405cc10848d69307c9144 Mon Sep 17 00:00:00 2001 From: yuming Date: Fri, 24 Apr 2026 21:43:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=BF=E4=B8=BB=E6=9C=BA=20=E2=86=94?= =?UTF-8?q?=20=E5=AE=B9=E5=99=A8=E8=B7=AF=E5=BE=84=E5=8F=8C=E5=90=91?= =?UTF-8?q?=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit config.yaml 新增 path_mapping 段声明宿主机前缀与容器挂载点前缀的对应关系。 用户在 Web 界面和 config 里填宿主机真实路径(如群晖 /volume2/...), 程序在真正落盘、调用云盘 SDK 前透明转成容器内路径,保证文件写到 bind mount 而不是容器 overlay 文件系统;前端显示/返回的路径再反向转回宿主机形式。 无映射配置时所有 to_container/to_host 均原样返回,向后兼容老配置。 Co-Authored-By: Claude Opus 4.7 (1M context) --- module/app.py | 13 ++++++-- module/path_mapper.py | 69 +++++++++++++++++++++++++++++++++++++++++++ module/web.py | 5 ++-- 3 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 module/path_mapper.py diff --git a/module/app.py b/module/app.py index bd05ca9..6281343 100644 --- a/module/app.py +++ b/module/app.py @@ -15,6 +15,7 @@ from ruamel import yaml from module.cloud_drive import CloudDrive, CloudDriveConfig from module.filter import Filter from module.language import Language, set_language +from module import path_mapper from utils.format import replace_date_time, validate_title from utils.meta_data import MetaData @@ -433,6 +434,9 @@ class Application: """ # pylint: disable = R0912 # TODO: judge the storage if enough,and provide more path + # 先加载 path_mapping,后续 save_path / cloud_drive 路径都按此映射表解析 + path_mapper.load_mappings(_config.get("path_mapping", [])) + if _config.get("save_path") is not None: self.save_path = _config["save_path"] @@ -676,10 +680,12 @@ class Application: return False ret: bool = False + # 云盘上传也走容器路径,避免 SDK 在容器内找不到宿主机路径 + save_path_container = path_mapper.to_container(self.save_path) if self.cloud_drive_config.upload_adapter == "rclone": ret = await CloudDrive.rclone_upload_file( self.cloud_drive_config, - self.save_path, + save_path_container, local_file_path, progress_callback, progress_args, @@ -688,7 +694,7 @@ class Application: ret = await self.loop.run_in_executor( self.executor, CloudDrive.aligo_upload_file( - self.cloud_drive_config, self.save_path, local_file_path + self.cloud_drive_config, save_path_container, local_file_path ), ) @@ -724,7 +730,8 @@ class Application: res = os.path.join(res, media_datetime) elif prefix == "media_type": res = os.path.join(res, media_type) - return res + # 真正落盘前把宿主机路径转成容器内挂载点路径,保证写到 bind mount 卷 + return path_mapper.to_container(res) def get_file_name( self, message_id: int, file_name: Optional[str], caption: Optional[str] diff --git a/module/path_mapper.py b/module/path_mapper.py new file mode 100644 index 0000000..b559783 --- /dev/null +++ b/module/path_mapper.py @@ -0,0 +1,69 @@ +"""宿主机路径 ↔ 容器路径映射 + +用于让用户在 Web 界面和 config.yaml 里填宿主机真实路径(如群晖的 /volume2/...), +程序在真正写盘/调用云盘 SDK 之前再转换成容器内挂载点路径(如 /app/downloads)。 +保证文件落到 bind mount 卷而不是容器 overlay 文件系统。 + +匹配规则:最长前缀匹配,以路径分隔符为边界。无匹配则原样返回,不阻断流程。 +""" +import logging +import os + +logger = logging.getLogger("media_downloader") + +# (host_prefix, container_prefix) 列表,加载时按 host_prefix 长度倒序 +_mappings: list = [] + + +def _normalize(p: str) -> str: + """去掉尾部 /,统一用 / 做分隔符""" + if not p: + return "" + return p.replace("\\", "/").rstrip("/") + + +def load_mappings(items: list): + """从 config 里的 path_mapping 列表加载映射 + + items 每项形如 {'host': '/volume2/save/tgdowload', 'container': '/app/downloads'} + """ + global _mappings + pairs = [] + for it in items or []: + h = _normalize(str(it.get("host", "") or "")) + c = _normalize(str(it.get("container", "") or "")) + if not h or not c: + continue + pairs.append((h, c)) + # 按 host 前缀长度倒序,保证最长前缀优先命中 + pairs.sort(key=lambda x: len(x[0]), reverse=True) + _mappings = pairs + if _mappings: + logger.info("path_mapper loaded %d mapping(s): %s", len(_mappings), _mappings) + + +def _match_prefix(p: str, prefix: str) -> bool: + """前缀匹配,必须落在路径分隔符边界上,避免 /a/b 匹配到 /a/bc""" + return p == prefix or p.startswith(prefix + "/") + + +def to_container(p: str) -> str: + """宿主机路径 → 容器路径。无匹配原样返回。""" + if not p: + return p + norm = _normalize(p) + for host, container in _mappings: + if _match_prefix(norm, host): + return container + norm[len(host):] + return p + + +def to_host(p: str) -> str: + """容器路径 → 宿主机路径。无匹配原样返回。""" + if not p: + return p + norm = _normalize(p) + for host, container in _mappings: + if _match_prefix(norm, container): + return host + norm[len(container):] + return p diff --git a/module/web.py b/module/web.py index 1d2dee2..22e18e6 100644 --- a/module/web.py +++ b/module/web.py @@ -16,6 +16,7 @@ from ruamel.yaml import YAML from werkzeug.serving import make_server import utils +from module import path_mapper from module.app import Application, ChatDownloadConfig, TaskNode from module.download_stat import ( DownloadState, @@ -337,7 +338,7 @@ def get_download_list(): "total_size": format_byte(r.get("file_size") or 0), "download_progress": "100", "download_speed": r.get("download_time", ""), - "save_path": (r.get("file_path") or "").replace("\\", "/"), + "save_path": path_mapper.to_host((r.get("file_path") or "").replace("\\", "/")), "status": r.get("status", "success"), }) return json.dumps({ @@ -363,7 +364,7 @@ def get_download_list(): "total_size": format_byte(value["total_size"]), "download_progress": str(round(value["down_byte"] / value["total_size"] * 100, 1)), "download_speed": download_speed, - "save_path": value["file_name"].replace("\\", "/"), + "save_path": path_mapper.to_host(value["file_name"].replace("\\", "/")), "paused": is_message_paused(str(chat_id), idx), }) return json.dumps(items, ensure_ascii=False)