diff --git a/app/helper/service.py b/app/helper/service.py index 2fb71056..193a2ba9 100644 --- a/app/helper/service.py +++ b/app/helper/service.py @@ -1,7 +1,10 @@ from typing import Dict, List, Optional, Type, TypeVar, Generic, Iterator +from pydantic import ValidationError + from app.core.module import ModuleManager from app.db.systemconfig_oper import SystemConfigOper +from app.log import logger from app.schemas import DownloaderConf, MediaServerConf, NotificationConf, NotificationSwitchConf, ServiceInfo from app.schemas.types import NotificationType, SystemConfigKey, ModuleType @@ -25,8 +28,18 @@ class ServiceConfigHelper: config_data = SystemConfigOper().get(config_key) if not config_data: return [] - # 直接使用 conf_type 来实例化配置对象 - return [conf_type(**conf) for conf in config_data] + configs = [] + for conf in config_data: + if not isinstance(conf, dict): + logger.warn(f"{config_key.value} 配置格式不正确,已跳过:{conf}") + continue + try: + # 直接使用 conf_type 来实例化配置对象 + configs.append(conf_type(**conf)) + except ValidationError as e: + # 单条配置存在非法值时跳过,避免影响其它服务的初始化 + logger.error(f"{config_key.value} 配置 {conf.get('name')} 校验失败,已跳过:{e}") + return configs @staticmethod def get_downloader_configs() -> List[DownloaderConf]: diff --git a/app/schemas/file.py b/app/schemas/file.py index ae21466e..fccc5ee4 100644 --- a/app/schemas/file.py +++ b/app/schemas/file.py @@ -1,9 +1,13 @@ +import re from typing import Optional from pathlib import Path from pydantic import BaseModel, Field from app.schemas.types import StorageSchema +# Windows 盘符绝对路径,如 Z:/Downloads 或 Z:\Downloads +WINDOWS_DRIVE_PATTERN = re.compile(r"^[A-Za-z]:[\\/]") + class FileURI(BaseModel): # 文件路径 @@ -13,10 +17,19 @@ class FileURI(BaseModel): @property def uri(self) -> str: + """ + 文件 URI,本地存储直接返回路径,其他存储带上存储前缀 + """ return self.path if self.storage == "local" else f"{self.storage}:{self.path}" @classmethod def from_uri(cls, uri: str) -> "FileURI": + """ + 解析文件 URI 为存储类型和路径 + + :param uri: 文件 URI,如 /media/movie、u115:/media/movie 或 Windows 盘符路径 Z:/media + :return: FileURI 对象 + """ storage, path = 'local', uri for s in StorageSchema: protocol = f"{s.value}:" @@ -24,11 +37,13 @@ class FileURI(BaseModel): path = uri[len(protocol):] storage = s.value break - if not path.startswith("/"): + # Windows 盘符路径本身就是绝对路径,补上根斜杠会得到 /Z:/xxx 这样的非法路径 + if not path.startswith("/") and not WINDOWS_DRIVE_PATTERN.match(path): path = "/" + path path = Path(path).as_posix() return cls(storage=storage, path=path) + class FileItem(FileURI): # 类型 dir/file type: Optional[str] = None @@ -68,4 +83,3 @@ class StorageUsage(BaseModel): class StorageTransType(BaseModel): # 传输类型 transtype: Optional[dict] = Field(default_factory=dict) - diff --git a/app/schemas/system.py b/app/schemas/system.py index 056cac97..1f3f1f0f 100644 --- a/app/schemas/system.py +++ b/app/schemas/system.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import Optional, Any -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator @dataclass @@ -40,6 +40,26 @@ class MediaServerConf(BaseModel): # 自动同步间隔(小时),未设置时使用旧全局配置 sync_interval: Optional[int] = None + @field_validator("sync_interval", mode="before") + @classmethod + def validate_sync_interval(cls, value: Any) -> Optional[int]: + """ + 兼容前端清空输入框后残留的空字符串等非法值,避免历史配置导致模块初始化失败 + + :param value: 原始配置值 + :return: 合法的间隔小时数,无法解析时返回 None + """ + if value is None: + return None + if isinstance(value, str): + value = value.strip() + if not value: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + class DownloaderConf(BaseModel): """ diff --git a/tests/test_file_uri.py b/tests/test_file_uri.py new file mode 100644 index 00000000..a0f768fc --- /dev/null +++ b/tests/test_file_uri.py @@ -0,0 +1,50 @@ +from app.schemas.file import FileURI + + +def test_from_uri_keeps_windows_drive_path() -> None: + """Windows 盘符路径已是绝对路径,不能再补根斜杠,否则映射网络驱动器整理会报 WinError 123。""" + file_uri = FileURI.from_uri("Z:/Downloads/电视剧/国产剧") + + assert file_uri.storage == "local" + assert file_uri.path == "Z:/Downloads/电视剧/国产剧" + assert file_uri.uri == "Z:/Downloads/电视剧/国产剧" + + +def test_from_uri_keeps_windows_drive_path_with_backslash() -> None: + """反斜杠写法的盘符路径同样不能补根斜杠。""" + file_uri = FileURI.from_uri("Z:\\Downloads\\电视剧") + + assert file_uri.storage == "local" + assert not file_uri.path.startswith("/") + + +def test_from_uri_keeps_posix_absolute_path() -> None: + """POSIX 绝对路径保持原样。""" + file_uri = FileURI.from_uri("/downloads/movies") + + assert file_uri.storage == "local" + assert file_uri.path == "/downloads/movies" + + +def test_from_uri_adds_root_for_relative_path() -> None: + """无前导斜杠的相对路径仍补全为绝对路径。""" + file_uri = FileURI.from_uri("downloads/movies") + + assert file_uri.path == "/downloads/movies" + + +def test_from_uri_parses_storage_prefix() -> None: + """带存储前缀的 URI 应拆分出存储类型并保留 POSIX 路径。""" + file_uri = FileURI.from_uri("u115:/media/anime") + + assert file_uri.storage == "u115" + assert file_uri.path == "/media/anime" + assert file_uri.uri == "u115:/media/anime" + + +def test_from_uri_storage_prefix_with_relative_path() -> None: + """远端存储的无前导斜杠路径补全为绝对路径。""" + file_uri = FileURI.from_uri("rclone:media/anime") + + assert file_uri.storage == "rclone" + assert file_uri.path == "/media/anime" diff --git a/tests/test_mediaserver_conf_sync_interval.py b/tests/test_mediaserver_conf_sync_interval.py new file mode 100644 index 00000000..ba91ac74 --- /dev/null +++ b/tests/test_mediaserver_conf_sync_interval.py @@ -0,0 +1,29 @@ +from app.helper.service import ServiceConfigHelper +from app.schemas.system import MediaServerConf +from app.schemas.types import SystemConfigKey + + +def test_mediaserver_conf_tolerates_blank_sync_interval(): + """自动同步间隔为空字符串等非法值时应回退为 None 而不是抛出校验错误。""" + assert MediaServerConf(name="blank", sync_interval="").sync_interval is None + assert MediaServerConf(name="spaces", sync_interval=" ").sync_interval is None + assert MediaServerConf(name="invalid", sync_interval="abc").sync_interval is None + assert MediaServerConf(name="text", sync_interval="12").sync_interval == 12 + assert MediaServerConf(name="number", sync_interval=6).sync_interval == 6 + assert MediaServerConf(name="none", sync_interval=None).sync_interval is None + + +def test_get_configs_skips_invalid_entries(monkeypatch): + """单条配置校验失败时应跳过该条,不影响其它服务配置的加载。""" + monkeypatch.setattr( + "app.helper.service.SystemConfigOper.get", + lambda self, key: [ + {"name": "good", "type": "emby", "enabled": True}, + "bad-format", + {"name": "bad-type", "type": "plex", "enabled": "maybe"}, + ], + ) + + configs = ServiceConfigHelper.get_configs(SystemConfigKey.MediaServers, MediaServerConf) + + assert [conf.name for conf in configs] == ["good"]