Files
MoviePilot/app/application/directory.py
jxxghp 6d4ce83837 fix(types): clear host mypy debt and prevent typing regressions
严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。
按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩,
CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。

修复空值、异步和供应方类型边界,保持插件与 API 兼容合同,
并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。
2026-10-21 22:03:18 +08:00

1020 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
下载与整理目录配置、路径解析及分类目录选择。
"""
import re
from dataclasses import dataclass
from functools import partial
from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
from typing import Any, List, Optional, Protocol, Tuple
from pydantic import ValidationError
from app.application.classification.reference import (
ClassificationCategoryResolution,
ClassificationCategoryResolver,
append_classification_category_path,
category_path_below_media_type,
classification_category_resolver_snapshot,
classification_media_type,
configure_classification_category_resolver,
reset_classification_category_resolver,
)
from app.application.configuration import (
ConfigurationRepository,
SystemConfigReader,
SystemConfigService,
SystemConfigValueNormalizer,
SystemConfigWriter,
SystemConfigWriteResult,
get_configured_system_config,
)
from app.application.database import AsyncDatabaseExecutor
from app.domain.context import MediaInfo, MusicInfo
from app.runtime.log import logger
from app.schemas.category import ClassificationPolicy
from app.schemas.file import FileURI as _SchemaFileURI
from app.schemas.system import TransferDirectoryConf as _SchemaTransferDirectoryConf
from app.schemas.types import MediaType, StorageSchema, SystemConfigKey
JINJA2_VAR_PATTERN = re.compile(r"\{\{.*?}}", re.DOTALL)
WINDOWS_DRIVE_PATTERN = re.compile(r"^[A-Za-z]:[\\/]")
WINDOWS_DRIVE_PREFIX_PATTERN = re.compile(r"^[A-Za-z]:")
# 目录选择支持的完整影视或音乐媒体对象。
DirectoryMedia = MediaInfo | MusicInfo
def build_media_download_path(
root_path: Path,
directory: _SchemaTransferDirectoryConf,
media: DirectoryMedia,
directory_helper: Optional["DirectoryHelper"] = None,
) -> Path:
"""
按下载目录开关和稳定分类快照构造保存路径。
"""
download_path = root_path
type_folder_enabled = bool(
not directory.media_type and directory.download_type_folder
)
if type_folder_enabled:
download_path = download_path / (media.type or MediaType.UNKNOWN).value
helper = directory_helper or DirectoryHelper()
if helper.has_fixed_category(directory) or not directory.download_category_folder:
return download_path
category_path = helper.resolve_media_category(media).path
if not category_path:
return download_path
category_path = category_path_below_media_type(
category_path,
media.type,
type_folder_enabled=type_folder_enabled,
)
if not category_path:
return download_path
return append_classification_category_path(download_path, category_path)
class DiskTopology(Protocol):
"""
描述本地路径磁盘拓扑判断能力。
"""
def is_same_disk(self, src: Path, dest: Path) -> bool:
"""
返回两个真实本地路径是否位于同一磁盘。
:param src: 已解析为本机真实文件系统路径的源路径
:param dest: 已解析为本机真实文件系统路径的目标路径
:return: 两个路径属于同一磁盘时为 True
"""
...
@dataclass(frozen=True, slots=True)
class DirectoryConfigurationWriteResult:
"""
目录配置事务提交后的变更状态和规范化快照。
:param changed: True 表示已提交变更,False 表示失败,None 表示无需更新
:param normalized_value: 按当前分类策略规范化后的目录配置,None 表示未提供配置列表
"""
changed: bool | None
normalized_value: list[dict[str, Any]] | None
class DirectoryConfigurationMutationPort(Protocol):
"""
Application 写入目录配置所依赖的原子持久化端口。
"""
def save(self, value: object) -> DirectoryConfigurationWriteResult:
"""
基于事务内活动策略规范化并保存目录配置。
:param value: 待规范化的目录配置列表;校验与保存使用同一事务内的分类策略
:return: 事务提交后的变更状态及实际保存的规范化目录配置
"""
...
class DirectoryConfigurationMutationService:
"""
统一执行同步和异步目录配置保存命令。
"""
def __init__(
self,
repository: DirectoryConfigurationMutationPort,
*,
async_executor: AsyncDatabaseExecutor | None = None,
) -> None:
"""
注入原子目录仓储和可选异步数据库执行端口。
"""
self._repository = repository
self._async_executor = async_executor
def save(self, value: object) -> DirectoryConfigurationWriteResult:
"""
同步保存目录配置并返回数据库实际提交的规范化值。
"""
return self._repository.save(value)
async def async_save(
self,
value: object,
) -> DirectoryConfigurationWriteResult:
"""
在线程私有短事务中异步保存目录配置。
"""
if self._async_executor is None:
raise RuntimeError("目录配置异步数据库执行端口尚未配置")
return await self._async_executor.run(partial(self._repository.save, value))
class DirectoryAwareSystemConfigService(SystemConfigService):
"""
让所有在线 Directories 写入复用原子目录变更命令。
"""
def __init__(
self,
*,
directory_mutation: DirectoryConfigurationMutationService,
repository: ConfigurationRepository | None = None,
reader: SystemConfigReader | None = None,
writer: SystemConfigWriter | None = None,
async_executor: AsyncDatabaseExecutor | None = None,
value_normalizer: SystemConfigValueNormalizer | None = None,
) -> None:
"""
注入通用配置端口,并为目录键绑定专用原子写入命令。
"""
super().__init__(
repository=repository,
reader=reader,
writer=writer,
async_executor=async_executor,
value_normalizer=value_normalizer,
)
self._directory_mutation = directory_mutation
def set_with_normalized_value(
self,
key: Any,
value: Any,
) -> SystemConfigWriteResult:
"""
目录键走原子命令,其他配置保持通用同步写入语义。
"""
if _system_config_key(key) != SystemConfigKey.Directories.value:
return super().set_with_normalized_value(key, value)
result = self._directory_mutation.save(value)
return SystemConfigWriteResult(
changed=result.changed,
normalized_value=result.normalized_value,
)
async def async_set_with_normalized_value(
self,
key: Any,
value: Any,
) -> SystemConfigWriteResult:
"""
目录键走原子命令,其他配置保持通用异步写入语义。
"""
if _system_config_key(key) != SystemConfigKey.Directories.value:
return await super().async_set_with_normalized_value(key, value)
result = await self._directory_mutation.async_save(value)
return SystemConfigWriteResult(
changed=result.changed,
normalized_value=result.normalized_value,
)
_disk_topology: Optional[DiskTopology] = None
def configure_disk_topology(topology: Optional[DiskTopology]) -> None:
"""
由启动组合根注入或清除本地磁盘拓扑适配器。
"""
global _disk_topology
_disk_topology = topology
def configure_directory_classification_resolver(
resolver: Optional[ClassificationCategoryResolver],
) -> Optional[ClassificationCategoryResolver]:
"""
装配目录选择使用的分类引用解析器,并返回旧值供测试恢复。
"""
return configure_classification_category_resolver(resolver)
def reset_directory_classification_resolver(
resolver: Optional[ClassificationCategoryResolver] = None,
) -> None:
"""
恢复指定目录分类解析器;省略参数时清除启动组合状态。
"""
reset_classification_category_resolver(resolver)
def _directory_classification_resolver_snapshot() -> ClassificationCategoryResolver:
"""
返回当前解析器;未装配时使用只支持兼容快照的空策略解析器。
"""
return classification_category_resolver_snapshot()
def _system_config_key(key: object) -> str:
"""
把枚举或字符串配置键统一为持久化字符串。
"""
return key.value if isinstance(key, SystemConfigKey) else str(key)
def normalize_directory_system_config_value(
key: object,
value: object,
*,
classification_resolver: ClassificationCategoryResolver | None = None,
) -> object:
"""
仅在目录配置写入时刷新稳定分类引用和兼容路径快照。
"""
normalized_key = _system_config_key(key)
if normalized_key != SystemConfigKey.Directories.value:
return value
return normalize_directory_configurations(
value,
classification_resolver=classification_resolver,
)
def normalize_directory_configurations(
value: object,
*,
classification_resolver: ClassificationCategoryResolver | None = None,
) -> list[dict[str, Any]] | None:
"""
校验目录配置,并把分类类型、稳定 ID 与当前路径快照规范化后返回。
"""
if value is None:
return None
if not isinstance(value, list):
raise ValueError("目录配置必须是数组或 null")
resolver = classification_resolver or _directory_classification_resolver_snapshot()
normalized_items: list[dict[str, Any]] = []
for index, item in enumerate(value):
if item is None:
continue
if isinstance(item, _SchemaTransferDirectoryConf):
normalized = item.model_dump()
elif isinstance(item, dict):
normalized = dict(item)
else:
raise ValueError(f"第 {index + 1} 项目录配置必须是对象")
raw_media_type = normalized.get("media_type")
media_type = classification_media_type(raw_media_type)
if raw_media_type and media_type is None:
raise ValueError(
f"第 {index + 1} 项目录配置的媒体类型 {raw_media_type!r} 不受支持"
)
if raw_media_type:
normalized["media_type"] = media_type
try:
directory = _SchemaTransferDirectoryConf.model_validate(normalized)
except ValidationError as error:
detail = error.errors(include_url=False)[0].get("msg", str(error))
raise ValueError(f"第 {index + 1} 项目录配置无效:{detail}") from error
category_id = str(directory.media_category_id or "").strip() or None
resolution = resolver.resolve(
category_id=category_id,
path_snapshot=directory.media_category,
media_type=directory.media_type,
)
if category_id and not resolution.stable:
raise ValueError(
f"第 {index + 1} 项目录配置的分类 ID 无效:"
f"{resolution.message or category_id}"
)
if directory.media_category and not resolution.usable:
raise ValueError(
f"第 {index + 1} 项目录配置的分类路径无效:"
f"{resolution.message or directory.media_category}"
)
normalized["media_category_id"] = resolution.category_id
normalized["media_category"] = "/".join(resolution.path) or None
normalized_items.append(normalized)
return normalized_items or None
def normalize_directory_configurations_for_policy(
value: object,
policy: ClassificationPolicy | None,
) -> list[dict[str, Any]] | None:
"""
仅使用调用方提供的事务内策略快照规范化目录配置。
"""
return normalize_directory_configurations(
value,
classification_resolver=ClassificationCategoryResolver(lambda: policy),
)
def _is_same_local_disk(src: Path, dest: Path) -> bool:
"""
通过已注入端口判断真实本地路径;未装配时稳定拒绝。
"""
if _disk_topology is None:
raise RuntimeError("本地磁盘拓扑能力尚未由启动组合根配置")
return _disk_topology.is_same_disk(src, dest)
class DirectoryHelper:
"""
下载目录/媒体库目录帮助类
"""
def __init__(
self,
classification_resolver: Optional[ClassificationCategoryResolver] = None,
) -> None:
"""
保存显式解析器,未提供时读取启动组合根装配的当前实例。
"""
self._classification_resolver = (
classification_resolver or _directory_classification_resolver_snapshot()
)
@staticmethod
def get_dirs() -> List[_SchemaTransferDirectoryConf]:
"""
获取所有下载目录
"""
dir_confs: List[dict[str, Any]] = get_configured_system_config().get(SystemConfigKey.Directories)
if not dir_confs:
return []
return [_SchemaTransferDirectoryConf(**d) for d in dir_confs]
def get_download_dirs(self) -> List[_SchemaTransferDirectoryConf]:
"""
获取所有下载目录
"""
return sorted([d for d in self.get_dirs() if d.download_path], key=lambda x: x.priority or 0)
def get_local_download_dirs(self) -> List[_SchemaTransferDirectoryConf]:
"""
获取所有本地的可下载目录
"""
return [d for d in self.get_download_dirs() if d.storage == "local"]
def get_download_dir_by_save_path(
self,
media: Optional[DirectoryMedia],
save_path: str,
) -> Optional[_SchemaTransferDirectoryConf]:
"""
按媒体信息和精确保存根路径匹配下载目录配置。
仅配置根目录本身继承自动分类规则;根目录下的自定义子目录保持调用方指定的完整路径。
: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
candidates: list[tuple[int, _SchemaTransferDirectoryConf]] = []
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
rank = self.media_match_rank(
dir_info,
media,
allow_stale_reference=True,
)
if rank is not None:
candidates.append((rank, dir_info))
if not candidates:
return None
return min(candidates, key=lambda item: item[0])[1]
def get_download_dir_by_task_path(
self,
media: Optional[DirectoryMedia],
task_path: str,
) -> Optional[_SchemaTransferDirectoryConf]:
"""
按下载器返回的任务保存路径匹配最深层配置根目录。
"""
value = str(task_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
candidates: list[tuple[int, int, int, _SchemaTransferDirectoryConf]] = []
for index, directory in enumerate(self.get_download_dirs()):
root = _normalize_download_root(directory)
if not root:
continue
root_storage, root_style, root_path = root
if storage != root_storage or target_style != root_style:
continue
if target_path != root_path and not target_path.is_relative_to(root_path):
continue
rank = self.media_match_rank(
directory,
media,
allow_stale_reference=True,
)
if rank is None:
continue
candidates.append((-len(root_path.parts), rank, index, directory))
if not candidates:
return None
return min(candidates, key=lambda item: item[:3])[3]
def get_library_dirs(self) -> List[_SchemaTransferDirectoryConf]:
"""
获取所有媒体库目录
"""
return sorted([d for d in self.get_dirs() if d.library_path], key=lambda x: x.priority or 0)
def get_local_library_dirs(self) -> List[_SchemaTransferDirectoryConf]:
"""
获取所有本地的媒体库目录
"""
return [d for d in self.get_library_dirs() if d.library_storage == "local"]
def get_media_cleanup_root(self, fileitem: _SchemaFileURI) -> Optional[Path]:
"""
返回同一存储中最深的配置或分类边界,媒体清理不得删除该目录及其祖先。
"""
file_path = Path(fileitem.path or "")
roots: list[Path] = []
for directory in self.get_dirs():
for library, storage, path in (
(False, directory.storage, directory.download_path),
(True, directory.library_storage, directory.library_path),
):
if not path or (storage or "local") != fileitem.storage:
continue
if file_path.is_relative_to(path):
roots.append(self._media_cleanup_root(file_path, Path(path), directory, library=library))
return max(roots, key=lambda root: len(root.parts), default=None)
def _media_cleanup_root(
self,
file_path: Path,
root: Path,
directory: _SchemaTransferDirectoryConf,
*,
library: bool,
) -> Path:
"""
按目录生成规则识别类型和分类边界;旧分类无法解析时保守保留第一层。
"""
media_type = directory.media_type
type_folder = (
directory.library_type_folder if library
else not media_type and directory.download_type_folder
)
relative_parts = file_path.relative_to(root).parts
if type_folder and relative_parts and relative_parts[0] in {item.value for item in MediaType}:
media_type = relative_parts[0]
root = root / media_type
category_folder = directory.library_category_folder if library else directory.download_category_folder
fixed_category = self.has_fixed_category(directory)
if not category_folder or (fixed_category and not library):
return root
category_paths = (
(self.resolve_directory_category(directory).path,) if fixed_category
else self.classification_category_paths(media_type)
)
roots = [root]
for category_path in category_paths:
if not category_path:
continue
category_path = category_path_below_media_type(
category_path, media_type, type_folder_enabled=bool(type_folder),
)
category_root = root.joinpath(*category_path)
if file_path.is_relative_to(category_root):
roots.append(category_root)
if len(roots) > 1:
return max(roots, key=lambda path: len(path.parts))
# 分类策略可能已改名或删除,不能因此把原分类目录当成影片目录清理。
relative_parts = file_path.relative_to(root).parts
return root / relative_parts[0] if relative_parts else root
def get_dir(self, media: Optional[DirectoryMedia], include_unsorted: Optional[bool] = False,
storage: Optional[str] = None, src_path: Optional[Path] = None,
target_storage: Optional[str] = None, dest_path: Optional[Path] = None
) -> Optional[_SchemaTransferDirectoryConf]:
"""
根据媒体信息获取下载目录、媒体库目录配置
:param media: 媒体信息
:param include_unsorted: 包含不整理目录
:param storage: 源存储类型
:param target_storage: 目标存储类型
:param src_path: 源目录,有值时直接匹配
:param dest_path: 目标目录,有值时直接匹配
"""
dirs = self.get_dirs()
# 如果存在源目录,并源目录为任一下载目录的子目录时,则进行源目录匹配,否则,允许源目录按同盘优先的逻辑匹配
matching_dirs = [
d for d in dirs
if src_path and d.download_path and src_path.is_relative_to(d.download_path)
]
dirs_to_consider = matching_dirs if matching_dirs else dirs
matched_dirs: list[tuple[int, int, _SchemaTransferDirectoryConf]] = []
for index, d in enumerate(dirs_to_consider):
if not d.monitor_type and not include_unsorted:
continue
if storage and d.storage != storage:
continue
if target_storage and d.library_storage != target_storage:
continue
if dest_path and d.library_path and dest_path != Path(d.library_path):
continue
rank = self.media_match_rank(d, media)
if rank is not None:
matched_dirs.append((rank, index, d))
continue
if matched_dirs:
best_rank = min(rank for rank, _, _ in matched_dirs)
best_dirs = [
(index, directory)
for rank, index, directory in matched_dirs
if rank == best_rank
]
best_dirs.sort(
key=lambda item: (
item[1].priority if item[1].priority is not None else 0,
item[0],
)
)
if src_path:
# 同一匹配等级内优先源目录同盘,不能盖过更精确的分类引用。
for _, matched_dir in best_dirs:
if not matched_dir.download_path:
continue
matched_path = Path(matched_dir.download_path)
if self._is_same_source(
(src_path, storage or "local"),
(matched_path, matched_dir.library_storage or "local"),
):
return matched_dir
return best_dirs[0][1]
return None
def matches_media(
self,
directory: _SchemaTransferDirectoryConf,
media: Optional[DirectoryMedia],
) -> bool:
"""
按媒体类型和稳定分类引用判断目录是否适用。
"""
return self.media_match_rank(directory, media) is not None
def media_match_rank(
self,
directory: _SchemaTransferDirectoryConf,
media: Optional[DirectoryMedia],
*,
allow_stale_reference: bool = False,
) -> Optional[int]:
"""
返回目录匹配等级;数值越小越精确,不匹配时返回空值。
"""
if media is None or not getattr(media, "type", None):
return 3
media_type = classification_media_type(getattr(media, "type", None))
directory_type = classification_media_type(directory.media_type)
if directory.media_type:
if directory_type and media_type:
if directory_type != media_type:
return None
elif str(directory.media_type) != str(getattr(media, "type", None)):
return None
if not self.has_fixed_category(directory):
return 2 if directory.media_type else 3
directory_reference = self.resolve_directory_category(directory, media)
if (
getattr(directory, "media_category_id", None)
and not directory_reference.stable
and not allow_stale_reference
):
return None
if not directory_reference.usable:
return None
media_reference = self.resolve_media_category(media)
if not media_reference.usable:
return None
if (
(directory_reference.stable or media_reference.stable)
and directory_reference.category_id
and media_reference.category_id
):
if directory_reference.category_id != media_reference.category_id:
return None
return 0
return 1 if directory_reference.path == media_reference.path else None
def resolve_media_category(
self,
media: Optional[DirectoryMedia],
) -> ClassificationCategoryResolution:
"""
解析媒体当前生效的稳定分类引用和安全路径。
"""
resolution = self._classification_resolver.resolve_media(media)
self._log_category_downgrade("媒体", resolution)
return resolution
def resolve_directory_category(
self,
directory: _SchemaTransferDirectoryConf,
media: Optional[DirectoryMedia] = None,
) -> ClassificationCategoryResolution:
"""
解析目录固定分类 ID,并在必要时显式降级到路径快照。
"""
resolution = self._classification_resolver.resolve(
category_id=getattr(directory, "media_category_id", None),
path_snapshot=getattr(directory, "media_category", None),
media_type=(
directory.media_type
or (getattr(media, "type", None) if media is not None else None)
),
)
self._log_category_downgrade(
f"目录 {directory.name or directory.download_path or directory.library_path or ''}".strip(),
resolution,
)
return resolution
def category_path_for_directory(
self,
directory: _SchemaTransferDirectoryConf,
media: Optional[DirectoryMedia],
) -> tuple[str, ...]:
"""
返回固定目录引用或媒体自动分类对应的安全相对路径。
"""
if self.has_fixed_category(directory):
return self.resolve_directory_category(directory, media).path
return self.resolve_media_category(media).path
def classification_category_paths(
self,
media_type: object = None,
) -> tuple[tuple[str, ...], ...]:
"""
返回当前活动策略中可用于边界识别的启用分类路径。
"""
return self._classification_resolver.category_paths(media_type)
@staticmethod
def has_fixed_category(directory: _SchemaTransferDirectoryConf) -> bool:
"""
返回目录是否绑定了稳定 ID 或兼容路径分类。
"""
return bool(
getattr(directory, "media_category_id", None)
or getattr(directory, "media_category", None)
)
@staticmethod
def _log_category_downgrade(
owner: str,
resolution: ClassificationCategoryResolution,
) -> None:
"""
把 ID 失效和不安全路径转为明确日志,不静默扩大目录匹配。
"""
if resolution.downgraded and resolution.message:
suffix = "" if resolution.usable else ";该分类引用不参与目录匹配"
logger.warning(f"{owner}分类引用降级:{resolution.message}{suffix}")
@staticmethod
def _is_same_source(src: Tuple[Path, str], tar: Tuple[Path, str]) -> bool:
"""
判断源目录和目标目录是否在同一存储盘
:param src: 源目录路径和存储类型
:param tar: 目标目录路径和存储类型
:return: 是否在同一存储盘
"""
src_path, src_storage = src
tar_path, tar_storage = tar
if "local" == tar_storage == src_storage:
return _is_same_local_disk(src_path, tar_path)
return src_storage == tar_storage
@staticmethod
def get_media_root_path(
rename_format: str,
rename_path: Path,
media_type: Optional[MediaType] = None,
) -> Optional[Path]:
"""
获取重命名后的媒体文件根路径
:param rename_format: 重命名格式
:param rename_path: 重命名后的路径
:param media_type: 媒体类型;音乐需要避开可选碟片目录并返回专辑目录
:return: 媒体文件根路径
"""
if not rename_format:
logger.error("重命名格式不能为空")
return None
if media_type == MediaType.MUSIC:
# 音乐模板允许按多碟动态增加 Disc 子目录,不能按静态模板层数反推。
# 文件的直接父目录通常就是专辑目录;命中碟片目录时再上移一级。
media_root = rename_path.parent
if re.fullmatch(
r"(?:cd|disc|disk)\s*0*\d+",
media_root.name,
re.IGNORECASE,
):
media_root = media_root.parent
return media_root
rename_list = rename_format.split("/")
rename_format_level = len(rename_list) - 1
for level, name in enumerate(reversed(rename_list)):
if level == 0:
# 跳过文件名的标题参数
continue
matchs = JINJA2_VAR_PATTERN.findall(name)
if not matchs:
continue
# 处理特例,有的人重命名的第一层是年份、分辨率
if (any("title" in m for m in matchs)
and not any("season" in m for m in matchs)):
# 找出最后一层含有标题且不含季参数的目录作为媒体根目录
rename_format_level = level
break
else:
# 假定第一层目录是媒体根目录
logger.warn(f"重命名格式 {rename_format} 缺少标题目录")
if rename_format_level > len(rename_path.parents):
# 通常因为路径以/结尾,被Path规范化删除了
logger.error(f"路径 {rename_path} 不匹配重命名格式 {rename_format}")
return None
if rename_format_level <= 0:
# 所有媒体文件都存在一个目录内的特殊需求
rename_format_level = 1
media_root = rename_path.parents[rename_format_level - 1]
return media_root
def _split_file_uri(value: str) -> Tuple[str, str]:
"""
拆分 FileURI 字符串,保留原始路径用于安全校验。
"""
for storage in StorageSchema:
protocol = f"{storage.value}:"
if value.startswith(protocol):
return storage.value, value[len(protocol):]
return "local", value
def _normalize_safe_posix_path(raw_path: str) -> PurePosixPath:
"""
规范化保存目录路径,并拒绝跨目录或跨平台歧义写法。
"""
if not raw_path:
raise ValueError("保存路径不能为空")
if "\\" in raw_path:
raise ValueError("保存路径不能包含反斜杠")
if raw_path.startswith("//"):
raise ValueError("保存路径不能使用 UNC 路径")
if WINDOWS_DRIVE_PATTERN.match(raw_path):
raise ValueError("保存路径不能使用 Windows 盘符路径")
if not raw_path.startswith("/"):
raise ValueError("保存路径必须是绝对路径")
path = PurePosixPath(raw_path)
parts = [part for part in path.parts if part != "/"]
if ".." in parts:
raise ValueError("保存路径不能包含上级目录")
if parts and re.fullmatch(r"[A-Za-z]:", parts[0]):
raise ValueError("保存路径不能使用 Windows 盘符路径")
return path
def _normalize_safe_windows_path(raw_path: str) -> PureWindowsPath:
"""
规范化已配置的 Windows 盘符路径;UNC 与反斜杠写法不参与下载目录 allowlist。
"""
if not raw_path:
raise ValueError("保存路径不能为空")
if "\\" in raw_path:
raise ValueError("保存路径不能包含反斜杠")
if raw_path.startswith("//"):
raise ValueError("保存路径不能使用 UNC 路径")
if not WINDOWS_DRIVE_PATTERN.match(raw_path):
raise ValueError("保存路径必须是 Windows 绝对路径")
path = PureWindowsPath(raw_path)
if ".." in path.parts:
raise ValueError("保存路径不能包含上级目录")
return path
def _normalize_download_path(raw_path: str, storage: str) -> Tuple[str, PurePath]:
"""
按存储类型解析下载路径,本地允许 POSIX 或已配置的 Windows drive,远端保持 FileURI POSIX 语义。
"""
path_value = str(raw_path or "").strip()
if storage == "local" and WINDOWS_DRIVE_PREFIX_PATTERN.match(path_value):
return "windows", _normalize_safe_windows_path(path_value)
return "posix", _normalize_safe_posix_path(path_value)
def _download_path_uri(storage: str, path: PurePath) -> str:
"""
生成可传给下载器的 save_path,保持 /download/paths 暴露的本地和远端路径风格。
"""
path_value = path.as_posix()
if storage == "local":
return path_value
return _SchemaFileURI(storage=storage, path=path_value).uri
def _normalize_download_root(dir_info: _SchemaTransferDirectoryConf) -> Optional[Tuple[str, str, PurePath]]:
"""
读取下载目录配置中的根路径;无效配置不参与用户 save_path allowlist。
"""
if not dir_info.download_path:
return None
storage = dir_info.storage or "local"
try:
path_style, root_path = _normalize_download_path(dir_info.download_path, storage)
return storage, path_style, root_path
except ValueError as err:
logger.warn(f"跳过无效下载目录配置:{str(err)}")
return None
def _has_storage_prefix(value: str) -> bool:
"""
判断输入是否带 <storage>: 前缀。
"""
return any(value.startswith(f"{item.value}:") for item in StorageSchema)
def _looks_like_download_path(value: str) -> bool:
"""
判断输入是否带路径特征;只有不带特征的字符串才按「目录别名不存在」报错。
"""
if "/" in value or WINDOWS_DRIVE_PREFIX_PATTERN.match(value):
return True
return _has_storage_prefix(value)
def _resolve_download_dir_alias(
value: str,
download_dirs: List[_SchemaTransferDirectoryConf],
) -> Optional[str]:
"""
按目录名称(别名)精确匹配下载目录,返回该目录根的 save_path。
调用方保证 value 已去空白且非空;重名目录沿用 get_download_dirs 的 priority 顺序取首个。
"""
for dir_info in download_dirs:
if (dir_info.name or "").strip() != value:
continue
root = _normalize_download_root(dir_info)
if root:
storage, _, root_path = root
return _download_path_uri(storage, root_path)
return None
def _validate_download_save_path_value(
value: str,
download_dirs: List[_SchemaTransferDirectoryConf],
) -> str:
"""
按路径规则校验已去空白的保存目录,不涉及别名。
"""
has_storage_prefix = _has_storage_prefix(value)
storage, raw_path = _split_file_uri(value)
target_style, target_path = _normalize_download_path(raw_path, storage)
download_roots = []
for dir_info in download_dirs:
root = _normalize_download_root(dir_info)
if root:
download_roots.append(root)
for root_storage, root_style, root_path in download_roots:
if storage != root_storage:
continue
if target_style != root_style:
continue
if target_path == root_path or target_path.is_relative_to(root_path):
return _download_path_uri(storage, target_path)
# 旧版订阅界面只持久化 download_path,需要从已配置根目录恢复远程存储类型。
if (not has_storage_prefix
and storage == StorageSchema.Local.value
and target_style == "posix"):
for root_storage, root_style, root_path in download_roots:
if root_storage == StorageSchema.Local.value or target_style != root_style:
continue
if target_path == root_path or target_path.is_relative_to(root_path):
return _download_path_uri(root_storage, target_path)
raise ValueError("保存路径不在允许的下载目录范围内")
def specified_download_save_path(save_path: Optional[str]) -> Optional[str]:
"""
返回调用方真正指定的下载保存目录,空白值视为未指定。
旧版订阅编辑界面会把清空的保存目录持久化为空字符串,它表示使用默认下载目录,
不能当作非法路径交给 validate_download_save_path 拒绝。
:param save_path: 订阅、事件或接口传入的保存目录
:return: 原样保留的非空保存目录;空值或纯空白返回 None
"""
if save_path is None or not str(save_path).strip():
return None
return save_path
def validate_download_save_path(save_path: str) -> str:
"""
校验用户传入的下载保存目录,/download/paths 暴露的下载目录配置是允许写入的公共合同。
路径优先:先按路径规则校验;失败后再用目录名称(别名)精确匹配,命中返回该目录根路径,
结果与直接传入该目录根路径的校验结果一致。空值不参与别名匹配。
:param save_path: 下载保存目录,支持本地 /path、远端 <storage>:/path、旧版订阅中的无前缀远程路径,
以及已配置下载目录的名称(别名)
:return: 可直接传给下载接口的规范化保存目录
"""
value = str(save_path or "").strip()
download_dirs = DirectoryHelper().get_download_dirs()
try:
return _validate_download_save_path_value(value, download_dirs)
except ValueError as path_error:
if not value:
raise
alias_uri = _resolve_download_dir_alias(value, download_dirs)
if alias_uri:
return alias_uri
if _looks_like_download_path(value):
raise
raise ValueError(f"未找到名为「{value}」的下载目录") from path_error