mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 08:57:09 +08:00
fix https://github.com/jxxghp/MoviePilot/issues/6205
This commit is contained in:
+14
-1
@@ -1,7 +1,10 @@
|
|||||||
from typing import Dict, List, Optional, Type, TypeVar, Generic, Iterator
|
from typing import Dict, List, Optional, Type, TypeVar, Generic, Iterator
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from app.core.module import ModuleManager
|
from app.core.module import ModuleManager
|
||||||
from app.db.systemconfig_oper import SystemConfigOper
|
from app.db.systemconfig_oper import SystemConfigOper
|
||||||
|
from app.log import logger
|
||||||
from app.schemas import DownloaderConf, MediaServerConf, NotificationConf, NotificationSwitchConf, ServiceInfo
|
from app.schemas import DownloaderConf, MediaServerConf, NotificationConf, NotificationSwitchConf, ServiceInfo
|
||||||
from app.schemas.types import NotificationType, SystemConfigKey, ModuleType
|
from app.schemas.types import NotificationType, SystemConfigKey, ModuleType
|
||||||
|
|
||||||
@@ -25,8 +28,18 @@ class ServiceConfigHelper:
|
|||||||
config_data = SystemConfigOper().get(config_key)
|
config_data = SystemConfigOper().get(config_key)
|
||||||
if not config_data:
|
if not config_data:
|
||||||
return []
|
return []
|
||||||
|
configs = []
|
||||||
|
for conf in config_data:
|
||||||
|
if not isinstance(conf, dict):
|
||||||
|
logger.warn(f"{config_key.value} 配置格式不正确,已跳过:{conf}")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
# 直接使用 conf_type 来实例化配置对象
|
# 直接使用 conf_type 来实例化配置对象
|
||||||
return [conf_type(**conf) for conf in config_data]
|
configs.append(conf_type(**conf))
|
||||||
|
except ValidationError as e:
|
||||||
|
# 单条配置存在非法值时跳过,避免影响其它服务的初始化
|
||||||
|
logger.error(f"{config_key.value} 配置 {conf.get('name')} 校验失败,已跳过:{e}")
|
||||||
|
return configs
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_downloader_configs() -> List[DownloaderConf]:
|
def get_downloader_configs() -> List[DownloaderConf]:
|
||||||
|
|||||||
+16
-2
@@ -1,9 +1,13 @@
|
|||||||
|
import re
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from app.schemas.types import StorageSchema
|
from app.schemas.types import StorageSchema
|
||||||
|
|
||||||
|
# Windows 盘符绝对路径,如 Z:/Downloads 或 Z:\Downloads
|
||||||
|
WINDOWS_DRIVE_PATTERN = re.compile(r"^[A-Za-z]:[\\/]")
|
||||||
|
|
||||||
|
|
||||||
class FileURI(BaseModel):
|
class FileURI(BaseModel):
|
||||||
# 文件路径
|
# 文件路径
|
||||||
@@ -13,10 +17,19 @@ class FileURI(BaseModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def uri(self) -> str:
|
def uri(self) -> str:
|
||||||
|
"""
|
||||||
|
文件 URI,本地存储直接返回路径,其他存储带上存储前缀
|
||||||
|
"""
|
||||||
return self.path if self.storage == "local" else f"{self.storage}:{self.path}"
|
return self.path if self.storage == "local" else f"{self.storage}:{self.path}"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_uri(cls, uri: str) -> "FileURI":
|
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
|
storage, path = 'local', uri
|
||||||
for s in StorageSchema:
|
for s in StorageSchema:
|
||||||
protocol = f"{s.value}:"
|
protocol = f"{s.value}:"
|
||||||
@@ -24,11 +37,13 @@ class FileURI(BaseModel):
|
|||||||
path = uri[len(protocol):]
|
path = uri[len(protocol):]
|
||||||
storage = s.value
|
storage = s.value
|
||||||
break
|
break
|
||||||
if not path.startswith("/"):
|
# Windows 盘符路径本身就是绝对路径,补上根斜杠会得到 /Z:/xxx 这样的非法路径
|
||||||
|
if not path.startswith("/") and not WINDOWS_DRIVE_PATTERN.match(path):
|
||||||
path = "/" + path
|
path = "/" + path
|
||||||
path = Path(path).as_posix()
|
path = Path(path).as_posix()
|
||||||
return cls(storage=storage, path=path)
|
return cls(storage=storage, path=path)
|
||||||
|
|
||||||
|
|
||||||
class FileItem(FileURI):
|
class FileItem(FileURI):
|
||||||
# 类型 dir/file
|
# 类型 dir/file
|
||||||
type: Optional[str] = None
|
type: Optional[str] = None
|
||||||
@@ -68,4 +83,3 @@ class StorageUsage(BaseModel):
|
|||||||
class StorageTransType(BaseModel):
|
class StorageTransType(BaseModel):
|
||||||
# 传输类型
|
# 传输类型
|
||||||
transtype: Optional[dict] = Field(default_factory=dict)
|
transtype: Optional[dict] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|||||||
+21
-1
@@ -1,7 +1,7 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -40,6 +40,26 @@ class MediaServerConf(BaseModel):
|
|||||||
# 自动同步间隔(小时),未设置时使用旧全局配置
|
# 自动同步间隔(小时),未设置时使用旧全局配置
|
||||||
sync_interval: Optional[int] = None
|
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):
|
class DownloaderConf(BaseModel):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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"]
|
||||||
Reference in New Issue
Block a user