Files
MoviePilot/app/application/site/contract.py

535 lines
15 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.
"""站点配置、用户数据与健康统计的持久化合同。"""
from __future__ import annotations
import builtins
from dataclasses import dataclass
from typing import Mapping, NoReturn, Optional, Protocol
from app.schemas.common import JsonData
_SITE_MUTATION_FIELDS = frozenset(
{
"name",
"domain",
"url",
"pri",
"rss",
"cookie",
"ua",
"apikey",
"token",
"proxy",
"filter",
"render",
"public",
"note",
"limit_interval",
"limit_count",
"limit_seconds",
"timeout",
"is_active",
"lst_mod_date",
"downloader",
}
)
_SITE_USERDATA_MUTATION_FIELDS = frozenset(
{
"domain",
"name",
"username",
"userid",
"user_level",
"join_at",
"bonus",
"upload",
"download",
"ratio",
"seeding",
"leeching",
"seeding_size",
"leeching_size",
"seeding_info",
"message_unread",
"message_unread_contents",
"err_msg",
"updated_day",
"updated_time",
}
)
class _FrozenJsonDict(dict[str, JsonData]):
"""保留 JSON 字典兼容读取,并拒绝原地修改。"""
def _reject_mutation(self, *args: object, **kwargs: object) -> NoReturn:
"""拒绝修改已经进入站点快照的 JSON。"""
raise TypeError("站点快照 JSON 不可修改")
__setitem__ = _reject_mutation
__delitem__ = _reject_mutation
__ior__ = _reject_mutation
clear = _reject_mutation
pop = _reject_mutation
popitem = _reject_mutation
setdefault = _reject_mutation
update = _reject_mutation
class _FrozenJsonList(list[JsonData]):
"""保留 JSON 数组兼容读取,并拒绝原地修改。"""
def _reject_mutation(self, *args: object, **kwargs: object) -> NoReturn:
"""拒绝修改已经进入站点快照的 JSON。"""
raise TypeError("站点快照 JSON 不可修改")
__setitem__ = _reject_mutation
__delitem__ = _reject_mutation
__iadd__ = _reject_mutation
__imul__ = _reject_mutation
append = _reject_mutation
clear = _reject_mutation
extend = _reject_mutation
insert = _reject_mutation
pop = _reject_mutation
remove = _reject_mutation
reverse = _reject_mutation
sort = _reject_mutation
def _freeze_json(value: JsonData) -> JsonData:
"""递归复制并冻结 JSON 容器。"""
if isinstance(value, dict):
return _FrozenJsonDict({key: _freeze_json(item) for key, item in value.items()})
if isinstance(value, list):
return _FrozenJsonList([_freeze_json(item) for item in value])
return value
def _freeze_mapping(
values: Mapping[str, JsonData],
*,
allowed_fields: frozenset[str],
) -> Mapping[str, JsonData]:
"""校验写字段并返回与调用方隔离的冻结映射。"""
unknown_fields = sorted(set(values) - allowed_fields)
if unknown_fields:
raise ValueError(f"站点写入包含未知字段: {', '.join(unknown_fields)}")
return _FrozenJsonDict({key: _freeze_json(value) for key, value in values.items()})
@dataclass(frozen=True, slots=True)
class SiteSnapshot:
"""脱离数据库 Session 的完整站点配置快照。"""
id: int
name: str
url: str
domain: Optional[str] = None
pri: Optional[int] = None
rss: Optional[str] = None
cookie: Optional[str] = None
ua: Optional[str] = None
apikey: Optional[str] = None
token: Optional[str] = None
proxy: Optional[int] = None
filter: Optional[str] = None
render: Optional[int] = None
public: Optional[int] = None
note: Optional[JsonData] = None
limit_interval: Optional[int] = None
limit_count: Optional[int] = None
limit_seconds: Optional[int] = None
timeout: Optional[int] = None
is_active: Optional[bool] = None
lst_mod_date: Optional[str] = None
downloader: Optional[str] = None
def __post_init__(self) -> None:
"""冻结附加 JSON,避免跨层共享 ORM 可变列。"""
object.__setattr__(self, "note", _freeze_json(self.note))
@dataclass(frozen=True, slots=True)
class SiteUserDataSnapshot:
"""脱离数据库 Session 的站点用户数据快照。"""
id: int
domain: Optional[str] = None
name: Optional[str] = None
username: Optional[str] = None
userid: Optional[str] = None
user_level: Optional[str] = None
join_at: Optional[str] = None
bonus: Optional[float] = None
upload: Optional[float] = None
download: Optional[float] = None
ratio: Optional[float] = None
seeding: Optional[float] = None
leeching: Optional[float] = None
seeding_size: Optional[float] = None
leeching_size: Optional[float] = None
seeding_info: Optional[JsonData] = None
message_unread: Optional[int] = None
message_unread_contents: Optional[JsonData] = None
err_msg: Optional[str] = None
updated_day: Optional[str] = None
updated_time: Optional[str] = None
def __post_init__(self) -> None:
"""冻结做种与未读消息 JSON,避免调用方修改持久化值。"""
object.__setattr__(self, "seeding_info", _freeze_json(self.seeding_info))
object.__setattr__(
self,
"message_unread_contents",
_freeze_json(self.message_unread_contents),
)
@dataclass(frozen=True, slots=True)
class SiteIconSnapshot:
"""脱离数据库 Session 的站点图标快照。"""
id: int
name: str
url: str
domain: Optional[str] = None
base64: Optional[str] = None
@dataclass(frozen=True, slots=True)
class SiteStatisticSnapshot:
"""脱离数据库 Session 的站点健康统计快照。"""
id: int
domain: Optional[str] = None
success: Optional[int] = None
fail: Optional[int] = None
seconds: Optional[int] = None
lst_state: Optional[int] = None
lst_mod_date: Optional[str] = None
note: Optional[JsonData] = None
def __post_init__(self) -> None:
"""冻结耗时明细 JSON。"""
object.__setattr__(self, "note", _freeze_json(self.note))
@dataclass(frozen=True, slots=True)
class SiteMutation:
"""一次站点新增或更新允许写入的字段集合。"""
values: Mapping[str, JsonData]
def __post_init__(self) -> None:
"""拒绝模型外字段并冻结嵌套 JSON。"""
object.__setattr__(
self,
"values",
_freeze_mapping(self.values, allowed_fields=_SITE_MUTATION_FIELDS),
)
def to_payload(self) -> dict[str, JsonData]:
"""返回交给 DB adapter 的独立可变副本。"""
return {key: _copy_json(value) for key, value in self.values.items()}
@dataclass(frozen=True, slots=True)
class SiteUserDataMutation:
"""一次站点用户数据更新允许写入的字段集合。"""
values: Mapping[str, JsonData]
def __post_init__(self) -> None:
"""拒绝模型外字段并冻结嵌套 JSON。"""
object.__setattr__(
self,
"values",
_freeze_mapping(
self.values,
allowed_fields=_SITE_USERDATA_MUTATION_FIELDS,
),
)
def to_payload(self) -> dict[str, JsonData]:
"""返回交给 DB adapter 的独立可变副本。"""
return {key: _copy_json(value) for key, value in self.values.items()}
def _copy_json(value: JsonData) -> JsonData:
"""把冻结 JSON 递归复制为普通容器。"""
if isinstance(value, dict):
return {key: _copy_json(item) for key, item in value.items()}
if isinstance(value, list):
return [_copy_json(item) for item in value]
return value
@dataclass(frozen=True, slots=True)
class SitePriorityMutation:
"""一条站点优先级更新。"""
site_id: int
priority: int
@dataclass(frozen=True, slots=True)
class SiteWriteResult:
"""描述站点写操作是否成功及兼容提示。"""
success: bool
message: str = ""
class SiteQueryPort(Protocol):
"""Chain、Agent 与 Application 所需的类型化站点查询端口。"""
def get(self, site_id: int) -> Optional[SiteSnapshot]:
"""同步按主键读取站点快照。"""
...
def get_by_domain(self, domain: str) -> Optional[SiteSnapshot]:
"""同步按域名读取站点快照。"""
...
def get_domains_by_ids(
self,
ids: builtins.list[int],
) -> builtins.list[str]:
"""同步读取一组站点主键对应的非空域名。"""
...
def list(self) -> builtins.list[SiteSnapshot]:
"""同步读取全部站点快照。"""
...
def list_order_by_pri(self) -> builtins.list[SiteSnapshot]:
"""同步按优先级读取站点快照。"""
...
def list_active(self) -> builtins.list[SiteSnapshot]:
"""同步读取已启用站点快照。"""
...
def get_userdata_latest(self) -> builtins.list[SiteUserDataSnapshot]:
"""同步读取各站点最新用户数据快照。"""
...
async def async_get(self, site_id: int) -> Optional[SiteSnapshot]:
"""异步按主键读取站点快照。"""
...
async def async_get_by_domain(self, domain: str) -> Optional[SiteSnapshot]:
"""异步按域名读取站点快照。"""
...
async def async_get_by_name(self, name: str) -> Optional[SiteSnapshot]:
"""异步按名称读取站点快照。"""
...
async def async_list(self) -> builtins.list[SiteSnapshot]:
"""异步读取全部站点快照。"""
...
async def async_list_order_by_pri(
self,
*,
is_active: Optional[bool] = None,
name: Optional[str] = None,
site_ids: Optional[builtins.list[int]] = None,
domains: Optional[builtins.list[str]] = None,
page: Optional[int] = None,
count: Optional[int] = None,
) -> builtins.list[SiteSnapshot]:
"""按筛选和可选分页窗口异步读取站点快照。"""
...
async def async_count_sites(
self,
*,
is_active: Optional[bool] = None,
name: Optional[str] = None,
site_ids: Optional[builtins.list[int]] = None,
domains: Optional[builtins.list[str]] = None,
) -> int:
"""按与站点列表一致的筛选条件统计数量。"""
...
async def async_list_active(self) -> builtins.list[SiteSnapshot]:
"""异步读取已启用站点快照。"""
...
async def async_get_userdata_by_domain(
self,
domain: str,
workdate: Optional[str] = None,
*,
page: Optional[int] = None,
count: Optional[int] = None,
) -> builtins.list[SiteUserDataSnapshot]:
"""按可选分页窗口异步读取指定域名和日期的用户数据快照。"""
...
async def async_count_userdata_by_domain(
self,
domain: str,
workdate: Optional[str] = None,
) -> int:
"""统计指定域名和日期的用户数据数量。"""
...
async def async_get_userdata_latest(
self,
*,
page: Optional[int] = None,
count: Optional[int] = None,
) -> builtins.list[SiteUserDataSnapshot]:
"""按可选分页窗口异步读取各站点最新用户数据快照。"""
...
async def async_count_userdata_latest(self) -> int:
"""统计各站点最新用户数据查询的结果数量。"""
...
async def async_get_icon_by_domain(
self,
domain: str,
) -> Optional[SiteIconSnapshot]:
"""异步按域名读取站点图标快照。"""
...
async def async_get_statistic_by_domain(
self,
domain: str,
) -> Optional[SiteStatisticSnapshot]:
"""异步按域名读取站点健康统计快照。"""
...
async def async_list_statistics(
self,
*,
page: Optional[int] = None,
count: Optional[int] = None,
) -> builtins.list[SiteStatisticSnapshot]:
"""按可选分页窗口异步读取站点健康统计快照。"""
...
async def async_count_statistics(self) -> int:
"""统计站点健康统计记录数量。"""
...
class SiteWritePort(Protocol):
"""站点配置、用户数据和健康统计的类型化写端口。"""
def add(self, mutation: SiteMutation) -> SiteWriteResult:
"""在独立同步事务中新增站点。"""
...
def update(
self,
site_id: int,
mutation: SiteMutation,
) -> Optional[SiteSnapshot]:
"""在独立同步事务中更新并返回站点快照。"""
...
async def async_update(
self,
site_id: int,
mutation: SiteMutation,
) -> Optional[SiteSnapshot]:
"""在独立异步事务中更新并返回站点快照。"""
...
def update_cookie(self, domain: str, cookies: str) -> SiteWriteResult:
"""在独立同步事务中更新站点 Cookie。"""
...
def update_rss(self, domain: str, rss: str) -> SiteWriteResult:
"""在独立同步事务中更新站点 RSS。"""
...
def update_userdata(
self,
domain: str,
name: str,
mutation: SiteUserDataMutation,
) -> SiteWriteResult:
"""在独立同步事务中写入站点用户数据。"""
...
def update_icon(
self,
name: str,
domain: str,
icon_url: str,
icon_base64: str,
) -> bool:
"""在独立同步事务中写入站点图标。"""
...
def success(self, domain: str, seconds: Optional[int] = None) -> None:
"""在独立同步事务中记录站点访问成功。"""
...
def fail(self, domain: str) -> None:
"""在独立同步事务中记录站点访问失败。"""
...
async def async_success(
self,
domain: str,
seconds: Optional[int] = None,
) -> None:
"""在独立异步事务中记录站点访问成功。"""
...
async def async_fail(self, domain: str) -> None:
"""在独立异步事务中记录站点访问失败。"""
...
class SiteStagingPort(Protocol):
"""复用请求 AsyncSession 且不自行提交的站点写端口。"""
async def get_by_id(self, site_id: int) -> Optional[SiteSnapshot]:
"""在请求会话中按主键读取站点快照。"""
...
async def async_get_by_domain(self, domain: str) -> Optional[SiteSnapshot]:
"""在请求会话中按域名读取站点快照。"""
...
async def stage_create(self, mutation: SiteMutation) -> None:
"""在请求事务中暂存新增站点。"""
...
async def stage_update(
self,
site_id: int,
mutation: SiteMutation,
) -> bool:
"""在请求事务中暂存站点更新并返回目标是否存在。"""
...
async def stage_delete(self, site_id: int) -> None:
"""在请求事务中暂存删除站点。"""
...
async def stage_priorities(
self,
priorities: tuple[SitePriorityMutation, ...],
) -> None:
"""在请求事务中暂存一组站点优先级更新。"""
...
async def stage_reset(self) -> None:
"""在请求事务中暂存清空全部站点。"""
...
class SiteRepository(SiteQueryPort, SiteWritePort, Protocol):
"""组合宿主所需全部站点查询和写入能力。"""