refactor(db): 修复异步连接池无界增长,并完成 SQLAlchemy 2.0 迁移与分层归位 (#6320)

This commit is contained in:
Aqr-K
2026-08-15 06:58:38 +08:00
committed by GitHub
parent e28de9cfe1
commit 8a11214a43
252 changed files with 11405 additions and 2889 deletions
+100
View File
@@ -0,0 +1,100 @@
"""
数据访问层(Oper)。
与 app/db/models 一一对应:models 声明表结构,oper 承载针对该表的读写。
两个包同名文件互为镜像(models/subscribe.py ↔ oper/subscribe.py),
文件名只写实体,角色由包名表达,因此这里不再有 `_oper` 后缀。
本文件只做符号解析,不在 import 期执行任何动作——没有建引擎、没有连库、
也不会把十六个 Oper 模块一并拉起。`from app.db.oper import SubscribeOper`
经下方 __getattr__ 惰性解析,只导入被点名的那一个模块。
这一点不是洁癖:多处测试靠往 sys.modules 塞桩来隔离单个 Oper(例如
app.db.oper.systemconfig),若本文件改成 models/__init__.py 那样的即时
re-export,导入任意一个 Oper 都会连带把其余十五个真正拉起来,桩就被绕过了。
按模块直连(from app.db.oper.subscribe import SubscribeOper)仍是仓库内的
首选写法,本入口是给「只想要一个类名」的调用方备的门面。
"""
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
# 运行期由 __getattr__ 解析,模块 __dict__ 里并不存在这些名字;
# 这里为静态检查补上真实类型,同时消掉 __all__ 的未定义告警。
from app.db.oper.agentchat import AgentChatOper
from app.db.oper.agenttask import AgentTaskOper
from app.db.oper.downloadfailure import DownloadFailureOper
from app.db.oper.downloadhistory import DownloadHistoryOper
from app.db.oper.mediaserver import MediaServerOper
from app.db.oper.message import MessageOper
from app.db.oper.plugindata import PluginDataOper
from app.db.oper.site import SiteOper
from app.db.oper.subscribe import SubscribeOper
from app.db.oper.subscribehistory import SubscribeHistoryOper
from app.db.oper.systemconfig import SystemConfigOper
from app.db.oper.transferhistory import TransferHistoryOper
from app.db.oper.transferpending import TransferPendingOper
from app.db.oper.user import UserOper
from app.db.oper.userconfig import UserConfigOper
from app.db.oper.workflow import WorkflowOper
# 类名 -> 所在子模块。子模块名即实体名,与 app/db/models 对齐。
_OPER_MODULES = {
"AgentChatOper": "agentchat",
"AgentTaskOper": "agenttask",
"DownloadFailureOper": "downloadfailure",
"DownloadHistoryOper": "downloadhistory",
"MediaServerOper": "mediaserver",
"MessageOper": "message",
"PluginDataOper": "plugindata",
"SiteOper": "site",
"SubscribeHistoryOper": "subscribehistory",
"SubscribeOper": "subscribe",
"SystemConfigOper": "systemconfig",
"TransferHistoryOper": "transferhistory",
"TransferPendingOper": "transferpending",
"UserConfigOper": "userconfig",
"UserOper": "user",
"WorkflowOper": "workflow",
}
def __getattr__(name: str) -> Any:
"""
惰性解析 Oper 类,只导入被点名的那个子模块。
:param name: 属性名
:return: 对应的 Oper 类
"""
module_name = _OPER_MODULES.get(name)
if module_name is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
from importlib import import_module
return getattr(import_module(f"{__name__}.{module_name}"), name)
def __dir__() -> list[str]:
"""
让 dir() 与自动补全看得见惰性名字。
:return: 属性名列表
"""
return sorted({*globals(), *_OPER_MODULES})
__all__ = [
"AgentChatOper",
"AgentTaskOper",
"DownloadFailureOper",
"DownloadHistoryOper",
"MediaServerOper",
"MessageOper",
"PluginDataOper",
"SiteOper",
"SubscribeHistoryOper",
"SubscribeOper",
"SystemConfigOper",
"TransferHistoryOper",
"TransferPendingOper",
"UserConfigOper",
"UserOper",
"WorkflowOper",
]
+344
View File
@@ -0,0 +1,344 @@
import time
from typing import Any, Optional, Union
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.db import DbOper
from app.db.models.agentchat import AgentChat
from app.schemas.types import MessageChannel
DEFAULT_AGENT_CHAT_TITLE = "未命名会话"
class AgentChatOper(DbOper):
"""
Agent 会话历史数据管理。
"""
def __init__(self, db: Optional[Union[Session, AsyncSession]] = None):
super().__init__(db)
@staticmethod
def _now() -> str:
"""返回数据库统一使用的当前时间字符串。"""
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
@staticmethod
def _channel_value(channel: Optional[Union[MessageChannel, str]]) -> Optional[str]:
"""获取渠道枚举的字符串值。"""
if isinstance(channel, MessageChannel):
return channel.value
return channel
@staticmethod
def _normalize_messages(messages: Optional[list[dict]]) -> list[dict]:
"""规范化展示消息列表,避免 JSON 字段存入 None。"""
return messages if isinstance(messages, list) else []
@staticmethod
def _normalize_title(value: Optional[str], messages: list[dict]) -> str:
"""生成会话标题。"""
if value and value.strip():
return value.strip()[:120]
for message in messages:
if message.get("role") != "user":
continue
content = str(message.get("content") or "").strip()
if content:
return content.replace("\n", " ")[:120]
attachments = message.get("attachments")
if isinstance(attachments, list) and attachments:
name = attachments[0].get("name") or "附件消息"
return str(name)[:120]
return DEFAULT_AGENT_CHAT_TITLE
@staticmethod
def has_custom_title(value: Optional[str]) -> bool:
"""判断会话是否已有真实标题。"""
return bool(value and value.strip() and value.strip() != DEFAULT_AGENT_CHAT_TITLE)
@staticmethod
def _normalize_preview(messages: list[dict]) -> str:
"""生成会话预览文本。"""
for message in reversed(messages):
content = str(message.get("content") or "").strip()
if content:
return content.replace("\n", " ")[:240]
attachments = message.get("attachments")
if isinstance(attachments, list) and attachments:
name = attachments[0].get("name") or "附件消息"
return str(name)[:240]
return ""
def get(
self, session_id: str, user_id: Optional[str] = None
) -> Optional[AgentChat]:
"""
获取 Agent 会话。
"""
return AgentChat.get_by_session(self._db, session_id, user_id)
async def async_get(
self, session_id: str, user_id: Optional[str] = None
) -> Optional[AgentChat]:
"""
异步获取 Agent 会话。
"""
return await AgentChat.async_get_by_session(self._db, session_id, user_id)
def ensure_session(
self,
session_id: str,
user_id: Optional[str] = None,
username: Optional[str] = None,
channel: Optional[Union[MessageChannel, str]] = None,
source: Optional[str] = None,
original_chat_id: Optional[str] = None,
client_session_id: Optional[str] = None,
) -> Optional[AgentChat]:
"""
确保 Agent 会话记录存在,并刷新基础渠道信息。
"""
now = self._now()
chat = self.get(session_id=session_id, user_id=user_id)
if not chat:
chat = self.get(session_id=session_id)
payload = {
"user_id": user_id,
"username": username,
"channel": self._channel_value(channel),
"source": source,
"original_chat_id": original_chat_id,
"client_session_id": client_session_id,
"updated_at": now,
}
payload = {key: value for key, value in payload.items() if value is not None}
if chat:
chat.update(self._db, payload)
return self.get(session_id=session_id, user_id=user_id) or self.get(session_id=session_id)
chat = AgentChat(
session_id=session_id,
user_id=user_id,
username=username,
channel=self._channel_value(channel),
source=source,
original_chat_id=original_chat_id,
client_session_id=client_session_id,
title=DEFAULT_AGENT_CHAT_TITLE,
preview="",
agent_messages=[],
display_messages=[],
message_count=0,
created_at=now,
updated_at=now,
)
chat.create(self._db)
return self.get(session_id=session_id, user_id=user_id) or self.get(session_id=session_id)
def save_agent_messages(
self,
session_id: str,
user_id: Optional[str],
messages: list[dict],
) -> None:
"""
保存可恢复 Agent 上下文的原始消息。
"""
chat = self.get(session_id=session_id, user_id=user_id)
if not chat:
chat = self.get(session_id=session_id)
if not chat:
chat = self.ensure_session(session_id=session_id, user_id=user_id)
if not chat:
return
chat.update(
self._db,
{
"agent_messages": messages or [],
"updated_at": self._now(),
},
)
def update_title_if_empty(
self,
session_id: str,
user_id: Optional[str],
title: Optional[str],
username: Optional[str] = None,
channel: Optional[Union[MessageChannel, str]] = None,
source: Optional[str] = None,
original_chat_id: Optional[str] = None,
client_session_id: Optional[str] = None,
) -> None:
"""
在会话尚未生成标题时写入标题。
"""
normalized_title = self._normalize_title(title, [])
if normalized_title == DEFAULT_AGENT_CHAT_TITLE:
return
chat = self.ensure_session(
session_id=session_id,
user_id=user_id,
username=username,
channel=channel,
source=source,
original_chat_id=original_chat_id,
client_session_id=client_session_id,
)
if not chat:
return
if self.has_custom_title(chat.title):
return
chat.update(
self._db,
{
"title": normalized_title,
"updated_at": self._now(),
},
)
def save_display_messages(
self,
session_id: str,
user_id: Optional[str] = None,
messages: Optional[list[dict]] = None,
username: Optional[str] = None,
channel: Optional[Union[MessageChannel, str]] = None,
source: Optional[str] = None,
original_chat_id: Optional[str] = None,
client_session_id: Optional[str] = None,
title: Optional[str] = None,
) -> Optional[AgentChat]:
"""
保存用户可见的 Agent 会话消息。
"""
normalized_messages = self._normalize_messages(messages)
chat = self.ensure_session(
session_id=session_id,
user_id=user_id,
username=username,
channel=channel,
source=source,
original_chat_id=original_chat_id,
client_session_id=client_session_id,
)
if not chat:
return None
normalized_title = (
chat.title
if self.has_custom_title(chat.title)
else self._normalize_title(title, normalized_messages)
)
chat.update(
self._db,
{
"title": normalized_title,
"preview": self._normalize_preview(normalized_messages),
"display_messages": normalized_messages,
"message_count": len(normalized_messages),
"updated_at": self._now(),
},
)
return self.get(session_id=session_id, user_id=user_id) or self.get(session_id=session_id)
def append_display_messages(
self,
session_id: str,
user_id: Optional[str] = None,
messages: Optional[list[dict]] = None,
username: Optional[str] = None,
channel: Optional[Union[MessageChannel, str]] = None,
source: Optional[str] = None,
original_chat_id: Optional[str] = None,
client_session_id: Optional[str] = None,
) -> Optional[AgentChat]:
"""
追加一组用户可见的 Agent 会话消息。
"""
chat = self.ensure_session(
session_id=session_id,
user_id=user_id,
username=username,
channel=channel,
source=source,
original_chat_id=original_chat_id,
client_session_id=client_session_id,
)
if not chat:
return None
display_messages = self._normalize_messages(chat.display_messages)
display_messages.extend(self._normalize_messages(messages))
title = chat.title if self.has_custom_title(chat.title) else None
return self.save_display_messages(
session_id=session_id,
user_id=user_id,
messages=display_messages,
username=username or chat.username,
channel=channel or chat.channel,
source=source or chat.source,
original_chat_id=original_chat_id or chat.original_chat_id,
client_session_id=client_session_id or chat.client_session_id,
title=title,
)
async def async_list_by_page(
self,
page: int = 1,
count: int = 30,
user_id: Optional[str] = None,
username: Optional[str] = None,
) -> list[AgentChat]:
"""
异步分页获取 Agent 会话历史。
"""
return await AgentChat.async_list_by_page(
self._db,
page=page,
count=count,
user_id=user_id,
username=username,
)
async def async_delete(
self, session_id: str, user_id: Optional[str] = None
) -> bool:
"""
异步删除 Agent 会话历史。
"""
chat = await self.async_get(session_id=session_id, user_id=user_id)
if not chat:
return False
await AgentChat.async_delete(self._db, chat.id)
return True
@staticmethod
def to_summary(chat: AgentChat) -> dict[str, Any]:
"""
转换为历史会话摘要。
"""
return {
"id": chat.id,
"session_id": chat.session_id,
"client_session_id": chat.client_session_id,
"title": chat.title,
"channel": chat.channel,
"source": chat.source,
"user_id": chat.user_id,
"username": chat.username,
"original_chat_id": chat.original_chat_id,
"message_count": chat.message_count or 0,
"created_at": chat.created_at,
"updated_at": chat.updated_at,
}
@classmethod
def to_detail(cls, chat: AgentChat) -> dict[str, Any]:
"""
转换为历史会话详情。
"""
data = cls.to_summary(chat)
data["messages"] = chat.display_messages or []
return data
+233
View File
@@ -0,0 +1,233 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from uuid import uuid4
from app.db import DbOper
from app.db.models.agenttask import AgentTask
from app.db.models.agenttaskrun import AgentTaskRun
class AgentTaskOper(DbOper):
"""
Agent 自主定时任务管理。
"""
@staticmethod
def _now() -> str:
"""生成当前数据库时间字符串。"""
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def add(self, **kwargs: object) -> Optional[AgentTask]:
"""
新增 Agent 定时任务。
"""
now = self._now()
task_id = AgentTask.add_task(
self._db,
**kwargs,
enabled=True,
last_status="waiting",
run_count=0,
created_at=now,
updated_at=now,
)
return self.get(task_id)
def get(
self,
task_id: int,
user_id: Optional[str] = None,
) -> Optional[AgentTask]:
"""
查询单个 Agent 定时任务。
"""
return AgentTask.get_for_user(self._db, task_id=task_id, user_id=user_id)
def list(
self,
user_id: Optional[str] = None,
enabled: Optional[bool] = None,
) -> list[AgentTask]:
"""
查询 Agent 定时任务列表。
"""
return AgentTask.list_for_user(self._db, user_id=user_id, enabled=enabled)
def update(
self,
task_id: int,
payload: dict,
user_id: Optional[str] = None,
) -> bool:
"""
更新 Agent 定时任务。
"""
normalized_payload = {
key: value
for key, value in payload.items()
if key in {
"name",
"content",
"trigger_type",
"cron_expression",
"run_at",
"enabled",
"last_status",
"last_result",
}
}
if not normalized_payload:
return False
normalized_payload["updated_at"] = self._now()
return AgentTask.update_task(
self._db,
task_id=task_id,
payload=normalized_payload,
user_id=user_id,
)
def delete(self, task_id: int, user_id: Optional[str] = None) -> bool:
"""
删除非运行中的 Agent 定时任务及其运行历史。
"""
return AgentTaskRun.delete_task_and_runs(
self._db,
task_id=task_id,
user_id=user_id,
)
def begin_run(
self,
task_id: int,
trigger_source: str = "scheduled",
) -> Optional[AgentTaskRun]:
"""
原子创建一次运行并返回其任务快照。
"""
run_id = uuid4().hex
created_run_id = AgentTaskRun.begin_run(
self._db,
task_id=task_id,
run_id=run_id,
trigger_source=trigger_source,
started_at=self._now(),
)
return self.get_run(created_run_id) if created_run_id else None
def mark_running(self, task_id: int) -> bool:
"""兼容既有调用并为该次执行创建运行记录。"""
return self.begin_run(task_id=task_id) is not None
def mark_interrupted(self, task_id: int, result: str) -> bool:
"""
将遗留的运行中任务标记为中断且结果未知。
"""
return AgentTaskRun.interrupt_task(
self._db,
task_id=task_id,
result=(result or "")[:20000],
finished_at=self._now(),
)
def get_run(self, run_id: str) -> Optional[AgentTaskRun]:
"""查询一次 Agent 任务运行。"""
return AgentTaskRun.get_by_run_id(self._db, run_id=run_id)
def list_runs(
self,
task_id: int,
user_id: Optional[str] = None,
limit: int = 10,
) -> list[AgentTaskRun]:
"""查询任务最近的有界运行历史。"""
return AgentTaskRun.list_for_task(
self._db,
task_id=task_id,
user_id=user_id,
limit=limit,
)
def finish_run(
self,
run_id: str,
success: bool,
result: str,
disable_date_task: bool = False,
) -> bool:
"""收口精确运行并更新仍匹配的任务投影。"""
return AgentTaskRun.finish_run(
self._db,
run_id=run_id,
success=success,
result=(result or "")[:20000],
finished_at=self._now(),
disable_date_task=disable_date_task,
)
def finish(
self,
task_id: int,
success: bool,
result: str,
disable: bool = False,
) -> bool:
"""
记录 Agent 定时任务执行结果。
"""
task = self.get(task_id)
if not task or not task.last_run_id:
return False
return self.finish_run(
run_id=task.last_run_id,
success=success,
result=result,
disable_date_task=disable,
)
@staticmethod
def to_dict(
task: AgentTask,
next_run_at: Optional[str] = None,
timezone: Optional[str] = None,
) -> dict:
"""
将 Agent 定时任务转换为工具可返回的结构。
"""
return {
"id": task.id,
"name": task.name,
"content": task.content,
"trigger_type": task.trigger_type,
"cron_expression": task.cron_expression,
"run_at": task.run_at,
"timezone": timezone,
"enabled": bool(task.enabled),
"last_status": task.last_status,
"last_run_at": task.last_run_at,
"last_result": task.last_result,
"last_run_id": task.last_run_id,
"run_count": task.run_count or 0,
"next_run_at": next_run_at,
"created_at": task.created_at,
"updated_at": task.updated_at,
}
@staticmethod
def run_to_dict(run: AgentTaskRun) -> dict:
"""将一次 Agent 任务运行转换为工具返回结构。"""
return {
"run_id": run.run_id,
"task_id": run.task_id,
"trigger_source": run.trigger_source,
"name": run.name,
"content": run.content,
"trigger_type": run.trigger_type,
"cron_expression": run.cron_expression,
"run_at": run.run_at,
"status": run.status,
"started_at": run.started_at,
"finished_at": run.finished_at,
"result": run.result,
}
+61
View File
@@ -0,0 +1,61 @@
from typing import Dict, List, Optional
from app.db import DbOper
from app.db.models.downloadfailure import DownloadFailure
class DownloadFailureOper(DbOper):
"""
下载失败冷却记录管理。
"""
def get_active_by_fingerprints(
self,
fingerprints: List[str],
now_time: str,
) -> Dict[str, DownloadFailure]:
"""
批量按指纹查询仍在冷却期的失败记录。
"""
failures = DownloadFailure.get_active_by_fingerprints(
self._db,
fingerprints=fingerprints,
now_time=now_time,
)
return {
failure.fingerprint: failure
for failure in failures
if failure and failure.fingerprint
}
def record_failure(
self,
fingerprint: str,
now_time: str,
next_retry_at: str,
**kwargs: object,
) -> DownloadFailure:
"""
新增或更新资源失败记录。
"""
return DownloadFailure.record_failure(
self._db,
fingerprint=fingerprint,
now_time=now_time,
next_retry_at=next_retry_at,
**kwargs,
)
def delete_expired(
self,
before_time: str,
limit: Optional[int] = 500,
) -> int:
"""
删除已过期较久的失败记录。
"""
return DownloadFailure.delete_expired(
self._db,
before_time=before_time,
limit=limit,
)
+199
View File
@@ -0,0 +1,199 @@
from typing import Dict, List, Optional, cast
from app.db import DbOper
from app.db.models.downloadhistory import DownloadHistory, DownloadFiles
from app.schemas.types import MediaSource
class DownloadHistoryOper(DbOper):
"""
下载历史管理
"""
def get_by_path(self, path: str) -> Optional[DownloadHistory]:
"""
按路径查询下载记录
:param path: 数据key
"""
return DownloadHistory.get_by_path(self._db, path)
def get_by_hash(self, download_hash: str) -> Optional[DownloadHistory]:
"""
按Hash查询下载记录
:param download_hash: 数据key
"""
return DownloadHistory.get_by_hash(self._db, download_hash)
def get_by_hashes(self, download_hashes: List[str]) -> Dict[str, DownloadHistory]:
"""
批量按 Hash 查询下载记录,并返回以 Hash 为键的映射。
"""
histories = DownloadHistory.get_by_hashes(self._db, download_hashes)
return {
history.download_hash: history
for history in histories
if history and history.download_hash
}
def get_by_media_identity(
self, media_source: MediaSource, media_id: str,
music_type: Optional[str] = None,
) -> List[DownloadHistory]:
"""
按规范媒体身份查询下载记录。
:param media_source: 媒体数据源
:param media_id: 数据源原生 ID
:param music_type: 音乐实体类型
"""
return DownloadHistory.get_by_media_identity(
self._db,
media_source=media_source,
media_id=media_id,
music_type=music_type,
)
def add(self, **kwargs):
"""
新增下载历史
"""
DownloadHistory(**kwargs).create(self._db)
def add_files(self, file_items: List[dict]):
"""
新增下载历史文件
"""
for file_item in file_items:
downloadfile = DownloadFiles(**file_item)
downloadfile.create(self._db)
def truncate_files(self):
"""
清空下载历史文件记录
"""
DownloadFiles.truncate(self._db)
def get_files_by_hash(self, download_hash: str, state: Optional[int] = None) -> List[DownloadFiles]:
"""
按Hash查询下载文件记录
:param download_hash: 数据key
:param state: 删除状态
"""
return DownloadFiles.get_by_hash(self._db, download_hash, state)
def get_file_by_fullpath(self, fullpath: str) -> Optional[DownloadFiles]:
"""
按fullpath查询下载文件记录
:param fullpath: 数据key
"""
return cast(Optional[DownloadFiles],
DownloadFiles.get_by_fullpath(self._db, fullpath=fullpath, all_files=False))
def get_files_by_fullpath(self, fullpath: str) -> List[DownloadFiles]:
"""
按fullpath查询下载文件记录
:param fullpath: 数据key
"""
return cast(List[DownloadFiles],
DownloadFiles.get_by_fullpath(self._db, fullpath=fullpath, all_files=True))
def get_files_by_savepath(self, fullpath: str) -> List[DownloadFiles]:
"""
按savepath查询下载文件记录
:param fullpath: 数据key
"""
return DownloadFiles.get_by_savepath(self._db, fullpath)
def delete_file_by_fullpath(self, fullpath: str):
"""
按fullpath删除下载文件记录
:param fullpath: 数据key
"""
DownloadFiles.delete_by_fullpath(self._db, fullpath)
def get_hash_by_fullpath(self, fullpath: str) -> Optional[str]:
"""
按fullpath查询下载文件记录hash
:param fullpath: 数据key
"""
fileinfo = cast(Optional[DownloadFiles],
DownloadFiles.get_by_fullpath(self._db, fullpath=fullpath, all_files=False))
if fileinfo:
return fileinfo.download_hash
return ""
def list_by_page(self, page: int = 1, count: int = 30) -> List[DownloadHistory]:
"""
分页查询下载历史
"""
return DownloadHistory.list_by_page(self._db, page, count)
async def async_delete_history(self, historyid: int):
"""
异步删除下载记录。
"""
await DownloadHistory.async_delete(self._db, historyid)
def truncate(self):
"""
清空下载记录
"""
DownloadHistory.truncate(self._db)
def get_last_by(self, mtype=None, title: Optional[str] = None, year: Optional[str] = None,
season: Optional[str] = None, episode: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None) -> List[DownloadHistory]:
"""
按类型、标题、年份、季集查询下载记录
媒体身份 + mtype 或 title + year
"""
return DownloadHistory.get_last_by(db=self._db,
mtype=mtype,
title=title,
year=year,
season=season,
episode=episode,
media_source=media_source,
media_id=media_id)
def list_by_user_date(self, date: str, username: Optional[str] = None) -> List[DownloadHistory]:
"""
查询某用户某时间之前的下载历史
"""
return DownloadHistory.list_by_user_date(db=self._db,
date=date,
username=username)
def list_by_date(
self, date: str, type: str, media_source: MediaSource, media_id: str,
seasons: Optional[str] = None,
) -> List[DownloadHistory]:
"""
查询某时间之后的下载历史
"""
return DownloadHistory.list_by_date(db=self._db,
date=date,
type=type,
media_source=media_source,
media_id=media_id,
seasons=seasons)
def list_by_type(self, mtype: str, days: int = 7) -> List[DownloadHistory]:
"""
获取指定类型的下载历史
"""
return DownloadHistory.list_by_type(db=self._db,
mtype=mtype,
days=days)
def delete_history(self, historyid):
"""
删除下载记录
"""
DownloadHistory.delete(self._db, historyid)
def delete_downloadfile(self, downloadfileid):
"""
删除下载文件记录
"""
DownloadFiles.delete(self._db, downloadfileid)
+152
View File
@@ -0,0 +1,152 @@
from typing import Optional
from sqlalchemy.orm import Session
from app.db import DbOper
from app.db.models.mediaserver import MediaServerItem
class MediaServerOper(DbOper):
"""
媒体服务器数据管理
"""
def __init__(self, db: Optional[Session] = None):
super().__init__(db)
@staticmethod
def __prepare_payload(kwargs: dict) -> dict:
"""
过滤数据库模型不存在或不应由远端覆盖的字段
"""
return {
k: v for k, v in kwargs.items()
if hasattr(MediaServerItem, k) and k != "id"
}
def add(self, **kwargs) -> bool:
"""
新增媒体服务器数据
"""
kwargs = self.__prepare_payload(kwargs)
server = kwargs.get("server")
item_id = kwargs.get("item_id")
if not server or not item_id:
return False
item = MediaServerItem(**kwargs)
if not item.get_by_server_itemid(self._db, server, item_id):
item.create(self._db)
return True
return False
def upsert(self, **kwargs) -> bool:
"""
按媒体服务器和条目ID新增或更新数据
"""
kwargs = self.__prepare_payload(kwargs)
server = kwargs.get("server")
item_id = kwargs.get("item_id")
if not server or not item_id:
return False
item = MediaServerItem.get_by_server_itemid(self._db, server, item_id)
if item:
item.update(self._db, kwargs)
return False
MediaServerItem(**kwargs).create(self._db)
return True
def empty(self, server: Optional[str] = None):
"""
清空媒体服务器数据
"""
MediaServerItem.empty(self._db, server)
def delete_stale(self, server: str, sync_time: str) -> int:
"""
删除本轮同步未更新的旧数据
"""
return MediaServerItem.delete_stale(self._db, server, sync_time)
def delete_excluded_servers(self, servers: list[str]) -> int:
"""
删除未启用或已移除媒体服务器的数据
"""
return MediaServerItem.delete_excluded_servers(self._db, servers)
def exists(self, **kwargs) -> Optional[MediaServerItem]:
"""
判断媒体服务器数据是否存在
"""
if kwargs.get("media_source") and kwargs.get("media_id"):
item = MediaServerItem.exist_by_media_identity(
self._db,
media_source=kwargs.get("media_source"),
media_id=kwargs.get("media_id"),
mtype=kwargs.get("mtype"),
)
elif kwargs.get("title"):
# 按标题、类型、年份查
item = MediaServerItem.exists_by_title(self._db, title=kwargs.get("title"),
mtype=kwargs.get("mtype"), year=kwargs.get("year"))
else:
return None
if not item:
return None
if kwargs.get("season") is not None:
# 判断季是否存在
if not item.seasoninfo:
return None
seasoninfo = item.seasoninfo or {}
if kwargs.get("season") not in seasoninfo.keys():
return None
return item
async def async_exists(self, **kwargs) -> Optional[MediaServerItem]:
"""
异步判断媒体服务器数据是否存在
"""
if kwargs.get("media_source") and kwargs.get("media_id"):
item = await MediaServerItem.async_exist_by_media_identity(
self._db,
media_source=kwargs.get("media_source"),
media_id=kwargs.get("media_id"),
mtype=kwargs.get("mtype"),
)
elif kwargs.get("title"):
# 按标题、类型、年份查
item = await MediaServerItem.async_exists_by_title(self._db, title=kwargs.get("title"),
mtype=kwargs.get("mtype"), year=kwargs.get("year"))
else:
return None
if not item:
return None
if kwargs.get("season") is not None:
# 判断季是否存在
if not item.seasoninfo:
return None
seasoninfo = item.seasoninfo or {}
if kwargs.get("season") not in seasoninfo.keys():
return None
return item
def get_item_id(self, **kwargs) -> Optional[str]:
"""
获取媒体服务器数据ID
"""
item = self.exists(**kwargs)
if not item:
return None
return str(item.item_id)
async def async_get_item_id(self, **kwargs) -> Optional[str]:
"""
异步获取媒体服务器数据ID
"""
item = await self.async_exists(**kwargs)
if not item:
return None
return str(item.item_id)
+143
View File
@@ -0,0 +1,143 @@
import time
from typing import Optional, Union
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.db import DbOper
from app.db.models.message import Message
from app.schemas import MessageChannel, NotificationType
class MessageOper(DbOper):
"""
消息数据管理
"""
def __init__(self, db: Optional[Union[Session, AsyncSession]] = None):
super().__init__(db)
def add(self,
channel: Optional[MessageChannel] = None,
source: Optional[str] = None,
mtype: Optional[NotificationType] = None,
title: Optional[str] = None,
text: Optional[str] = None,
image: Optional[str] = None,
link: Optional[str] = None,
userid: Optional[str] = None,
action: Optional[int] = 1,
note: Optional[Union[list, dict]] = None,
**kwargs) -> dict:
"""
新增消息
:param channel: 消息渠道
:param source: 来源
:param mtype: 消息类型
:param title: 标题
:param text: 文本内容
:param image: 图片
:param link: 链接
:param userid: 用户ID
:param action: 消息方向:0-接收息,1-发送消息
:param note: 附件json
"""
kwargs.update({
"channel": channel.value if channel else '',
"source": source,
"mtype": mtype.value if mtype else '',
"title": title,
"text": text,
"image": image,
"link": link,
"userid": userid,
"action": action,
"reg_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
"note": note or {}
})
# 从kwargs中去掉Message中没有的字段
for k in list(kwargs.keys()):
if k not in Message.__table__.columns.keys(): # noqa
kwargs.pop(k)
return Message(**kwargs).create_and_to_dict(self._db)
async def async_add(self,
channel: Optional[MessageChannel] = None,
source: Optional[str] = None,
mtype: Optional[NotificationType] = None,
title: Optional[str] = None,
text: Optional[str] = None,
image: Optional[str] = None,
link: Optional[str] = None,
userid: Optional[str] = None,
action: Optional[int] = 1,
note: Optional[Union[list, dict]] = None,
**kwargs) -> Message:
"""
异步新增消息
"""
kwargs.update({
"channel": channel.value if channel else '',
"source": source,
"mtype": mtype.value if mtype else '',
"title": title,
"text": text,
"image": image,
"link": link,
"userid": userid,
"action": action,
"reg_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
"note": note or {}
})
# 从kwargs中去掉Message中没有的字段
for k in list(kwargs.keys()):
if k not in Message.__table__.columns.keys(): # noqa
kwargs.pop(k)
return await Message(**kwargs).async_create(self._db)
def list_by_page(self, page: int = 1, count: int = 30) -> list[Message]:
"""
分页获取消息记录。
"""
return Message.list_by_page(self._db, page, count)
def exists_by_source(self, source: str) -> bool:
"""
判断指定来源标识的消息记录是否存在。
:param source: 消息来源唯一标识
:return: 是否存在匹配记录
"""
return Message.exists_by_source(self._db, source)
async def async_list_by_page(
self, page: int = 1, count: int = 30
) -> list[Message]:
"""
分页获取消息记录。
"""
return await Message.async_list_by_page(self._db, page, count)
async def async_list_sent_by_page(
self,
page: int = 1,
count: int = 30,
all_clear_before: Optional[str] = None,
system_clear_before: Optional[str] = None,
media_clear_before: Optional[str] = None,
) -> list[Message]:
"""
分页获取系统发送的通知消息。
"""
return await Message.async_list_sent_by_page(
self._db,
page,
count,
all_clear_before=all_clear_before,
system_clear_before=system_clear_before,
media_clear_before=media_clear_before,
)
+103
View File
@@ -0,0 +1,103 @@
from typing import Any, Optional
from app.db import DbOper
from app.db.models.plugindata import PluginData
class PluginDataOper(DbOper):
"""
插件数据管理
"""
def save(self, plugin_id: str, key: str, value: Any):
"""
保存插件数据
:param plugin_id: 插件id
:param key: 数据key
:param value: 数据值
"""
plugin = PluginData.get_plugin_data_by_key(self._db, plugin_id, key)
if plugin:
plugin.update(self._db, {
"value": value
})
else:
PluginData(plugin_id=plugin_id, key=key, value=value).create(self._db)
async def async_save(self, plugin_id: str, key: str, value: Any) -> None:
"""
异步保存插件数据
:param plugin_id: 插件ID
:param key: 数据键
:param value: 数据值
"""
plugin = await PluginData.async_get_plugin_data_by_key(
self._db, plugin_id, key
)
if plugin:
await plugin.async_update(self._db, {"value": value})
else:
await PluginData(
plugin_id=plugin_id, key=key, value=value
).async_create(self._db)
def get_data(self, plugin_id: str, key: Optional[str] = None) -> Any:
"""
获取插件数据
:param plugin_id: 插件id
:param key: 数据key
"""
if key:
data = PluginData.get_plugin_data_by_key(self._db, plugin_id, key)
if not data:
return None
return data.value
else:
return PluginData.get_plugin_data(self._db, plugin_id)
async def async_get_data(self, plugin_id: str, key: Optional[str] = None) -> Any:
"""
异步获取插件数据。
:param plugin_id: 插件id
:param key: 数据key
"""
if key:
data = await PluginData.async_get_plugin_data_by_key(
self._db, plugin_id, key
)
if not data:
return None
return data.value
return await PluginData.async_get_plugin_data(self._db, plugin_id)
def del_data(self, plugin_id: str, key: Optional[str] = None) -> Any:
"""
删除插件数据
:param plugin_id: 插件id
:param key: 数据key
"""
if key:
PluginData.del_plugin_data_by_key(self._db, plugin_id, key)
else:
PluginData.del_plugin_data(self._db, plugin_id)
def truncate(self):
"""
清空插件数据
"""
PluginData.truncate(self._db)
def get_data_all(self, plugin_id: str) -> Any:
"""
获取插件所有数据
:param plugin_id: 插件id
"""
return PluginData.get_plugin_data_by_plugin_id(self._db, plugin_id)
async def async_get_data_all(self, plugin_id: str) -> Any:
"""
异步获取插件所有数据。
:param plugin_id: 插件id
"""
return await PluginData.async_get_plugin_data_by_plugin_id(self._db, plugin_id)
+349
View File
@@ -0,0 +1,349 @@
from datetime import datetime
from typing import List, Tuple, Optional
from app.db import DbOper
from app.db.models import SiteIcon
from app.db.models.site import Site
from app.db.models.sitestatistic import SiteStatistic
from app.db.models.siteuserdata import SiteUserData
class SiteOper(DbOper):
"""
站点管理
"""
def add(self, **kwargs) -> Tuple[bool, str]:
"""
新增站点
"""
site = Site(**kwargs)
if not site.get_by_domain(self._db, kwargs.get("domain")):
site.create(self._db)
return True, "新增站点成功"
return False, "站点已存在"
def get(self, sid: int) -> Optional[Site]:
"""
查询单个站点
"""
return Site.get(self._db, sid)
async def async_get(self, sid: int) -> Optional[Site]:
"""
异步查询单个站点
"""
return await Site.async_get(self._db, sid)
def list(self) -> List[Site]:
"""
获取站点列表
"""
return Site.list(self._db)
async def async_list(self) -> List[Site]:
"""
异步获取站点列表
"""
return await Site.async_list(self._db)
def list_order_by_pri(self) -> List[Site]:
"""
获取站点列表
"""
return Site.list_order_by_pri(self._db)
def list_active(self) -> List[Site]:
"""
按状态获取站点列表
"""
return Site.get_actives(self._db)
async def async_list_active(self) -> List[Site]:
"""
异步按状态获取站点列表
"""
return await Site.async_get_actives(self._db)
def delete(self, sid: int):
"""
删除站点
"""
Site.delete(self._db, sid)
def update(self, sid: int, payload: dict) -> Optional[Site]:
"""
更新站点
"""
site = Site.get(self._db, sid)
if not site:
return None
site.update(self._db, payload)
return site
async def async_update(self, sid: int, payload: dict) -> Optional[Site]:
"""
异步更新站点。
"""
site = await self.async_get(sid)
if site:
await site.async_update(self._db, payload)
return site
def get_by_domain(self, domain: str) -> Optional[Site]:
"""
按域名获取站点
"""
return Site.get_by_domain(self._db, domain)
async def async_get_by_domain(self, domain: str) -> Optional[Site]:
"""
异步按域名获取站点
"""
return await Site.async_get_by_domain(self._db, domain)
async def async_get_by_name(self, name: str) -> Optional[Site]:
"""
异步按名称获取站点
"""
return await Site.async_get_by_name(self._db, name)
def get_domains_by_ids(self, ids: List[int]) -> List[Optional[str]]:
"""
按ID获取站点域名
"""
return Site.get_domains_by_ids(self._db, ids)
def exists(self, domain: str) -> bool:
"""
判断站点是否存在
"""
return Site.get_by_domain(self._db, domain) is not None
def update_cookie(self, domain: str, cookies: str) -> Tuple[bool, str]:
"""
更新站点Cookie
"""
site = Site.get_by_domain(self._db, domain)
if not site:
return False, "站点不存在"
site.update(self._db, {
"cookie": cookies
})
return True, "更新站点Cookie成功"
def update_rss(self, domain: str, rss: str) -> Tuple[bool, str]:
"""
更新站点rss
"""
site = Site.get_by_domain(self._db, domain)
if not site:
return False, "站点不存在"
site.update(self._db, {
"rss": rss
})
return True, "更新站点RSS地址成功"
def update_userdata(self, domain: str, name: str, payload: dict) -> 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 = SiteUserData.get_by_domain(self._db, domain=domain, workdate=current_day)
if siteuserdatas:
# 存在则更新
if not payload.get("err_msg"):
siteuserdatas[0].update(self._db, payload)
else:
# 不存在则插入
SiteUserData(**payload).create(self._db)
return True, "更新站点用户数据成功"
def get_userdata(self) -> List[SiteUserData]:
"""
获取站点用户数据
"""
return SiteUserData.list(self._db)
def get_userdata_by_domain(self, domain: str, workdate: Optional[str] = None) -> List[SiteUserData]:
"""
获取站点用户数据
"""
return SiteUserData.get_by_domain(self._db, domain=domain, workdate=workdate)
async def async_get_userdata_by_domain(
self, domain: str, workdate: Optional[str] = None
) -> List[SiteUserData]:
"""
异步获取站点用户数据。
"""
return await SiteUserData.async_get_by_domain(
self._db, domain=domain, workdate=workdate
)
def get_userdata_by_date(self, date: str) -> List[SiteUserData]:
"""
获取站点用户数据
"""
return SiteUserData.get_by_date(self._db, date)
def get_userdata_latest(self) -> List[SiteUserData]:
"""
获取站点最新数据
"""
return SiteUserData.get_latest(self._db)
def get_icon_by_domain(self, domain: str) -> Optional[SiteIcon]:
"""
按域名获取站点图标
"""
return SiteIcon.get_by_domain(self._db, 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 ""
siteicon = self.get_icon_by_domain(domain)
if not siteicon:
SiteIcon(name=name, domain=domain, url=icon_url, base64=icon_base64).create(self._db)
elif icon_base64:
siteicon.update(self._db, {
"url": icon_url,
"base64": icon_base64
})
return True
def success(self, domain: str, seconds: Optional[int] = None):
"""
站点访问成功
"""
lst_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
sta = SiteStatistic.get_by_domain(self._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
sta.update(self._db, {
"success": sta.success + 1,
"seconds": avg_seconds or sta.seconds,
"lst_state": 0,
"lst_mod_date": lst_date,
"note": note
})
else:
note = {}
if seconds is not None:
note = {
lst_date: seconds or 1
}
SiteStatistic(
domain=domain,
success=1,
fail=0,
seconds=seconds or 1,
lst_state=0,
lst_mod_date=lst_date,
note=note
).create(self._db)
def fail(self, domain: str):
"""
站点访问失败
"""
lst_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
sta = SiteStatistic.get_by_domain(self._db, domain)
if sta:
sta.update(self._db, {
"fail": sta.fail + 1,
"lst_state": 1,
"lst_mod_date": lst_date
})
else:
SiteStatistic(
domain=domain,
success=0,
fail=1,
lst_state=1,
lst_mod_date=lst_date
).create(self._db)
async def async_success(self, domain: str, seconds: Optional[int] = None):
"""
异步站点访问成功
"""
lst_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
sta = await SiteStatistic.async_get_by_domain(self._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
await sta.async_update(self._db, {
"success": sta.success + 1,
"seconds": avg_seconds or sta.seconds,
"lst_state": 0,
"lst_mod_date": lst_date,
"note": note
})
else:
note = {}
if seconds is not None:
note = {
lst_date: seconds or 1
}
await SiteStatistic(
domain=domain,
success=1,
fail=0,
seconds=seconds or 1,
lst_state=0,
lst_mod_date=lst_date,
note=note
).async_create(self._db)
async def async_fail(self, domain: str):
"""
异步站点访问失败
"""
lst_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
sta = await SiteStatistic.async_get_by_domain(self._db, domain)
if sta:
await sta.async_update(self._db, {
"fail": sta.fail + 1,
"lst_state": 1,
"lst_mod_date": lst_date
})
else:
await SiteStatistic(
domain=domain,
success=0,
fail=1,
lst_state=1,
lst_mod_date=lst_date
).async_create(self._db)
+280
View File
@@ -0,0 +1,280 @@
"""
订阅数据访问。
本模块只收敛针对订阅表的读写。把 MediaInfo / MusicInfo 翻译成一行订阅是订阅业务的
规则,住在 app/application/subscribe.py;这里收到的 payload 已经是纯粹的持久化字段,
因此不 import 任何领域对象。
留在这一层的只有列类型强转与建库时间戳——它们跟着订阅表的列走,换谁来调都一样。
"""
import time
from typing import Any, Tuple, List, Optional
from app.db import DbOper
from app.db.models.subscribe import Subscribe
from app.db.models.subscribehistory import SubscribeHistory
from app.schemas.types import MediaSource
INTEGER_FLAG_FIELDS = ("best_version", "best_version_full", "search_imdbid", "manual_total_episode")
def _normalize_integer_flags(payload: dict, fields: Tuple[str, ...] = INTEGER_FLAG_FIELDS) -> dict:
"""
将历史兼容的布尔开关转换为整型值,避免 PostgreSQL 严格类型检查失败。
"""
normalized_payload = dict(payload)
for field in fields:
if isinstance(normalized_payload.get(field), bool):
normalized_payload[field] = int(normalized_payload[field])
return normalized_payload
def _normalize_year(year: Optional[int | str]) -> Optional[str]:
"""
订阅表的 year 列为字符串类型,而识别链路的媒体年份可能是数字
(音乐等来源),写库前统一转换为字符串避免数据库类型错误。
"""
if year is None:
return None
return str(year)
def _persistable(payload: dict) -> dict:
"""
把应用层给的写入字段落成订阅表能收的一行。
做两件事。一是列类型强转:PostgreSQL 的整型列拒收布尔值、字符串列拒收数字,而
SQLite 会靠类型亲和悄悄替我们转好——漏了只在生产库上炸,所以放在紧挨建模的地方。
二是盖建库时间戳:调用方传进来的 date 不作数,否则订阅列表的默认排序与过期清理
都会读到一个假的建库时间。
:param payload: 应用层翻译好的写入字段
:return: 可直接建模的字段字典
"""
persistable = _normalize_integer_flags(payload)
persistable["year"] = _normalize_year(persistable.get("year"))
# search_imdbid 参与搜索分支判定,None 与真值都要归一到 0/1,否则同一列在不同
# 订阅上会存出三种形态,PG 上还会直接拒写
persistable["search_imdbid"] = 1 if persistable.get("search_imdbid") else 0
persistable["date"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
return persistable
class SubscribeOper(DbOper):
"""
订阅管理
"""
def _exists(self, identity: dict, username: Optional[str]) -> Optional[Any]:
"""
按身份查重。
:param identity: 查重身份
:param username: 非空时只在该用户的订阅内查
:return: 命中的订阅行,未命中为 None
"""
if username:
return Subscribe.exists_by_username(self._db, username=username, **identity)
return Subscribe.exists(self._db, **identity)
async def _async_exists(self, identity: dict, username: Optional[str]) -> Optional[Any]:
"""
按身份查重(异步)。
:param identity: 查重身份
:param username: 非空时只在该用户的订阅内查
:return: 命中的订阅行,未命中为 None
"""
if username:
return await Subscribe.async_exists_by_username(self._db, username=username, **identity)
return await Subscribe.async_exists(self._db, **identity)
def add(self, identity: dict, payload: dict,
username: Optional[str] = None) -> Tuple[int, str]:
"""
新增订阅:命中既有订阅则原样返回,否则落库后回读。
回读不是多余的一次查询——写入可能被唯一约束或事务回滚吞掉,此时若报成功,
调用方会继续按订阅已建立往下走,用户看到「订阅成功」却永远等不到资源。
:param identity: 查重身份(media_source/media_id/music_type/season/episode_group
:param payload: 订阅表的写入字段,媒体翻译由 app/application/subscribe.py 完成
:param username: 非空时把查重限定在该用户的订阅内
:return: (订阅 ID, 结果说明)ID 为 0 表示未新增
"""
subscribe = self._exists(identity, username)
if subscribe:
return subscribe.id, "订阅已存在"
Subscribe(**_persistable(payload)).create(self._db)
subscribe = self._exists(identity, username)
if not subscribe:
return 0, "新增订阅失败"
return subscribe.id, "新增订阅成功"
async def async_add(self, identity: dict, payload: dict,
username: Optional[str] = None) -> Tuple[int, str]:
"""
异步新增订阅,语义与 add 完全一致。
:param identity: 查重身份(media_source/media_id/music_type/season/episode_group
:param payload: 订阅表的写入字段,媒体翻译由 app/application/subscribe.py 完成
:param username: 非空时把查重限定在该用户的订阅内
:return: (订阅 ID, 结果说明)ID 为 0 表示未新增
"""
subscribe = await self._async_exists(identity, username)
if subscribe:
return subscribe.id, "订阅已存在"
await Subscribe(**_persistable(payload)).async_create(self._db)
subscribe = await self._async_exists(identity, username)
if not subscribe:
return 0, "新增订阅失败"
return subscribe.id, "新增订阅成功"
def exists(
self, media_source: MediaSource, media_id: str,
season: Optional[int] = None, episode_group: Optional[str] = None,
music_type: Optional[str] = None,
) -> bool:
"""
按媒体身份、季号及可选剧集组判断订阅是否存在。
"""
identity_params = {
"media_source": media_source,
"media_id": media_id,
"music_type": music_type,
"season": season,
"episode_group": episode_group,
}
return bool(Subscribe.exists(self._db, **identity_params))
def get(self, sid: int) -> Optional[Subscribe]:
"""
获取订阅
"""
return Subscribe.get(self._db, rid=sid)
async def async_get(self, sid: int) -> Optional[Subscribe]:
"""
获取订阅
"""
return await Subscribe.async_get(self._db, rid=sid)
def get_by(
self, type: str, media_source: MediaSource, media_id: str,
season: Optional[str] = None,
music_type: Optional[str] = None,
) -> Optional[Subscribe]:
"""
根据条件查询订阅
"""
return Subscribe.get_by(
self._db, type, media_source, media_id, season, music_type,
)
async def async_get_by(
self, type: str, media_source: MediaSource, media_id: str,
season: Optional[str] = None,
music_type: Optional[str] = None,
) -> Optional[Subscribe]:
"""
根据条件查询订阅
"""
return await Subscribe.async_get_by(
self._db, type, media_source, media_id, season, music_type,
)
def list(self, state: Optional[str] = None) -> List[Subscribe]:
"""
获取订阅列表
"""
if state:
return Subscribe.get_by_state(self._db, state)
return Subscribe.list(self._db)
async def async_list(self, state: Optional[str] = None) -> List[Subscribe]:
"""
异步获取订阅列表
"""
if state:
return await Subscribe.async_get_by_state(self._db, state)
return await Subscribe.async_list(self._db)
def delete(self, sid: int):
"""
删除订阅
"""
Subscribe.delete(self._db, rid=sid)
async def async_delete(self, sid: int):
"""
异步删除订阅。
"""
await Subscribe.async_delete(self._db, rid=sid)
async def async_update(self, sid: int, payload: dict) -> Optional[Subscribe]:
"""
异步更新订阅。
"""
subscribe = await self.async_get(sid)
if subscribe:
payload = _normalize_integer_flags(payload)
await subscribe.async_update(self._db, payload)
return subscribe
async def async_update_filter_groups(
self, sid: int, filter_groups: List[str]
) -> Optional[Subscribe]:
"""
异步更新订阅使用的过滤规则组。
"""
return await self.async_update(sid, {"filter_groups": filter_groups})
def update(self, sid: int, payload: dict) -> Optional[Subscribe]:
"""
更新订阅
"""
subscribe = self.get(sid)
if subscribe:
payload = _normalize_integer_flags(payload)
subscribe.update(self._db, payload)
return subscribe
def list_by_username(self, username: str, state: Optional[str] = None,
mtype: Optional[str] = None) -> List[Subscribe]:
"""
获取指定用户的订阅
"""
return Subscribe.list_by_username(self._db, username=username, state=state, mtype=mtype)
def list_by_type(self, mtype: str, days: int = 7) -> List[Subscribe]:
"""
获取指定类型的订阅
"""
return Subscribe.list_by_type(self._db, mtype=mtype, days=days)
def add_history(self, **kwargs):
"""
新增订阅
"""
# 去除kwargs中 SubscribeHistory 没有的字段
kwargs = {k: v for k, v in kwargs.items() if hasattr(SubscribeHistory, k)}
kwargs = _normalize_integer_flags(kwargs)
# 更新完成订阅时间
kwargs.update({"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())})
# 去掉主键
if "id" in kwargs:
kwargs.pop("id")
subscribe = SubscribeHistory(**kwargs)
subscribe.create(self._db)
def exist_history(
self, media_source: MediaSource, media_id: str,
season: Optional[int] = None, episode_group: Optional[str] = None,
music_type: Optional[str] = None,
) -> bool:
"""
按媒体身份、季号及可选剧集组判断订阅历史是否存在。
"""
identity_params = {
"media_source": media_source,
"media_id": media_id,
"music_type": music_type,
"season": season,
"episode_group": episode_group,
}
return bool(SubscribeHistory.exists(self._db, **identity_params))
+26
View File
@@ -0,0 +1,26 @@
from typing import List, Optional
from app.db import DbOper
from app.db.models.subscribehistory import SubscribeHistory
class SubscribeHistoryOper(DbOper):
"""
订阅历史管理。
"""
async def async_list_by_type(
self,
mtype: str,
page: int = 1,
count: int = 30,
) -> List[SubscribeHistory]:
"""
异步按媒体类型分页查询订阅历史。
"""
return await SubscribeHistory.async_list_by_type(
self._db,
mtype=mtype,
page=page,
count=count,
)
+136
View File
@@ -0,0 +1,136 @@
import asyncio
import copy
import threading
from typing import Any, Optional, Union
from app.db import DbOper
from app.db.models.systemconfig import SystemConfig
from app.schemas.types import SystemConfigKey
from app.foundation.singleton import Singleton
class SystemConfigOper(DbOper, metaclass=Singleton):
"""
系统配置管理
"""
def __init__(self):
"""
加载配置到内存
"""
super().__init__()
self.__SYSTEMCONF = {}
self._rlock = threading.RLock()
self._alock = asyncio.Lock()
for item in SystemConfig.list(self._db):
self.__SYSTEMCONF[item.key] = item.value
def set(self, key: Union[str, SystemConfigKey], value: Any) -> Optional[bool]:
"""
设置系统设置
:param key: 配置键
:param value: 配置值
:return: 是否设置成功(True 成功/False 失败/None 无需更新)
"""
if isinstance(key, SystemConfigKey):
key = key.value
with self._rlock:
# 旧值
old_value = self.__SYSTEMCONF.get(key)
# 更新内存(deepcopy避免内存共享)
self.__SYSTEMCONF[key] = copy.deepcopy(value)
conf = SystemConfig.get_by_key(self._db, key)
if conf:
if old_value != value:
# 假值(False/0/None/空容器)同样落库而不是删除记录:
# 读取端以「无记录」表示未配置并回落默认值,删除会使布尔开关的关闭态无法持久化
conf.update(self._db, {"value": value})
return True
return None
else:
conf = SystemConfig(key=key, value=value)
conf.create(self._db)
return True
async def async_set(self, key: Union[str, SystemConfigKey], value: Any) -> Optional[bool]:
"""
异步设置系统设置
:param key: 配置键
:param value: 配置值
:return: 是否设置成功(True 成功/False 失败/None 无需更新)
"""
if isinstance(key, SystemConfigKey):
key = key.value
async with self._alock:
conf = await SystemConfig.async_get_by_key(self._db, key)
# 确定是否需要更新数据库
needs_db_update = False
if conf:
if conf.value != value:
needs_db_update = True
else: # 记录不存在,总是需要创建/更新
needs_db_update = True
if not needs_db_update:
# 即使数据库值相同,也要确保缓存同步
with self._rlock:
self.__SYSTEMCONF[key] = copy.deepcopy(value)
return None
# 执行数据库更新
if conf:
# 假值(False/0/None/空容器)同样落库而不是删除记录:
# 读取端以「无记录」表示未配置并回落默认值,删除会使布尔开关的关闭态无法持久化
await conf.async_update(self._db, {"value": value})
else:
conf = SystemConfig(key=key, value=value)
await conf.async_create(self._db)
# 数据库更新成功后,再更新缓存
with self._rlock:
self.__SYSTEMCONF[key] = copy.deepcopy(value)
return True
def get(self, key: Optional[Union[str, SystemConfigKey]] = None) -> Any:
"""
获取系统设置
"""
if isinstance(key, SystemConfigKey):
key = key.value
if not key:
return self.all()
with self._rlock:
# 避免将__SYSTEMCONF内的值引用出去,会导致set时误判没有变动
return copy.deepcopy(self.__SYSTEMCONF.get(key))
def increment(self, key: SystemConfigKey, step: int = 1) -> int:
"""
原子递增整数系统设置
:param key: 配置键
:param step: 递增步长
:return: 递增后的整数值
"""
with self._rlock:
value = int(self.get(key) or 0) + step
self.set(key, value)
return value
def all(self):
"""
获取所有系统设置
"""
with self._rlock:
# 避免将__SYSTEMCONF内的值引用出去,会导致set时误判没有变动
return copy.deepcopy(self.__SYSTEMCONF)
def delete(self, key: Union[str, SystemConfigKey]) -> bool:
"""
删除系统设置
"""
if isinstance(key, SystemConfigKey):
key = key.value
with self._rlock:
# 更新内存
self.__SYSTEMCONF.pop(key, None)
# 写入数据库
conf = SystemConfig.get_by_key(self._db, key)
if conf:
conf.delete(self._db, conf.id)
return True
+254
View File
@@ -0,0 +1,254 @@
import time
from typing import Any, List, Optional
from app.db import DbOper
from app.db.models.transferhistory import TransferHistory
from app.schemas.types import MediaSource
class TransferHistoryOper(DbOper):
"""
转移历史管理
"""
def get(self, historyid: int) -> Optional[TransferHistory]:
"""
获取转移历史
:param historyid: 转移历史id
"""
return TransferHistory.get(self._db, historyid)
async def async_get(self, historyid: int) -> Optional[TransferHistory]:
"""
异步获取转移历史。
"""
return await TransferHistory.async_get(self._db, historyid)
async def async_list_by_title(
self,
title: str,
page: int = 1,
count: int = 30,
status: Optional[bool] = None,
) -> List[TransferHistory]:
"""
异步按标题分页查询转移记录。
"""
return await TransferHistory.async_list_by_title(
self._db, title=title, page=page, count=count, status=status
)
async def async_list_by_page(
self,
page: int = 1,
count: int = 30,
status: Optional[bool] = None,
) -> List[TransferHistory]:
"""
异步分页查询转移记录。
"""
return await TransferHistory.async_list_by_page(
self._db, page=page, count=count, status=status
)
async def async_count(self, status: Optional[bool] = None) -> Optional[int]:
"""
异步统计转移记录数量。
"""
return await TransferHistory.async_count(self._db, status=status)
async def async_count_by_title(
self,
title: str,
status: Optional[bool] = None,
) -> Optional[int]:
"""
异步按标题统计转移记录数量。
"""
return await TransferHistory.async_count_by_title(
self._db, title=title, status=status
)
def get_by_title(self, title: str) -> List[TransferHistory]:
"""
按标题查询转移记录
:param title: 数据key
"""
return TransferHistory.list_by_title(self._db, title)
def get_by_src(
self, src: str, storage: Optional[str] = None
) -> Optional[TransferHistory]:
"""
按源查询转移记录
:param src: 数据key
:param storage: 存储类型
:return: 命中的整理记录,未命中时返回 None
"""
return TransferHistory.get_by_src(self._db, src, storage)
def get_success_by_src(
self, src: str, storage: Optional[str] = None
) -> Optional[TransferHistory]:
"""
按源查询成功的转移记录,源路径原样精确匹配
:param src: 数据key
:param storage: 存储类型
:return: 命中的成功整理记录,未命中时返回 None
"""
return TransferHistory.get_success_by_src(self._db, src, storage)
def get_by_dest(
self, dest: str, storage: Optional[str] = None
) -> Optional[TransferHistory]:
"""
按转移路径查询转移记录
:param dest: 数据key
:param storage: 存储类型
"""
return TransferHistory.get_by_dest(self._db, dest, storage)
def list_success_by_src(
self,
src: str,
storage: Optional[str] = None,
recursive: bool = False,
) -> List[TransferHistory]:
"""
按源路径查询成功整理记录。
:param src: 源路径
:param storage: 源存储类型
:param recursive: 是否递归匹配目录子项
:return: 命中的成功整理记录
"""
return TransferHistory.list_success_by_src(
self._db,
src=src,
storage=storage,
recursive=recursive,
)
def list_success_move_by_dest(
self,
dest: str,
storage: Optional[str] = None,
recursive: bool = False,
) -> List[TransferHistory]:
"""
按目标路径查询成功移动记录。
:param dest: 目标路径
:param storage: 目标存储类型
:param recursive: 是否递归匹配目录子项
:return: 命中的成功移动记录
"""
return TransferHistory.list_success_move_by_dest(
self._db,
dest=dest,
storage=storage,
recursive=recursive,
)
def list_by_hash(self, download_hash: str) -> List[TransferHistory]:
"""
按种子hash查询转移记录
:param download_hash: 种子hash
"""
return TransferHistory.list_by_hash(self._db, download_hash)
def add(self, **kwargs):
"""
新增转移历史
"""
kwargs.update({
"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
})
TransferHistory(**kwargs).create(self._db)
def statistic(self, days: int = 7) -> List[Any]:
"""
统计最近days天的下载历史数量
"""
return TransferHistory.statistic(self._db, days)
def get_by(self, title: Optional[str] = None, year: Optional[str] = None, mtype: Optional[str] = None,
season: Optional[str] = None, episode: Optional[str] = None,
media_source: Optional[MediaSource] = None, media_id: Optional[str] = None,
dest: Optional[str] = None) -> List[TransferHistory]:
"""
按类型、标题、年份、季集查询转移记录
"""
return TransferHistory.list_by(db=self._db,
mtype=mtype,
title=title,
dest=dest,
year=year,
season=season,
episode=episode,
media_source=media_source,
media_id=media_id)
def get_by_media_identity(
self, media_source: MediaSource, media_id: str,
mtype: Optional[str] = None,
) -> Optional[TransferHistory]:
"""按规范媒体身份和类型查询整理记录。"""
return TransferHistory.get_by_media_identity(
db=self._db,
media_source=media_source,
media_id=media_id,
mtype=mtype,
)
def delete(self, historyid):
"""
删除转移记录
"""
TransferHistory.delete(self._db, historyid)
async def async_delete(self, historyid):
"""
异步删除转移记录。
"""
await TransferHistory.async_delete(self._db, historyid)
def truncate(self):
"""
清空转移记录
"""
TransferHistory.truncate(self._db)
def add_force(self, **kwargs) -> Optional[TransferHistory]:
"""
新增转移历史,并以同源存储的记录为准替换旧记录。
"""
# 文件项的默认存储是 local;归一化旧调用传入的 None,确保运行时语义与
# (src, src_storage) 唯一索引一致。
kwargs["src_storage"] = kwargs.get("src_storage") or "local"
# 旧记录的清理交给 replace_by_src 按 (src, src_storage) 处理:
# 仅按 src 删除会连带删掉其他存储下同路径的记录。
kwargs.update({
"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
})
TransferHistory.replace_by_src(self._db, **kwargs)
# 保持 add_force 的既有返回契约:返回可被调用方安全读取字段的查询结果,
# 而非事务提交后可能已脱离会话的新建实例。
return TransferHistory.get_by_src(
self._db,
kwargs.get("src"),
kwargs["src_storage"],
)
def update_download_hash(self, historyid, download_hash):
"""
补充转移记录download_hash
"""
TransferHistory.update_download_hash(self._db, historyid, download_hash)
def list_by_date(self, date: str) -> List[TransferHistory]:
"""
查询某时间之后的转移历史
:param date: 日期
"""
return TransferHistory.list_by_date(self._db, date)
+59
View File
@@ -0,0 +1,59 @@
from datetime import datetime
from typing import List, Optional, Tuple
from app.db import DbOper
from app.db.models.transferpending import TransferPending
class TransferPendingOper(DbOper):
"""
待整理文件登记管理。
只保存「存储 + 源文件路径」这一最小事实,用于在进程重启后把没走完整理链的
文件重新送回去,避免挂载故障重启后永久漏件。
"""
def register(self, storage: str, src_path: str) -> Optional[TransferPending]:
"""
登记一个待整理文件。
:param storage: 存储
:param src_path: 源文件路径
:return: 登记记录
"""
return TransferPending.register(
self._db,
storage=storage,
src_path=src_path,
now_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)
def discard(self, storage: str, src_path: str) -> int:
"""
注销一个待整理文件登记。
:param storage: 存储
:param src_path: 源文件路径
:return: 删除的记录数
"""
return TransferPending.discard(self._db, storage=storage, src_path=src_path)
def list_all(self, limit: Optional[int] = 5000) -> List[Tuple[str, str]]:
"""
列出全部待整理登记,供启动回放使用。
返回纯元组而不是 ORM 实例:回放发生在会话之外,ORM 实例脱离 session
后访问属性会触发 DetachedInstanceError。
:param limit: 单次回放上限
:return: (存储, 源文件路径) 列表
"""
return [
(item.storage, item.src_path)
for item in TransferPending.list_all(self._db, limit=limit) or []
if item and item.storage and item.src_path
]
def clear(self) -> int:
"""
清空全部待整理登记。
:return: 删除的记录数
"""
return TransferPending.clear(self._db)
+93
View File
@@ -0,0 +1,93 @@
"""
用户数据访问。
认证依赖(get_current_user 等八个)已迁至 app/api/deps.py——那是 HTTP 层的关注点,
产出 403/400 而非数据。本模块只保留 UserOper。
这里不为那八个名字留惰性转发。转发曾是给仓外插件备的软着陆,代价是把
app.db.oper.user -> app.api.deps -> app.application.security 这条边永久焊进依赖图:
数据访问模块从此在静态分析里牵着整个鉴权栈,而仓内没有任何调用方需要它。插件生态
既已确定迭代,就让旧名字直接以 AttributeError 报错——指向明确、当场可改,好过一条
悄悄成立的反向依赖。
"""
from typing import List, Optional
from app.db import DbOper
from app.db.models.user import User
class UserOper(DbOper):
"""
用户管理
"""
def list(self) -> List[User]:
"""
获取用户列表
"""
return User.list(self._db)
def add(self, **kwargs):
"""
新增用户
"""
user = User(**kwargs)
user.create(self._db)
def get_by_name(self, name: str) -> Optional[User]:
"""
根据用户名获取用户
"""
return User.get_by_name(self._db, name)
async def async_get_by_name(self, name: str) -> Optional[User]:
"""
异步根据用户名获取用户。
"""
return await User.async_get_by_name(self._db, name)
async def async_get_by_id(self, user_id: int) -> Optional[User]:
"""
异步根据用户 ID 获取用户。
"""
return await User.async_get_by_id(self._db, user_id)
def get_permissions(self, name: str) -> dict:
"""
获取用户权限
"""
user = User.get_by_name(self._db, name)
if user:
return user.permissions or {}
return {}
def get_settings(self, name: str) -> Optional[dict]:
"""
获取用户个性化设置,返回None表示用户不存在
"""
user = User.get_by_name(self._db, name)
if user:
return user.settings or {}
return None
def get_setting(self, name: str, key: str) -> Optional[str]:
"""
获取用户个性化设置
"""
settings = self.get_settings(name)
if settings:
return settings.get(key)
return None
def get_name(self, **kwargs) -> Optional[str]:
"""
根据绑定账号获取用户名称
"""
users = self.list()
for user in users:
user_setting = user.settings
if user_setting:
for k, v in kwargs.items():
if user_setting.get(k) == str(v):
return user.name
return None
+86
View File
@@ -0,0 +1,86 @@
from typing import Any, Union, Dict, Optional
from app.db import DbOper
from app.db.models.userconfig import UserConfig
from app.schemas.types import UserConfigKey
from app.foundation.singleton import Singleton
class UserConfigOper(DbOper, metaclass=Singleton):
"""
用户配置管理
"""
def __init__(self):
"""
加载配置到内存
"""
super().__init__()
self.__USERCONF = {}
for item in UserConfig.list(self._db):
self.__set_config_cache(username=item.username, key=item.key, value=item.value)
def set(self, username: str, key: Union[str, UserConfigKey], value: Any):
"""
设置用户配置
"""
if isinstance(key, UserConfigKey):
key = key.value
# 更新内存
self.__set_config_cache(username=username, key=key, value=value)
# 写入数据库
conf = UserConfig.get_by_key(db=self._db, username=username, key=key)
if conf:
if value:
conf.update(self._db, {"value": value})
else:
conf.delete(self._db, conf.id)
else:
conf = UserConfig(username=username, key=key, value=value)
conf.create(self._db)
def get(self, username: str, key: Optional[Union[str, UserConfigKey]] = None) -> Any:
"""
获取用户配置
"""
if not username:
return self.__USERCONF
if isinstance(key, UserConfigKey):
key = key.value
if not key:
return self.__get_config_caches(username=username)
return self.__get_config_cache(username=username, key=key)
def __set_config_cache(self, username: str, key: str, value: Any):
"""
设置配置缓存
"""
if not username or not key:
return
cache = self.__USERCONF
if not cache:
cache = {}
user_cache = cache.get(username)
if not user_cache:
user_cache = {}
cache[username] = user_cache
user_cache[key] = value
self.__USERCONF = cache
def __get_config_caches(self, username: str) -> Optional[Dict[str, Any]]:
"""
获取配置缓存
"""
if not username or not self.__USERCONF:
return None
return self.__USERCONF.get(username)
def __get_config_cache(self, username: str, key: str) -> Any:
"""
获取配置缓存
"""
if not username or not key or not self.__USERCONF:
return None
user_cache = self.__get_config_caches(username)
if not user_cache:
return None
return user_cache.get(key)
+110
View File
@@ -0,0 +1,110 @@
from typing import List, Tuple, Optional, Any, Coroutine, Sequence
from app.db import DbOper
from app.db.models.workflow import Workflow
class WorkflowOper(DbOper):
"""
工作流管理
"""
def add(self, **kwargs) -> Tuple[bool, str]:
"""
新增工作流
"""
wf = Workflow(**kwargs)
if not wf.get_by_name(self._db, kwargs.get("name")):
wf.create(self._db)
return True, "新增工作流成功"
return False, "工作流已存在"
def get(self, wid: int) -> Optional[Workflow]:
"""
查询单个工作流
"""
return Workflow.get(self._db, wid)
async def async_get(self, wid: int) -> Optional[Workflow]:
"""
异步查询单个工作流
"""
return await Workflow.async_get(self._db, wid)
def list(self) -> List[Workflow]:
"""
获取所有工作流列表
"""
return Workflow.list(self._db)
async def async_list(self) -> List[Workflow]:
"""
异步获取所有工作流列表
"""
return await Workflow.async_list(self._db)
def list_enabled(self) -> List[Workflow]:
"""
获取启用的工作流列表
"""
return Workflow.get_enabled_workflows(self._db)
def get_timer_triggered_workflows(self) -> List[Workflow]:
"""
获取定时触发的工作流列表
"""
return Workflow.get_timer_triggered_workflows(self._db)
def get_event_triggered_workflows(self) -> List[Workflow]:
"""
获取事件触发的工作流列表
"""
return Workflow.get_event_triggered_workflows(self._db)
def get_by_name(self, name: str) -> Workflow:
"""
按名称获取工作流
"""
return Workflow.get_by_name(self._db, name)
async def async_get_by_name(self, name: str) -> Optional[Workflow]:
"""
异步按名称获取工作流
"""
return await Workflow.async_get_by_name(self._db, name)
def start(self, wid: int) -> bool:
"""
启动
"""
return Workflow.start(self._db, wid)
def success(self, wid: int, result: Optional[str] = None) -> bool:
"""
成功
"""
return Workflow.success(self._db, wid, result)
def fail(self, wid: int, result: str) -> bool:
"""
失败
"""
return Workflow.fail(self._db, wid, result)
def step(self, wid: int, action_id: str, context: dict, execution_state: Optional[dict] = None) -> bool:
"""
步进
"""
return Workflow.update_current_action(
self._db,
wid,
action_id,
context,
execution_state
)
def reset(self, wid: int, reset_count: bool = False) -> bool:
"""
重置
"""
return Workflow.reset(self._db, wid, reset_count=reset_count)