mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
refactor backend module architecture
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""不依赖 MoviePilot 业务与运行配置的通用基础能力。"""
|
||||
@@ -0,0 +1,213 @@
|
||||
import base64
|
||||
import hashlib
|
||||
from hashlib import md5
|
||||
from typing import Union, Optional, Tuple
|
||||
|
||||
from Crypto import Random
|
||||
from Crypto.Cipher import AES
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding as asym_padding, rsa
|
||||
|
||||
|
||||
class RSAUtils:
|
||||
"""提供 RSA 密钥生成、校验和加解密辅助能力。"""
|
||||
|
||||
@staticmethod
|
||||
def generate_rsa_key_pair(key_size: int = 2048) -> Tuple[str, str]:
|
||||
"""
|
||||
生成RSA密钥对
|
||||
:return: 私钥和公钥(Base64 编码,无标识符)
|
||||
"""
|
||||
# 生成RSA密钥对
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=key_size,
|
||||
)
|
||||
|
||||
public_key = private_key.public_key()
|
||||
|
||||
# 导出私钥为DER格式
|
||||
private_key_der = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
)
|
||||
|
||||
# 导出公钥为DER格式
|
||||
public_key_der = public_key.public_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
|
||||
# 将DER格式的密钥编码为Base64
|
||||
private_key_b64 = base64.b64encode(private_key_der).decode("utf-8")
|
||||
public_key_b64 = base64.b64encode(public_key_der).decode("utf-8")
|
||||
|
||||
return private_key_b64, public_key_b64
|
||||
|
||||
@staticmethod
|
||||
def verify_rsa_keys(private_key: Optional[str], public_key: Optional[str]) -> bool:
|
||||
"""
|
||||
使用 RSA 验证私钥和公钥是否匹配
|
||||
|
||||
:param private_key: 私钥字符串 (Base64 编码,无标识符)
|
||||
:param public_key: 公钥字符串 (Base64 编码,无标识符)
|
||||
:return: 如果匹配则返回 True,否则返回 False
|
||||
"""
|
||||
if not private_key or not public_key:
|
||||
return False
|
||||
|
||||
try:
|
||||
# 解码 Base64 编码的公钥和私钥
|
||||
public_key_bytes = base64.b64decode(public_key)
|
||||
private_key_bytes = base64.b64decode(private_key)
|
||||
|
||||
# 加载公钥
|
||||
public_key = serialization.load_der_public_key(public_key_bytes, backend=default_backend())
|
||||
|
||||
# 加载私钥
|
||||
private_key = serialization.load_der_private_key(private_key_bytes, password=None,
|
||||
backend=default_backend())
|
||||
|
||||
# 测试加解密
|
||||
message = b'test'
|
||||
encrypted_message = public_key.encrypt(
|
||||
message,
|
||||
asym_padding.OAEP(
|
||||
mgf=asym_padding.MGF1(algorithm=hashes.SHA256()),
|
||||
algorithm=hashes.SHA256(),
|
||||
label=None
|
||||
)
|
||||
)
|
||||
|
||||
decrypted_message = private_key.decrypt(
|
||||
encrypted_message,
|
||||
asym_padding.OAEP(
|
||||
mgf=asym_padding.MGF1(algorithm=hashes.SHA256()),
|
||||
algorithm=hashes.SHA256(),
|
||||
label=None
|
||||
)
|
||||
)
|
||||
|
||||
return message == decrypted_message
|
||||
except Exception as e:
|
||||
print(f"RSA 密钥验证失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class HashUtils:
|
||||
"""提供常用摘要算法的统一字符串和字节接口。"""
|
||||
|
||||
@staticmethod
|
||||
def md5(data: Union[str, bytes], encoding: str = "utf-8") -> str:
|
||||
"""
|
||||
生成数据的MD5哈希值,并以字符串形式返回
|
||||
|
||||
:param data: 输入的数据,类型为字符串
|
||||
:param encoding: 字符串编码类型,默认使用UTF-8
|
||||
:return: 生成的MD5哈希字符串
|
||||
"""
|
||||
if isinstance(data, str):
|
||||
data = data.encode(encoding)
|
||||
return hashlib.md5(data).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def sha1(data: Union[str, bytes], encoding: str = "utf-8") -> str:
|
||||
"""
|
||||
生成数据的SHA-1哈希值,并以字符串形式返回
|
||||
|
||||
:param data: 输入的数据,类型为字符串或字节
|
||||
:param encoding: 字符串编码类型,默认使用UTF-8
|
||||
:return: 生成的SHA-1哈希字符串
|
||||
"""
|
||||
if isinstance(data, str):
|
||||
data = data.encode(encoding)
|
||||
return hashlib.sha1(data).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def md5_bytes(data: Union[str, bytes], encoding: str = "utf-8") -> bytes:
|
||||
"""
|
||||
生成数据的MD5哈希值,并以字节形式返回
|
||||
|
||||
:param data: 输入的数据,类型为字符串
|
||||
:param encoding: 字符串编码类型,默认使用UTF-8
|
||||
:return: 生成的MD5哈希二进制数据
|
||||
"""
|
||||
if isinstance(data, str):
|
||||
data = data.encode(encoding)
|
||||
return hashlib.md5(data).digest()
|
||||
|
||||
|
||||
class CryptoJsUtils:
|
||||
"""兼容 CryptoJS OpenSSL 格式的 AES 加解密工具。"""
|
||||
|
||||
|
||||
@staticmethod
|
||||
def bytes_to_key(data: bytes, salt: bytes, output=48) -> bytes:
|
||||
"""
|
||||
生成加密/解密所需的密钥和初始化向量 (IV)
|
||||
"""
|
||||
# extended from https://gist.github.com/gsakkis/4546068
|
||||
assert len(salt) == 8, len(salt)
|
||||
data += salt
|
||||
key = md5(data).digest()
|
||||
final_key = key
|
||||
while len(final_key) < output:
|
||||
key = md5(key + data).digest()
|
||||
final_key += key
|
||||
return final_key[:output]
|
||||
|
||||
@staticmethod
|
||||
def encrypt(message: bytes, passphrase: bytes) -> bytes:
|
||||
"""
|
||||
使用 CryptoJS 兼容的加密策略对消息进行加密
|
||||
"""
|
||||
# This is a modified copy of https://stackoverflow.com/questions/36762098/how-to-decrypt-password-from-javascript-cryptojs-aes-encryptpassword-passphras
|
||||
# 生成8字节的随机盐值
|
||||
salt = Random.new().read(8)
|
||||
# 通过密码短语和盐值生成密钥和IV
|
||||
key_iv = CryptoJsUtils.bytes_to_key(passphrase, salt, 32 + 16)
|
||||
key = key_iv[:32]
|
||||
iv = key_iv[32:]
|
||||
# 创建AES加密器(CBC模式)
|
||||
aes = AES.new(key, AES.MODE_CBC, iv)
|
||||
# 应用PKCS#7填充
|
||||
padding_length = 16 - (len(message) % 16)
|
||||
padding = bytes([padding_length] * padding_length)
|
||||
padded_message = message + padding
|
||||
# 加密消息
|
||||
encrypted = aes.encrypt(padded_message)
|
||||
# 构建加密数据格式:b"Salted__" + salt + encrypted_message
|
||||
salted_encrypted = b"Salted__" + salt + encrypted
|
||||
# 返回Base64编码的加密数据
|
||||
return base64.b64encode(salted_encrypted)
|
||||
|
||||
@staticmethod
|
||||
def decrypt(encrypted: Union[str, bytes], passphrase: bytes) -> bytes:
|
||||
"""
|
||||
使用 CryptoJS 兼容的解密策略对加密消息进行解密
|
||||
"""
|
||||
# 确保输入是字节类型
|
||||
if isinstance(encrypted, str):
|
||||
encrypted = encrypted.encode("utf-8")
|
||||
# Base64 解码
|
||||
encrypted = base64.b64decode(encrypted)
|
||||
# 检查前8字节是否为 "Salted__"
|
||||
assert encrypted.startswith(b"Salted__"), "Invalid encrypted data format"
|
||||
# 提取盐值
|
||||
salt = encrypted[8:16]
|
||||
# 通过密码短语和盐值生成密钥和IV
|
||||
key_iv = CryptoJsUtils.bytes_to_key(passphrase, salt, 32 + 16)
|
||||
key = key_iv[:32]
|
||||
iv = key_iv[32:]
|
||||
# 创建AES解密器(CBC模式)
|
||||
aes = AES.new(key, AES.MODE_CBC, iv)
|
||||
# 解密加密部分
|
||||
decrypted_padded = aes.decrypt(encrypted[16:])
|
||||
# 移除PKCS#7填充
|
||||
padding_length = decrypted_padded[-1]
|
||||
if isinstance(padding_length, str):
|
||||
padding_length = ord(padding_length)
|
||||
decrypted = decrypted_padded[:-padding_length]
|
||||
return decrypted
|
||||
@@ -0,0 +1,34 @@
|
||||
from typing import Union
|
||||
|
||||
|
||||
class DomUtils:
|
||||
"""提供 XML DOM 节点读取和创建辅助能力。"""
|
||||
|
||||
@staticmethod
|
||||
def tag_value(tag_item, tag_name: str, attname: str = "", default: Union[str, int] = None):
|
||||
"""
|
||||
解析XML标签值
|
||||
"""
|
||||
tagNames = tag_item.getElementsByTagName(tag_name)
|
||||
if tagNames:
|
||||
if attname:
|
||||
attvalue = tagNames[0].getAttribute(attname)
|
||||
if attvalue:
|
||||
return attvalue
|
||||
else:
|
||||
firstChild = tagNames[0].firstChild
|
||||
if firstChild:
|
||||
return firstChild.data
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def add_node(doc, parent, name: str, value: str = None):
|
||||
"""
|
||||
添加一个DOM节点
|
||||
"""
|
||||
node = doc.createElement(name)
|
||||
parent.appendChild(node)
|
||||
if value is not None:
|
||||
text = doc.createTextNode(str(value))
|
||||
node.appendChild(text)
|
||||
return node
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
from typing import Optional, Union
|
||||
|
||||
# 后台任务会话使用的内部占位用户ID。
|
||||
# 它只用于在 agent/memory/session 侧标识“系统触发的任务”,
|
||||
# 不能直接作为真实消息接收人下发到 Telegram/企业微信 等通知渠道。
|
||||
SYSTEM_INTERNAL_USER_ID = "system"
|
||||
|
||||
|
||||
def is_internal_user_id(userid: Optional[Union[str, int]]) -> bool:
|
||||
"""
|
||||
判断是否为系统内部占位用户ID。
|
||||
"""
|
||||
return (
|
||||
isinstance(userid, str)
|
||||
and userid.strip().lower() == SYSTEM_INTERNAL_USER_ID
|
||||
)
|
||||
|
||||
|
||||
def normalize_internal_user_id(
|
||||
userid: Optional[Union[str, int]]
|
||||
) -> Optional[Union[str, int]]:
|
||||
"""
|
||||
将系统内部占位用户ID归一化为 None,避免被通知渠道误认为真实接收人。
|
||||
"""
|
||||
if is_internal_user_id(userid):
|
||||
return None
|
||||
return userid
|
||||
@@ -0,0 +1,10 @@
|
||||
"""中文分词工具。"""
|
||||
|
||||
from jieba_next import cut as jieba_next_cut
|
||||
|
||||
|
||||
def cut(text: str, HMM: bool = True, cut_all: bool = False) -> list[str]:
|
||||
"""
|
||||
使用 jieba-next 执行中文分词,并兼容 jieba.cut 的常用参数名。
|
||||
"""
|
||||
return list(jieba_next_cut(text, HMM=HMM, cut_all=cut_all))
|
||||
@@ -0,0 +1,118 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import importlib
|
||||
import pkgutil
|
||||
from pathlib import Path
|
||||
from typing import List, Any, Callable
|
||||
|
||||
FilterFuncType = Callable[[str, Any], bool]
|
||||
|
||||
|
||||
def _default_filter(name: str, obj: Any) -> bool:
|
||||
"""
|
||||
默认过滤器
|
||||
"""
|
||||
return True if name and obj else False
|
||||
|
||||
|
||||
class ModuleHelper:
|
||||
"""
|
||||
模块动态加载
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def load(cls, package_path: str, filter_func: FilterFuncType = _default_filter) -> List[Any]:
|
||||
"""
|
||||
导入模块
|
||||
:param package_path: 父包名
|
||||
:param filter_func: 子模块过滤函数,入参为模块名和模块对象,返回True则导入,否则不导入
|
||||
:return: 导入的模块对象列表
|
||||
"""
|
||||
|
||||
submodules: list = []
|
||||
loaded_modules = set()
|
||||
packages = importlib.import_module(package_path)
|
||||
for importer, package_name, _ in pkgutil.iter_modules(packages.__path__):
|
||||
try:
|
||||
if package_name.startswith('_'):
|
||||
continue
|
||||
full_package_name = f'{package_path}.{package_name}'
|
||||
module = importlib.import_module(full_package_name)
|
||||
importlib.reload(module)
|
||||
for name, obj in module.__dict__.items():
|
||||
if name.startswith('_'):
|
||||
continue
|
||||
if isinstance(obj, type) and filter_func(name, obj):
|
||||
if name in loaded_modules:
|
||||
continue
|
||||
loaded_modules.add(name)
|
||||
submodules.append(obj)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return submodules
|
||||
|
||||
@classmethod
|
||||
def load_with_pre_filter(cls, package_path: str, filter_func: FilterFuncType = _default_filter) -> List[Any]:
|
||||
"""
|
||||
导入子模块
|
||||
:param package_path: 父包名
|
||||
:param filter_func: 子模块过滤函数,入参为模块名和模块对象,返回True则导入,否则不导入
|
||||
:return: 导入的模块对象列表
|
||||
"""
|
||||
|
||||
submodules: list = []
|
||||
packages = importlib.import_module(package_path)
|
||||
|
||||
def reload_module_objects(target_module):
|
||||
"""加载模块并返回对象"""
|
||||
importlib.reload(target_module)
|
||||
# reload后,重新过滤已经重新加载后的模块中的对象
|
||||
return [
|
||||
obj for name, obj in target_module.__dict__.items()
|
||||
if not name.startswith('_') and isinstance(obj, type) and filter_func(name, obj)
|
||||
]
|
||||
|
||||
def reload_sub_modules(parent_module, parent_module_name):
|
||||
"""重新加载一级子模块"""
|
||||
for sub_importer, sub_module_name, sub_is_pkg in pkgutil.walk_packages(parent_module.__path__,
|
||||
parent_module_name + '.'):
|
||||
try:
|
||||
full_sub_module = importlib.import_module(sub_module_name)
|
||||
importlib.reload(full_sub_module)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# 遍历包中的所有子模块
|
||||
for importer, package_name, is_pkg in pkgutil.iter_modules(packages.__path__):
|
||||
if package_name.startswith('_'):
|
||||
continue
|
||||
full_package_name = f'{package_path}.{package_name}'
|
||||
try:
|
||||
module = importlib.import_module(full_package_name)
|
||||
# 预检查模块中的对象
|
||||
candidates = [(name, obj) for name, obj in module.__dict__.items() if
|
||||
not name.startswith('_') and isinstance(obj, type)]
|
||||
# 确定是否需要重新加载
|
||||
if any(filter_func(name, obj) for name, obj in candidates):
|
||||
# 如果子模块是包,重新加载其子模块
|
||||
if is_pkg:
|
||||
reload_sub_modules(module, full_package_name)
|
||||
submodules.extend(reload_module_objects(module))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return submodules
|
||||
|
||||
@staticmethod
|
||||
def dynamic_import_all_modules(base_path: Path, package_name: str):
|
||||
"""
|
||||
动态导入目录下所有模块
|
||||
"""
|
||||
modules = []
|
||||
# 遍历文件夹,找到所有模块文件
|
||||
for file in base_path.glob("*.py"):
|
||||
file_name = file.stem
|
||||
if file_name != "__init__":
|
||||
modules.append(file_name)
|
||||
full_module_name = f"{package_name}.{file_name}"
|
||||
importlib.import_module(full_module_name)
|
||||
@@ -0,0 +1,137 @@
|
||||
import ast
|
||||
import dis
|
||||
import inspect
|
||||
import textwrap
|
||||
from types import FunctionType
|
||||
from typing import Any, Callable, get_type_hints
|
||||
|
||||
|
||||
class ObjectUtils:
|
||||
"""提供对象类型、函数实现和签名检查能力。"""
|
||||
|
||||
@staticmethod
|
||||
def is_obj(obj: Any):
|
||||
"""判断值是否属于可展开的复合对象。"""
|
||||
if isinstance(obj, list) \
|
||||
or isinstance(obj, dict) \
|
||||
or isinstance(obj, tuple):
|
||||
return True
|
||||
elif isinstance(obj, int) \
|
||||
or isinstance(obj, float) \
|
||||
or isinstance(obj, bool) \
|
||||
or isinstance(obj, bytes) \
|
||||
or isinstance(obj, str):
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def is_objstr(obj: Any):
|
||||
"""判断字符串是否以常见复合对象字面量开头。"""
|
||||
if not isinstance(obj, str):
|
||||
return False
|
||||
return str(obj).startswith("{") \
|
||||
or str(obj).startswith("[") \
|
||||
or str(obj).startswith("(")
|
||||
|
||||
@staticmethod
|
||||
def arguments(func: Callable) -> int:
|
||||
"""
|
||||
返回函数的参数个数
|
||||
"""
|
||||
signature = inspect.signature(func)
|
||||
parameters = signature.parameters
|
||||
|
||||
return len(list(parameters.keys()))
|
||||
|
||||
@staticmethod
|
||||
def check_method(func: Callable[..., Any]) -> bool:
|
||||
"""
|
||||
检查函数是否已实现
|
||||
"""
|
||||
try:
|
||||
src = inspect.getsource(func)
|
||||
tree = ast.parse(textwrap.dedent(src))
|
||||
node = tree.body[0]
|
||||
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
return True
|
||||
body = node.body
|
||||
|
||||
for stmt in body:
|
||||
# 跳过 pass
|
||||
if isinstance(stmt, ast.Pass):
|
||||
continue
|
||||
# 跳过 docstring 或 ...
|
||||
if isinstance(stmt, ast.Expr):
|
||||
expr = stmt.value
|
||||
if isinstance(expr, ast.Constant):
|
||||
if isinstance(expr.value, str) or expr.value is Ellipsis:
|
||||
continue
|
||||
# 检查 raise NotImplementedError
|
||||
if isinstance(stmt, ast.Raise):
|
||||
exc = stmt.exc
|
||||
if isinstance(exc, ast.Call) and getattr(exc.func, "id", None) == "NotImplementedError":
|
||||
continue
|
||||
if isinstance(exc, ast.Name) and exc.id == "NotImplementedError":
|
||||
continue
|
||||
|
||||
return True
|
||||
return False
|
||||
except Exception as err:
|
||||
print(err)
|
||||
# 源代码分析失败时,进行字节码分析
|
||||
code_obj = func.__code__ # type: ignore[attr-defined]
|
||||
instructions = list(dis.get_instructions(code_obj))
|
||||
# 检查是否为仅返回None的简单结构
|
||||
if len(instructions) == 2:
|
||||
first, second = instructions
|
||||
if (first.opname == 'LOAD_CONST' and
|
||||
second.opname == 'RETURN_VALUE'):
|
||||
# 验证加载的常量是否为None
|
||||
const_index = first.arg
|
||||
if (const_index < len(code_obj.co_consts) and
|
||||
code_obj.co_consts[const_index] is None):
|
||||
# 未实现的空函数
|
||||
return False
|
||||
# 其他情况认为已实现
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def check_signature(func: FunctionType, *args) -> bool:
|
||||
"""
|
||||
检查输出与函数的参数类型是否一致
|
||||
"""
|
||||
# 获取函数的参数信息
|
||||
signature = inspect.signature(func)
|
||||
parameters = signature.parameters
|
||||
if len(args) != len(parameters):
|
||||
return False
|
||||
try:
|
||||
# 获取解析后的类型提示
|
||||
type_hints = get_type_hints(func)
|
||||
except TypeError:
|
||||
type_hints = {}
|
||||
for arg, (param_name, param) in zip(args, parameters.items()):
|
||||
# 优先使用解析后的类型提示
|
||||
param_type = type_hints.get(param_name, None)
|
||||
if param_type is None:
|
||||
# 处理原始注解(可能为字符串或Cython类型)
|
||||
param_annotation = param.annotation
|
||||
if param_annotation is inspect.Parameter.empty:
|
||||
continue
|
||||
# 处理字符串类型的注解
|
||||
if isinstance(param_annotation, str):
|
||||
# 尝试解析字符串为实际类型
|
||||
module = inspect.getmodule(func)
|
||||
global_vars = module.__dict__ if module else globals()
|
||||
try:
|
||||
param_type = eval(param_annotation, global_vars)
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
continue
|
||||
else:
|
||||
param_type = param_annotation
|
||||
if param_type is None:
|
||||
continue
|
||||
if not isinstance(arg, param_type):
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,70 @@
|
||||
import abc
|
||||
import threading
|
||||
import weakref
|
||||
|
||||
|
||||
class Singleton(abc.ABCMeta, type):
|
||||
"""
|
||||
类单例模式(按参数)
|
||||
"""
|
||||
|
||||
_instances: dict = {}
|
||||
|
||||
def get_existing_instance(cls, *args, **kwargs):
|
||||
"""按相同参数返回已创建实例,不触发初始化"""
|
||||
key = (cls, args, frozenset(kwargs.items()))
|
||||
return cls._instances.get(key)
|
||||
|
||||
def __call__(cls, *args, **kwargs):
|
||||
"""按类和构造参数创建或复用实例。"""
|
||||
key = (cls, args, frozenset(kwargs.items()))
|
||||
if key not in cls._instances:
|
||||
cls._instances[key] = super().__call__(*args, **kwargs)
|
||||
return cls._instances[key]
|
||||
|
||||
|
||||
class AbstractSingleton(abc.ABC, metaclass=Singleton):
|
||||
"""
|
||||
抽像类单例模式
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class SingletonClass(abc.ABCMeta, type):
|
||||
"""
|
||||
类单例模式(按类)
|
||||
"""
|
||||
|
||||
_instances: dict = {}
|
||||
|
||||
def get_existing_instance(cls):
|
||||
"""返回已创建实例,不触发初始化"""
|
||||
return cls._instances.get(cls)
|
||||
|
||||
def __call__(cls, *args, **kwargs):
|
||||
"""按类创建或复用唯一实例。"""
|
||||
if cls not in cls._instances:
|
||||
cls._instances[cls] = super().__call__(*args, **kwargs)
|
||||
return cls._instances[cls]
|
||||
|
||||
|
||||
class AbstractSingletonClass(abc.ABC, metaclass=SingletonClass):
|
||||
"""
|
||||
抽像类单例模式(按类)
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class WeakSingleton(abc.ABCMeta, type):
|
||||
"""
|
||||
弱引用单例模式 - 当没有强引用时自动清理
|
||||
"""
|
||||
_instances: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
|
||||
_lock = threading.RLock()
|
||||
|
||||
def __call__(cls, *args, **kwargs):
|
||||
"""按类创建或复用仍有强引用的实例。"""
|
||||
with cls._lock:
|
||||
if cls not in cls._instances:
|
||||
cls._instances[cls] = super().__call__(*args, **kwargs)
|
||||
return cls._instances[cls]
|
||||
@@ -0,0 +1,82 @@
|
||||
from typing import Dict, List, Set, TypeVar, Any, Union
|
||||
|
||||
K = TypeVar("K")
|
||||
V = TypeVar("V")
|
||||
|
||||
|
||||
class DictUtils:
|
||||
"""提供字典键集合相关的基础操作。"""
|
||||
|
||||
@staticmethod
|
||||
def filter_keys_to_subset(source: Dict[K, V], reference: Dict[K, V]) -> Dict[K, V]:
|
||||
"""
|
||||
过滤 source 字典,使其键成为 reference 字典键的子集
|
||||
|
||||
:param source: 要被过滤的字典
|
||||
:param reference: 参考字典,定义允许的键
|
||||
:return: 过滤后的字典,只包含在 reference 中存在的键
|
||||
"""
|
||||
if not isinstance(source, dict) or not isinstance(reference, dict):
|
||||
return {}
|
||||
|
||||
return {key: value for key, value in source.items() if key in reference}
|
||||
|
||||
@staticmethod
|
||||
def is_keys_subset(source: Dict[K, V], reference: Dict[K, V]) -> bool:
|
||||
"""
|
||||
判断 source 字典的键是否为 reference 字典键的子集
|
||||
|
||||
:param source: 要检查的字典
|
||||
:param reference: 参考字典
|
||||
:return: 如果 source 的键是 reference 的键子集,则返回 True,否则返回 False
|
||||
"""
|
||||
if not isinstance(source, dict) or not isinstance(reference, dict):
|
||||
return False
|
||||
|
||||
return all(key in reference for key in source)
|
||||
|
||||
|
||||
class ListUtils:
|
||||
"""提供列表结构的基础转换能力。"""
|
||||
|
||||
@staticmethod
|
||||
def flatten(nested_list: Union[List[List[Any]], List[Any]]) -> List[Any]:
|
||||
"""
|
||||
将嵌套的列表展平成单个列表
|
||||
|
||||
:param nested_list: 嵌套的列表
|
||||
:return: 展平后的列表
|
||||
"""
|
||||
if not isinstance(nested_list, list):
|
||||
return []
|
||||
|
||||
# 检查是否嵌套,若不嵌套直接返回
|
||||
if not any(isinstance(sublist, list) for sublist in nested_list):
|
||||
return nested_list
|
||||
|
||||
return [
|
||||
item
|
||||
for sublist in nested_list
|
||||
for item in (sublist if isinstance(sublist, list) else [sublist])
|
||||
]
|
||||
|
||||
|
||||
class SetUtils:
|
||||
"""提供集合结构的基础转换能力。"""
|
||||
|
||||
@staticmethod
|
||||
def flatten(nested_sets: Union[Set[Set[Any]], Set[Any]]) -> Set[Any]:
|
||||
"""
|
||||
将嵌套的集合展开为单个集合
|
||||
|
||||
:param nested_sets: 嵌套的集合
|
||||
:return: 展开的集合
|
||||
"""
|
||||
if not isinstance(nested_sets, set):
|
||||
return set()
|
||||
|
||||
# 检查是否嵌套,若不嵌套直接返回
|
||||
if not any(isinstance(subset, set) for subset in nested_sets):
|
||||
return nested_sets
|
||||
|
||||
return {item for subset in nested_sets if isinstance(subset, set) for item in subset}
|
||||
@@ -0,0 +1,137 @@
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union, Tuple
|
||||
from urllib import parse
|
||||
from urllib.parse import parse_qs, urlencode, urljoin, urlparse, urlunparse
|
||||
|
||||
class UrlUtils:
|
||||
"""提供不发起网络请求的 URL 解析与组合能力。"""
|
||||
|
||||
@staticmethod
|
||||
def standardize_base_url(host: str) -> str:
|
||||
"""
|
||||
标准化提供的主机地址,确保它以http://或https://开头,并且以斜杠(/)结尾
|
||||
:param host: 提供的主机地址字符串
|
||||
:return: 标准化后的主机地址字符串
|
||||
"""
|
||||
if not host:
|
||||
return host
|
||||
if not host.endswith("/"):
|
||||
host += "/"
|
||||
if not host.startswith("http://") and not host.startswith("https://"):
|
||||
host = "http://" + host
|
||||
return host
|
||||
|
||||
@staticmethod
|
||||
def adapt_request_url(host: str, endpoint: str) -> Optional[str]:
|
||||
"""
|
||||
基于传入的host,适配请求的URL,确保每个请求的URL是完整的,用于在发送请求前自动处理和修正请求的URL
|
||||
:param host: 主机头
|
||||
:param endpoint: 端点
|
||||
:return: 完整的请求URL字符串
|
||||
"""
|
||||
if not host and not endpoint:
|
||||
return None
|
||||
if endpoint.startswith(("http://", "https://")):
|
||||
return endpoint
|
||||
host = UrlUtils.standardize_base_url(host)
|
||||
return urljoin(host, endpoint) if host else endpoint
|
||||
|
||||
@staticmethod
|
||||
def combine_url(host: str, path: Optional[str] = None, query: Optional[dict] = None) -> Optional[str]:
|
||||
"""
|
||||
使用给定的主机头、路径和查询参数组合生成完整的URL
|
||||
:param host: str, 主机头,例如 https://example.com
|
||||
:param path: Optional[str], 包含路径和可能已经包含的查询参数的端点,例如 /path/to/resource?current=1
|
||||
:param query: Optional[dict], 可选,额外的查询参数,例如 {"key": "value"}
|
||||
:return: str, 完整的请求URL字符串
|
||||
"""
|
||||
try:
|
||||
# 如果路径为空,则默认为 '/'
|
||||
if path is None:
|
||||
path = '/'
|
||||
host = UrlUtils.standardize_base_url(host)
|
||||
# 使用 urljoin 合并 host 和 path
|
||||
url = urljoin(host, path)
|
||||
# 解析当前 URL 的组成部分
|
||||
url_parts = urlparse(url)
|
||||
# 解析已存在的查询参数,并与额外的查询参数合并
|
||||
query_params = parse_qs(url_parts.query)
|
||||
if query:
|
||||
for key, value in query.items():
|
||||
query_params[key] = value
|
||||
|
||||
# 重新构建查询字符串
|
||||
query_string = urlencode(query_params, doseq=True)
|
||||
# 构建完整的 URL
|
||||
new_url_parts = url_parts._replace(query=query_string)
|
||||
complete_url = urlunparse(new_url_parts)
|
||||
return str(complete_url)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_mime_type(path_or_url: Union[str, Path], default_type: str = "application/octet-stream") -> str:
|
||||
"""
|
||||
根据文件路径或 URL 获取 MIME 类型,如果无法获取则返回默认类型
|
||||
|
||||
:param path_or_url: 文件路径 (Path) 或 URL (str)
|
||||
:param default_type: 无法获取类型时返回的默认 MIME 类型
|
||||
:return: 获取到的 MIME 类型或默认类型
|
||||
"""
|
||||
try:
|
||||
# 如果是 Path 类型,转换为字符串
|
||||
if isinstance(path_or_url, Path):
|
||||
path_or_url = str(path_or_url)
|
||||
|
||||
# 尝试根据路径或 URL 获取 MIME 类型
|
||||
mime_type, _ = mimetypes.guess_type(path_or_url)
|
||||
# 如果无法推测到类型,返回默认类型
|
||||
if not mime_type:
|
||||
return default_type
|
||||
return mime_type
|
||||
except Exception:
|
||||
return default_type
|
||||
|
||||
@staticmethod
|
||||
def quote(s: str) -> str:
|
||||
"""
|
||||
将字符串编码为 URL 安全的格式
|
||||
|
||||
:param s: 要编码的字符串
|
||||
:return: 编码后的字符串
|
||||
"""
|
||||
return parse.quote(s)
|
||||
|
||||
@staticmethod
|
||||
def parse_url_params(url: str) -> Optional[Tuple[str, str, int, str]]:
|
||||
"""
|
||||
解析给定的 URL,并提取协议、主机名、端口和路径信息
|
||||
|
||||
:param url: str
|
||||
需要解析的 URL 字符串
|
||||
可以是完整的 URL(例如:"http://example.com:8080/path")或不带协议的地址(例如:"example.com:1234")
|
||||
:return: Optional[Tuple[str, str, int, str]]
|
||||
- str: 协议(例如:"http", "https")
|
||||
- str: 主机名或 IP 地址(例如:"example.com", "192.168.1.1")
|
||||
- int: 端口号(例如:80, 443)
|
||||
- str: URL 的路径部分(例如:"/", "/path")
|
||||
如果输入地址无效或无法解析,则返回 None
|
||||
"""
|
||||
try:
|
||||
if not url:
|
||||
return None
|
||||
|
||||
url = UrlUtils.standardize_base_url(host=url)
|
||||
parsed = urlparse(url)
|
||||
|
||||
if not parsed.hostname:
|
||||
return None
|
||||
protocol = parsed.scheme
|
||||
hostname = parsed.hostname
|
||||
port = parsed.port or (443 if protocol == "https" else 80)
|
||||
path = parsed.path or "/"
|
||||
|
||||
return protocol, hostname, port, path
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,69 @@
|
||||
import re
|
||||
from typing import Optional, Tuple
|
||||
|
||||
|
||||
_VERSION_LABELS = {"stable": -1, "rc": -2, "beta": -3, "alpha": -4}
|
||||
_UNKNOWN_VERSION_LABEL = -5
|
||||
_COMPARISON_TYPES = {"ge", "gt", "le", "lt", "eq", "==", ">=", ">", "<=", "<"}
|
||||
|
||||
|
||||
def _normalize_version(version: str) -> list[int]:
|
||||
"""将历史版本格式转换为保持原有排序语义的整数序列。"""
|
||||
parts = re.split(r"[.-]", version.strip().lstrip("vV"))
|
||||
return [
|
||||
int(part)
|
||||
if part.isdigit()
|
||||
else _VERSION_LABELS.get(part, _UNKNOWN_VERSION_LABEL)
|
||||
for part in parts
|
||||
]
|
||||
|
||||
|
||||
def compare_version(
|
||||
source: str,
|
||||
comparison: str,
|
||||
target: str,
|
||||
verbose: bool = False,
|
||||
) -> Optional[bool] | Tuple[Optional[bool], str | Exception]:
|
||||
"""按 MoviePilot 历史规则比较版本号,并可返回可读比较结果。"""
|
||||
try:
|
||||
if not source or not target:
|
||||
raise ValueError("要比较的版本号不全")
|
||||
if not comparison:
|
||||
raise ValueError("缺少比对模式,无法比对")
|
||||
if comparison not in _COMPARISON_TYPES:
|
||||
raise ValueError(f"设置的版本比对模式 {comparison} 不是有效的模式!")
|
||||
|
||||
source_parts = _normalize_version(source)
|
||||
target_parts = _normalize_version(target)
|
||||
max_length = max(len(source_parts), len(target_parts))
|
||||
source_parts += [0] * (max_length - len(source_parts))
|
||||
target_parts += [0] * (max_length - len(target_parts))
|
||||
|
||||
relation = "等于"
|
||||
for source_value, target_value in zip(source_parts, target_parts):
|
||||
if source_value > target_value:
|
||||
relation = "大于"
|
||||
break
|
||||
if source_value < target_value:
|
||||
relation = "小于"
|
||||
break
|
||||
|
||||
matched = {
|
||||
"eq": relation == "等于",
|
||||
"==": relation == "等于",
|
||||
"ge": relation in {"大于", "等于"},
|
||||
">=": relation in {"大于", "等于"},
|
||||
"gt": relation == "大于",
|
||||
">": relation == "大于",
|
||||
"le": relation in {"小于", "等于"},
|
||||
"<=": relation in {"小于", "等于"},
|
||||
"lt": relation == "小于",
|
||||
"<": relation == "小于",
|
||||
}[comparison]
|
||||
display_relation = (
|
||||
"不等于" if comparison in {"eq", "=="} and not matched else relation
|
||||
)
|
||||
message = f"版本号 {source} {display_relation} 目标版本号 {target} !"
|
||||
return (matched, message) if verbose else matched
|
||||
except Exception as err:
|
||||
return (None, err) if verbose else None
|
||||
@@ -0,0 +1,10 @@
|
||||
"""中文简繁转换工具。"""
|
||||
|
||||
from zhconv_rs import zhconv as _zhconv # pylint: disable=no-name-in-module
|
||||
|
||||
|
||||
def convert(text: str, target: str) -> str:
|
||||
"""
|
||||
使用 zhconv-rs 执行中文简繁转换,并隔离第三方包的函数名差异。
|
||||
"""
|
||||
return _zhconv(text, target)
|
||||
Reference in New Issue
Block a user