Files
MoviePilot/app/db/oper/site.py
Aqr-K 598d1663b1 fix(download): preserve protocol failures and isolate site updates (#7143)
* fix(download): preserve protocol failures and isolate site updates

* test: cover tracker token lifecycle and record dependency facts

---------

Co-authored-by: jxxghp <jxxghp@gmail.com>
2026-10-23 07:22:22 +08:00

868 lines
28 KiB
Python
Raw Permalink 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 datetime import datetime
from typing import Any, List, Mapping, Optional, Tuple
from sqlalchemy import delete as sqlalchemy_delete
from sqlalchemy import false, func, or_, select, text, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from sqlalchemy.sql.dml import Update
from sqlalchemy.sql.elements import TextClause
from app.db.base import DbOper
from app.db.models.site import Site
from app.db.models.siteicon import SiteIcon
from app.db.models.sitestatistic import SiteStatistic
from app.db.models.siteuserdata import SiteUserData
from app.db.oper.query import literal_contains
from app.schemas.exception import SiteDomainConflictError
async def _async_first(session: AsyncSession, statement: Any) -> Optional[Site]:
"""
执行异步站点查询并返回首条记录。
"""
result = await session.execute(statement)
site: Optional[Site] = result.scalars().first()
return site
async def _async_all(session: AsyncSession, statement: Any) -> list[Any]:
"""
执行异步列表查询并返回稳定 ORM 行。
"""
result = await session.execute(statement)
return list(result.scalars().all())
async def _async_scalar(session: AsyncSession, statement: Any) -> int:
"""
执行异步计数语句并返回整数。
"""
result = await session.execute(statement)
return int(result.scalar_one() or 0)
def _apply_page(statement: Any, page: Optional[int], count: Optional[int]) -> Any:
"""
仅在分页窗口完整时向 SQL 语句追加 LIMIT/OFFSET。
"""
if page is None or count is None:
return statement
return statement.offset((page - 1) * count).limit(count)
def _site_conditions(
*,
is_active: Optional[bool] = None,
name: Optional[str] = None,
site_ids: Optional[list[int]] = None,
domains: Optional[list[str]] = None,
) -> list[Any]:
"""
构造站点列表与计数共享的数据库筛选条件。
"""
conditions: list[Any] = []
if is_active is not None:
conditions.append(Site.is_active.is_(is_active))
if name:
conditions.append(literal_contains(Site.name, name))
if site_ids is not None or domains is not None:
identity_conditions: list[Any] = []
if site_ids:
identity_conditions.append(Site.id.in_(site_ids))
if domains:
identity_conditions.append(Site.domain.in_(domains))
conditions.append(
or_(*identity_conditions) if identity_conditions else false()
)
return conditions
def _userdata_conditions(
domain: str,
workdate: Optional[str],
) -> list[Any]:
"""
构造站点用户数据列表与计数共享的筛选条件。
"""
conditions = [SiteUserData.domain == domain]
if workdate:
conditions.append(SiteUserData.updated_day == workdate)
return conditions
def _latest_userdata_subquery() -> Any:
"""
构造各站点最新有效数据日期的共享子查询。
"""
return (
select(
SiteUserData.domain,
func.max(SiteUserData.updated_day).label("latest_update_day"),
)
.where(or_(SiteUserData.err_msg.is_(None), SiteUserData.err_msg == ""))
.group_by(SiteUserData.domain)
.subquery()
)
def _latest_userdata_join(statement: Any, subquery: Any) -> Any:
"""
把最新用户数据日期子查询连接到目标查询语句。
"""
return statement.join(
subquery,
(SiteUserData.domain == subquery.c.domain)
& (SiteUserData.updated_day == subquery.c.latest_update_day),
)
def _site_domain_lock(dialect: str) -> TextClause | Update:
"""
在数据库事务中锁住站点域名准入,兼容空表及已有重复历史记录。
SQLite的零行UPDATE仍获取数据库写锁;PostgreSQL表锁覆盖空表与并发插入。
不持有进程锁,不提交事务,锁由调用方UoW提交或回滚时释放。
:param dialect: 当前会话绑定的SQLAlchemy方言名称
:return: 获取事务写入准入的语句
"""
if dialect == "postgresql":
return text("LOCK TABLE site IN SHARE ROW EXCLUSIVE MODE")
if dialect == "sqlite":
return update(Site).where(Site.id == -1).values(id=Site.id)
raise RuntimeError("站点域名并发准入不支持当前数据库方言")
def _check_site_domain(session: Session, domain: Optional[str], site_id: Optional[int] = None) -> None:
"""
同步取得事务准入后重新检查域名,防止先查后插竞争。
:param session: 调用方持有的真实数据库会话
:param domain: 新配置的规范域名
:param site_id: 更新时允许保留该站点自身域名
:raises SiteDomainConflictError: 已有另一站点占用域名
"""
if domain is None:
return
session.execute(_site_domain_lock(session.get_bind().dialect.name))
statement = select(Site.id).where(Site.domain == domain)
if site_id is not None:
statement = statement.where(Site.id != site_id)
if session.execute(statement.limit(1)).scalar_one_or_none() is not None:
raise SiteDomainConflictError("站点已存在")
async def _async_check_site_domain(
session: AsyncSession, domain: Optional[str], site_id: Optional[int] = None,
) -> None:
"""
异步取得数据库事务准入后重新检查域名。
:param session: 调用方持有的真实数据库会话
:param domain: 新配置的规范域名
:param site_id: 更新时允许保留该站点自身域名
:raises SiteDomainConflictError: 已有另一站点占用域名
"""
if domain is None:
return
await session.execute(_site_domain_lock(session.get_bind().dialect.name))
statement = select(Site.id).where(Site.domain == domain)
if site_id is not None:
statement = statement.where(Site.id != site_id)
result = await session.execute(statement.limit(1))
if result.scalar_one_or_none() is not None:
raise SiteDomainConflictError("站点已存在")
class SiteOper(DbOper):
"""
站点管理
"""
def add(self, **kwargs: Any) -> Tuple[bool, str]:
"""
新增站点
"""
def stage(session: Session) -> Tuple[bool, str]:
"""
在同一调用方事务中完成锁定、查重及暂存。
"""
try:
_check_site_domain(session, kwargs.get("domain"))
except SiteDomainConflictError:
return False, "站点已存在"
session.add(Site(**kwargs))
return True, "新增站点成功"
return self._execute_sync_write(stage)
def get(self, sid: int) -> Optional[Site]:
"""
查询单个站点
"""
site: Optional[Site] = self._execute_sync_query(
lambda session: session.execute(
select(Site).where(Site.id == sid)
).scalars().first()
)
return site
async def async_get(self, sid: int) -> Optional[Site]:
"""
异步查询单个站点
"""
return await self._execute_async_query(
lambda session: _async_first(
session,
select(Site).where(Site.id == sid),
)
)
async def get_by_id(self, site_id: int) -> Optional[Site]:
"""
读取站点写用例需要的目标站点。
"""
return await self.async_get(site_id)
async def stage_create(self, payload: Mapping[str, Any]) -> None:
"""
暂存新增站点,不由仓储自行提交。
"""
if not isinstance(self._db, AsyncSession):
raise RuntimeError("站点事务新增需要调用方提供 AsyncSession")
values = dict(payload)
values.pop("id", None)
await _async_check_site_domain(self._db, values.get("domain"))
self._db.add(Site(**values))
async def stage_update(
self,
site_id: int,
payload: Mapping[str, Any],
) -> bool:
"""
暂存站点字段更新,事务由调用方统一提交。
"""
if "domain" in payload:
await _async_check_site_domain(self._db, payload["domain"], site_id)
site = await self.async_get(site_id)
if not site:
return False
for key, value in payload.items():
if key != "id":
setattr(site, key, value)
return True
async def stage_delete(self, site_id: int) -> None:
"""
暂存站点删除,由请求级 UnitOfWork 统一提交。
"""
if not isinstance(self._db, AsyncSession):
raise RuntimeError("站点事务删除需要调用方提供 AsyncSession")
await self._db.execute(
sqlalchemy_delete(Site).where(Site.id == site_id)
)
async def stage_priorities(self, priorities: list[dict[str, Any]]) -> None:
"""
暂存批量优先级更新,避免逐行独立提交。
"""
for priority in priorities:
site_id = priority.get("id")
site = await self.async_get(site_id) if site_id else None
if site:
site.pri = priority.get("pri")
def list(self) -> List[Site]:
"""
获取站点列表
"""
return self._execute_sync_query(
lambda session: list(session.execute(select(Site)).scalars().all())
)
async def async_list(self) -> List[Site]:
"""
异步获取站点列表
"""
return await self._execute_async_query(
lambda session: _async_all(session, select(Site))
)
async def async_list_order_by_pri(
self,
*,
is_active: Optional[bool] = None,
name: Optional[str] = None,
site_ids: Optional[List[int]] = None,
domains: Optional[List[str]] = None,
page: Optional[int] = None,
count: Optional[int] = None,
) -> List[Site]:
"""
按筛选、优先级和可选分页窗口异步获取站点。
"""
statement = select(Site).where(
*_site_conditions(
is_active=is_active,
name=name,
site_ids=site_ids,
domains=domains,
)
).order_by(Site.pri, Site.id)
return await self._execute_async_query(
lambda session: _async_all(
session,
_apply_page(statement, page, count),
)
)
async def async_count_sites(
self,
*,
is_active: Optional[bool] = None,
name: Optional[str] = None,
site_ids: Optional[List[int]] = None,
domains: Optional[List[str]] = None,
) -> int:
"""
按与站点列表一致的筛选条件异步统计数量。
"""
statement = select(func.count()).select_from(Site).where(
*_site_conditions(
is_active=is_active,
name=name,
site_ids=site_ids,
domains=domains,
)
)
return await self._execute_async_query(
lambda session: _async_scalar(session, statement)
)
def list_order_by_pri(self) -> List[Site]:
"""
获取站点列表
"""
return self._execute_sync_query(
lambda session: list(
session.execute(select(Site).order_by(Site.pri)).scalars().all()
)
)
def list_active(self) -> List[Site]:
"""
按状态获取站点列表
"""
return self._execute_sync_query(
lambda session: list(
session.execute(
select(Site).where(Site.is_active.is_(True))
).scalars().all()
)
)
async def async_list_active(self) -> List[Site]:
"""
异步按状态获取站点列表
"""
return await self._execute_async_query(
lambda session: _async_all(
session,
select(Site).where(Site.is_active.is_(True)),
)
)
def delete(self, sid: int) -> None:
"""
删除站点
"""
self._stage_delete(Site, sid)
def reset(self) -> None:
"""
清空站点表;兼容入口的事务由组合根统一持有。
"""
self._execute_sync_write(Site.reset)
async def stage_reset(self) -> None:
"""
暂存清空站点表,由应用事务统一提交。
"""
if not isinstance(self._db, AsyncSession):
raise RuntimeError("站点事务清空需要调用方提供 AsyncSession")
await self._db.execute(sqlalchemy_delete(Site))
def update(self, sid: int, payload: dict[str, Any]) -> Optional[Site]:
"""
更新站点
"""
def stage(session: Session) -> Optional[Site]:
"""
在同一事务中校验域名并更新现有站点。
"""
if "domain" in payload:
_check_site_domain(session, payload["domain"], sid)
site: Optional[Site] = session.get(Site, sid)
if site is not None:
for key, value in payload.items():
setattr(site, key, value)
return site
return self._execute_sync_write(stage)
async def async_update(self, sid: int, payload: dict[str, Any]) -> Optional[Site]:
"""
在调用方异步事务内校验域名并更新站点。
"""
async def stage(session: AsyncSession) -> Optional[Site]:
"""
共享与站点创建相同的数据库准入锁。
"""
if "domain" in payload:
await _async_check_site_domain(session, payload["domain"], sid)
site: Optional[Site] = await session.get(Site, sid)
if site is not None:
for key, value in payload.items():
setattr(site, key, value)
return site
return await self._execute_async_write(stage)
def get_by_domain(self, domain: Optional[str]) -> Optional[Site]:
"""
按域名获取站点
"""
site: Optional[Site] = self._execute_sync_query(
lambda session: session.execute(
select(Site).where(Site.domain == domain)
).scalars().first()
)
return site
async def async_get_by_domain(self, domain: str) -> Optional[Site]:
"""
异步按域名获取站点
"""
return await self._execute_async_query(
lambda session: _async_first(
session,
select(Site).where(Site.domain == domain),
)
)
async def async_get_by_name(self, name: str) -> Optional[Site]:
"""
异步按名称获取站点
"""
return await self._execute_async_query(
lambda session: _async_first(
session,
select(Site).where(Site.name == name),
)
)
def get_domains_by_ids(self, ids: List[int]) -> List[Optional[str]]:
"""
按ID获取站点域名
"""
if not ids:
return []
return self._execute_sync_query(
lambda session: list(
session.execute(
select(Site.domain).where(Site.id.in_(ids))
).scalars().all()
)
)
def exists(self, domain: str) -> bool:
"""
判断站点是否存在
"""
return self.get_by_domain(domain) is not None
def update_cookie(self, domain: str, cookies: str) -> Tuple[bool, str]:
"""
更新站点Cookie
"""
site = self.get_by_domain(domain)
if not site:
return False, "站点不存在"
self._stage_update(site, {
"cookie": cookies
})
return True, "更新站点Cookie成功"
def update_rss(self, domain: str, rss: str) -> Tuple[bool, str]:
"""
更新站点rss
"""
site = self.get_by_domain(domain)
if not site:
return False, "站点不存在"
self._stage_update(site, {
"rss": rss
})
return True, "更新站点RSS地址成功"
def update_userdata(self, domain: str, name: str, payload: dict[str, Any]) -> Tuple[bool, str]:
"""
更新站点用户数据
"""
# 当前系统日期
current_day = datetime.now().strftime('%Y-%m-%d')
current_time = datetime.now().strftime('%H:%M:%S')
payload.update({
"domain": domain,
"name": name,
"updated_day": current_day,
"updated_time": current_time,
"err_msg": payload.get("err_msg") or ""
})
# 按站点+天判断是否存在数据
siteuserdatas = self._execute_sync_query(
lambda session: SiteUserData.get_by_domain(
session,
domain=domain,
workdate=current_day,
)
)
if siteuserdatas:
# 存在则更新
if not payload.get("err_msg"):
self._stage_update(siteuserdatas[0], payload)
else:
# 不存在则插入
self._stage_create(SiteUserData(**payload))
return True, "更新站点用户数据成功"
def get_userdata(self) -> List[SiteUserData]:
"""
获取站点用户数据
"""
return self._execute_sync_query(
lambda session: SiteUserData.list(session)
)
def get_userdata_by_domain(self, domain: str, workdate: Optional[str] = None) -> List[SiteUserData]:
"""
获取站点用户数据
"""
return self._execute_sync_query(
lambda session: SiteUserData.get_by_domain(
session,
domain=domain,
workdate=workdate,
)
)
async def async_get_userdata_by_domain(
self,
domain: str,
workdate: Optional[str] = None,
*,
page: Optional[int] = None,
count: Optional[int] = None,
) -> List[SiteUserData]:
"""
按可选分页窗口异步获取站点用户数据。
"""
statement = select(SiteUserData).where(
*_userdata_conditions(domain, workdate)
).order_by(
SiteUserData.updated_day.desc(),
SiteUserData.updated_time.desc(),
SiteUserData.id.desc(),
)
return await self._execute_async_query(
lambda session: _async_all(
session,
_apply_page(statement, page, count),
)
)
async def async_count_userdata_by_domain(
self,
domain: str,
workdate: Optional[str] = None,
) -> int:
"""
异步统计指定站点和日期的用户数据数量。
"""
statement = select(func.count()).select_from(SiteUserData).where(
*_userdata_conditions(domain, workdate)
)
return await self._execute_async_query(
lambda session: _async_scalar(session, statement)
)
async def async_get_userdata_latest(
self,
*,
page: Optional[int] = None,
count: Optional[int] = None,
) -> List[SiteUserData]:
"""
按可选分页窗口异步获取各站点最新用户数据。
"""
subquery = _latest_userdata_subquery()
statement = _latest_userdata_join(select(SiteUserData), subquery).order_by(
SiteUserData.updated_time.desc(),
SiteUserData.id.desc(),
)
return await self._execute_async_query(
lambda session: _async_all(
session,
_apply_page(statement, page, count),
)
)
async def async_count_userdata_latest(self) -> int:
"""
异步统计各站点最新用户数据查询的结果数量。
"""
subquery = _latest_userdata_subquery()
statement = _latest_userdata_join(
select(func.count()).select_from(SiteUserData),
subquery,
)
return await self._execute_async_query(
lambda session: _async_scalar(session, statement)
)
async def async_get_icon_by_domain(self, domain: str) -> Optional[SiteIcon]:
"""
异步按域名获取站点图标。
"""
return await self._execute_async_query(
lambda session: SiteIcon.async_get_by_domain(session, domain)
)
async def async_get_statistic_by_domain(
self,
domain: str,
) -> Optional[SiteStatistic]:
"""
异步按域名获取站点统计。
"""
return await self._execute_async_query(
lambda session: SiteStatistic.async_get_by_domain(session, domain)
)
async def async_list_statistics(
self,
*,
page: Optional[int] = None,
count: Optional[int] = None,
) -> List[SiteStatistic]:
"""
按可选分页窗口异步获取站点统计。
"""
statement = select(SiteStatistic).order_by(SiteStatistic.id)
return await self._execute_async_query(
lambda session: _async_all(
session,
_apply_page(statement, page, count),
)
)
async def async_count_statistics(self) -> int:
"""
异步统计站点健康统计记录数量。
"""
statement = select(func.count()).select_from(SiteStatistic)
return await self._execute_async_query(
lambda session: _async_scalar(session, statement)
)
def get_userdata_by_date(self, date: str) -> List[SiteUserData]:
"""
获取站点用户数据
"""
return self._execute_sync_query(
lambda session: SiteUserData.get_by_date(session, date)
)
def get_userdata_latest(self) -> List[SiteUserData]:
"""
获取站点最新数据
"""
return self._execute_sync_query(
lambda session: SiteUserData.get_latest(session)
)
def get_icon_by_domain(self, domain: str) -> Optional[SiteIcon]:
"""
按域名获取站点图标
"""
return self._execute_sync_query(
lambda session: SiteIcon.get_by_domain(session, domain)
)
def update_icon(self, name: str, domain: str, icon_url: str, icon_base64: str) -> bool:
"""
更新站点图标
"""
icon_base64 = f"data:image/ico;base64,{icon_base64}" if icon_base64 else ""
def write(db: Session) -> None:
"""
在同一同步事务中查询并更新站点图标。
"""
siteicon = SiteIcon.get_by_domain(db, domain)
if not siteicon:
db.add(SiteIcon(
name=name,
domain=domain,
url=icon_url,
base64=icon_base64,
))
elif icon_base64:
siteicon.url = icon_url
siteicon.base64 = icon_base64
self._execute_sync_write(write)
return True
def success(self, domain: str, seconds: Optional[int] = None) -> None:
"""
站点访问成功
"""
def write(db: Session) -> None:
"""
在同一同步事务中读取并更新站点统计。
"""
lst_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
sta = SiteStatistic.get_by_domain(db, domain)
if sta:
# 使用深复制确保 note 是全新的字典对象
note = dict(sta.note) if sta.note else {}
avg_seconds = None
if seconds is not None:
note[lst_date] = seconds or 1
avg_times = len(note.keys())
if avg_times > 10:
note = dict(sorted(note.items(), key=lambda x: x[0], reverse=True)[:10])
avg_seconds = sum([v for v in note.values()]) // avg_times
for key, value in {
"success": sta.success + 1,
"seconds": avg_seconds or sta.seconds,
"lst_state": 0,
"lst_mod_date": lst_date,
"note": note,
}.items():
setattr(sta, key, value)
else:
note = {}
if seconds is not None:
note = {lst_date: seconds or 1}
db.add(SiteStatistic(
domain=domain,
success=1,
fail=0,
seconds=seconds or 1,
lst_state=0,
lst_mod_date=lst_date,
note=note,
))
self._execute_sync_write(write)
def fail(self, domain: str) -> None:
"""
站点访问失败
"""
def write(db: Session) -> None:
"""
在同一同步事务中读取并更新站点失败统计。
"""
lst_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
sta = SiteStatistic.get_by_domain(db, domain)
if sta:
sta.fail += 1
sta.lst_state = 1
sta.lst_mod_date = lst_date
else:
db.add(SiteStatistic(
domain=domain,
success=0,
fail=1,
lst_state=1,
lst_mod_date=lst_date,
))
self._execute_sync_write(write)
async def async_success(self, domain: str, seconds: Optional[int] = None) -> None:
"""
异步站点访问成功
"""
async def write(session: AsyncSession) -> None:
"""
在同一异步事务中读取并更新站点成功统计。
"""
lst_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
sta = await SiteStatistic.async_get_by_domain(session, domain)
if sta:
note = dict(sta.note) if sta.note else {}
avg_seconds = None
if seconds is not None:
note[lst_date] = seconds or 1
avg_times = len(note.keys())
if avg_times > 10:
note = dict(sorted(
note.items(), key=lambda item: item[0], reverse=True
)[:10])
avg_seconds = sum(note.values()) // avg_times
sta.success += 1
sta.seconds = avg_seconds or sta.seconds
sta.lst_state = 0
sta.lst_mod_date = lst_date
sta.note = note
return
note = {lst_date: seconds or 1} if seconds is not None else {}
session.add(SiteStatistic(
domain=domain,
success=1,
fail=0,
seconds=seconds or 1,
lst_state=0,
lst_mod_date=lst_date,
note=note,
))
await self._execute_async_write(write)
async def async_fail(self, domain: str) -> None:
"""
异步站点访问失败
"""
async def write(session: AsyncSession) -> None:
"""
在同一异步事务中读取并更新站点失败统计。
"""
lst_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
sta = await SiteStatistic.async_get_by_domain(session, domain)
if sta:
sta.fail += 1
sta.lst_state = 1
sta.lst_mod_date = lst_date
return
session.add(SiteStatistic(
domain=domain,
success=0,
fail=1,
lst_state=1,
lst_mod_date=lst_date,
))
await self._execute_async_write(write)