refactor: 推进后端分层架构治理

This commit is contained in:
jxxghp
2026-08-18 00:29:14 +08:00
parent 5d0bacabd5
commit 5128ae9e1e
363 changed files with 34179 additions and 6019 deletions
+1
View File
@@ -0,0 +1 @@
"""Chain 运行时依赖组合。"""
+64
View File
@@ -0,0 +1,64 @@
"""Chain 兼容门面所需运行时依赖的显式上下文。"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, Optional
from app.application.messaging.message import MessageHelper, MessageQueueManager
from app.db.oper.message import MessageOper
from app.runtime.cache import AsyncFileCache, FileCache
from app.runtime.events import EventManager
from app.runtime.extensions.module_manager import ModuleManager
from app.runtime.extensions.plugin_manager import PluginManager
MessageQueueFactory = Callable[[Callable[..., Any]], Any]
ChainRuntimeContextProvider = Callable[[], "ChainRuntimeContext"]
@dataclass(frozen=True, slots=True)
class ChainRuntimeContext:
"""集中声明 Chain 调度、事件、消息和缓存所需的最小运行时对象。"""
module_manager: Any
plugin_manager: Any
event_manager: Any
message_oper: Any
message_helper: Any
file_cache: Any
async_file_cache: Any
message_queue_factory: MessageQueueFactory
def build_default_chain_runtime_context() -> ChainRuntimeContext:
"""按旧构造规则创建上下文,同时复用各管理器既有单例身份。"""
return ChainRuntimeContext(
module_manager=ModuleManager(),
plugin_manager=PluginManager(),
event_manager=EventManager(),
message_oper=MessageOper(),
message_helper=MessageHelper(),
file_cache=FileCache(),
async_file_cache=AsyncFileCache(),
message_queue_factory=lambda callback: MessageQueueManager(
send_callback=callback
),
)
_context_provider: ChainRuntimeContextProvider = build_default_chain_runtime_context
def configure_chain_runtime_context_provider(
provider: Optional[ChainRuntimeContextProvider],
) -> None:
"""由组合根替换 Chain 上下文来源;传入空值恢复兼容默认值。"""
global _context_provider
_context_provider = provider or build_default_chain_runtime_context
def get_chain_runtime_context() -> ChainRuntimeContext:
"""返回当前组合根提供的 Chain 运行上下文。"""
return _context_provider()
+13 -12
View File
@@ -2,7 +2,8 @@ import re
from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
from typing import List, Optional, Tuple
from app import schemas
from app.schemas.file import FileURI as _SchemaFileURI
from app.schemas.system import TransferDirectoryConf as _SchemaTransferDirectoryConf
from app.domain.context import MediaInfo
from app.db.oper.systemconfig import SystemConfigOper
from app.runtime.log import logger
@@ -20,22 +21,22 @@ class DirectoryHelper:
"""
@staticmethod
def get_dirs() -> List[schemas.TransferDirectoryConf]:
def get_dirs() -> List[_SchemaTransferDirectoryConf]:
"""
获取所有下载目录
"""
dir_confs: List[dict] = SystemConfigOper().get(SystemConfigKey.Directories)
if not dir_confs:
return []
return [schemas.TransferDirectoryConf(**d) for d in dir_confs]
return [_SchemaTransferDirectoryConf(**d) for d in dir_confs]
def get_download_dirs(self) -> List[schemas.TransferDirectoryConf]:
def get_download_dirs(self) -> List[_SchemaTransferDirectoryConf]:
"""
获取所有下载目录
"""
return sorted([d for d in self.get_dirs() if d.download_path], key=lambda x: x.priority)
def get_local_download_dirs(self) -> List[schemas.TransferDirectoryConf]:
def get_local_download_dirs(self) -> List[_SchemaTransferDirectoryConf]:
"""
获取所有本地的可下载目录
"""
@@ -45,7 +46,7 @@ class DirectoryHelper:
self,
media: Optional[MediaInfo],
save_path: str,
) -> Optional[schemas.TransferDirectoryConf]:
) -> Optional[_SchemaTransferDirectoryConf]:
"""
按媒体信息和精确保存根路径匹配下载目录配置。
@@ -78,13 +79,13 @@ class DirectoryHelper:
return dir_info
return None
def get_library_dirs(self) -> List[schemas.TransferDirectoryConf]:
def get_library_dirs(self) -> List[_SchemaTransferDirectoryConf]:
"""
获取所有媒体库目录
"""
return sorted([d for d in self.get_dirs() if d.library_path], key=lambda x: x.priority)
def get_local_library_dirs(self) -> List[schemas.TransferDirectoryConf]:
def get_local_library_dirs(self) -> List[_SchemaTransferDirectoryConf]:
"""
获取所有本地的媒体库目录
"""
@@ -93,7 +94,7 @@ class DirectoryHelper:
def get_dir(self, media: Optional[MediaInfo], include_unsorted: Optional[bool] = False,
storage: Optional[str] = None, src_path: Path = None,
target_storage: Optional[str] = None, dest_path: Path = None
) -> Optional[schemas.TransferDirectoryConf]:
) -> Optional[_SchemaTransferDirectoryConf]:
"""
根据媒体信息获取下载目录、媒体库目录配置
:param media: 媒体信息
@@ -113,7 +114,7 @@ class DirectoryHelper:
dirs_to_consider = matching_dirs if matching_dirs else dirs
# 已匹配的目录
matched_dirs: List[schemas.TransferDirectoryConf] = []
matched_dirs: List[_SchemaTransferDirectoryConf] = []
# 按照配置顺序查找
for d in dirs_to_consider:
# 没有启用整理的目录
@@ -297,10 +298,10 @@ def _download_path_uri(storage: str, path: PurePath) -> str:
path_value = path.as_posix()
if storage == "local":
return path_value
return schemas.FileURI(storage=storage, path=path_value).uri
return _SchemaFileURI(storage=storage, path=path_value).uri
def _normalize_download_root(dir_info: schemas.TransferDirectoryConf) -> Optional[Tuple[str, str, PurePath]]:
def _normalize_download_root(dir_info: _SchemaTransferDirectoryConf) -> Optional[Tuple[str, str, PurePath]]:
"""
读取下载目录配置中的根路径;无效配置不参与用户 save_path allowlist。
"""
+1
View File
@@ -0,0 +1 @@
"""下载应用服务。"""
+77
View File
@@ -0,0 +1,77 @@
"""下载任务查询与控制应用服务。"""
from typing import Callable, List, Optional
from app.schemas.transfer import DownloaderTorrent
from app.schemas.types import TorrentStatus
class DownloadTaskService:
"""通过下载器和历史端口查询、启停及删除下载任务。"""
def __init__(
self,
list_torrents: Callable[..., List[DownloaderTorrent]],
get_history_by_hashes: Callable[[list[str]], dict],
start_torrents: Callable[..., bool],
stop_torrents: Callable[..., bool],
remove_torrents: Callable[..., bool],
) -> None:
"""注入下载器操作和历史读取端口。"""
self._list_torrents = list_torrents
self._get_history_by_hashes = get_history_by_hashes
self._start_torrents = start_torrents
self._stop_torrents = stop_torrents
self._remove_torrents = remove_torrents
def downloading(self, name: Optional[str] = None) -> List[DownloaderTorrent]:
"""查询下载中任务,并附加对应下载历史的媒体与用户信息。"""
torrents = self._list_torrents(
downloader=name,
status=TorrentStatus.DOWNLOADING,
)
if not torrents:
return []
history_map = self._get_history_by_hashes(
[torrent.hash for torrent in torrents if torrent.hash]
)
for torrent in torrents:
history = history_map.get(torrent.hash)
if not history:
continue
torrent.media = {
"media_source": history.media_source,
"media_id": history.media_id,
"type": history.type,
"title": history.title,
"season": history.seasons,
"episode": history.episodes,
"image": history.poster,
"poster": history.poster,
"backdrop": history.image,
}
torrent.site_name = history.torrent_site
torrent.userid = history.userid
torrent.username = history.username
return torrents
def set_downloading(
self,
hash_str: str,
operation: str,
name: Optional[str] = None,
) -> bool:
"""按 start/stop 操作控制单个下载任务。"""
if operation == "start":
return self._start_torrents(hashs=[hash_str], downloader=name)
if operation == "stop":
return self._stop_torrents(hashs=[hash_str], downloader=name)
return False
def remove_downloading(
self,
hash_str: str,
name: Optional[str] = None,
) -> bool:
"""删除单个下载任务。"""
return self._remove_torrents(hashs=[hash_str], downloader=name)
+2 -1
View File
@@ -1,7 +1,8 @@
from typing import Optional
from app.runtime.extensions.service_registry import ServiceBaseHelper
from app.schemas import DownloaderConf, ServiceInfo
from app.schemas.system import DownloaderConf
from app.schemas.system import ServiceInfo
from app.schemas.types import SystemConfigKey, ModuleType
+2 -1
View File
@@ -11,7 +11,8 @@ from app.runtime.config import settings
from app.domain.metainfo import MetaInfoPath
from app.domain.meta.metabase import MetaBase
from app.runtime.log import logger
from app.schemas import EpisodeFormatRule, FileItem
from app.schemas.transfer import EpisodeFormatRule
from app.schemas.workflow import FileItem
@dataclass(frozen=True)
+159 -2
View File
@@ -1,4 +1,6 @@
from typing import Any, Dict, Optional, Union
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Dict, Optional, Protocol, Union
from app.domain.context import MediaInfo, MusicInfo
from app.schemas.media import resolve_media_identity
@@ -9,7 +11,8 @@ from app.runtime.config import settings
from app.db.models.transferhistory import TransferHistory
from app.db.oper.transferhistory import TransferHistoryOper
from app.runtime.log import logger
from app.schemas import FileItem, TransferInfo
from app.schemas.workflow import FileItem
from app.schemas.transfer import TransferInfo
from app.schemas.types import MUSIC_ENTITY_RECORDING
# 失败重试次数的合法区间。下界为 1:一次瞬时故障(网络抖动、TMDB 瞬断、移动失败)
@@ -25,6 +28,160 @@ FAILED_RETRY_TTL = 24 * 3600
_failed_retry_counts = TTLCache(region="transfer_failed_retry", maxsize=5000, ttl=FAILED_RETRY_TTL)
@dataclass(frozen=True, slots=True)
class HistoryMutationResult:
"""描述历史记录维护操作是否成功及兼容提示。"""
success: bool
message: str = ""
class DownloadHistoryMutationRepository(Protocol):
"""下载历史删除用例需要的最小持久化端口。"""
def stage_delete_history(self, history_id: int) -> None:
"""暂存下载历史删除。"""
...
class TransferHistoryMutationRepository(Protocol):
"""整理历史删除与清理用例需要的最小持久化端口。"""
def get(self, history_id: int) -> Optional[Any]:
"""读取整理历史。"""
...
def stage_delete(self, history_id: int) -> None:
"""暂存整理历史删除。"""
...
def stage_truncate(self) -> None:
"""暂存全部整理历史删除。"""
...
class DownloadFileMutationRepository(Protocol):
"""整理历史删除时关联下载文件状态更新端口。"""
def stage_delete_file_by_fullpath(self, fullpath: str) -> None:
"""暂存下载文件删除状态。"""
...
class HistoryUnitOfWork(Protocol):
"""同步历史维护用例使用的事务端口。"""
def commit(self) -> None:
"""提交当前事务。"""
...
def rollback(self) -> None:
"""回滚当前事务。"""
...
class DownloadHistoryMutationCommand:
"""统一提交下载历史删除,避免 API 直接持有数据库事务。"""
def __init__(
self,
*,
repository: DownloadHistoryMutationRepository,
unit_of_work: HistoryUnitOfWork,
) -> None:
"""保存下载历史持久化和事务端口。"""
self._repository = repository
self._unit_of_work = unit_of_work
def delete(self, history_id: int) -> HistoryMutationResult:
"""暂存并提交单条下载历史删除。"""
self._repository.stage_delete_history(history_id)
self._commit()
return HistoryMutationResult(True)
def _commit(self) -> None:
"""提交事务,失败时回滚。"""
try:
self._unit_of_work.commit()
except Exception:
self._unit_of_work.rollback()
raise
class TransferHistoryMutationCommand:
"""协调整理历史、关联文件状态和外部存储删除。"""
def __init__(
self,
*,
repository: TransferHistoryMutationRepository,
download_repository: DownloadFileMutationRepository,
unit_of_work: HistoryUnitOfWork,
file_item_factory: Callable[[dict], Any],
delete_media_file: Callable[[Any], bool],
publish_download_file_deleted: Callable[[dict], None],
clear_failures: Callable[[Optional[str], Optional[str]], None],
) -> None:
"""保存历史事务、存储删除、事件和失败状态清理端口。"""
self._repository = repository
self._download_repository = download_repository
self._unit_of_work = unit_of_work
self._file_item_factory = file_item_factory
self._delete_media_file = delete_media_file
self._publish_download_file_deleted = publish_download_file_deleted
self._clear_failures = clear_failures
def delete(
self,
history_id: int,
*,
delete_source: bool = False,
delete_destination: bool = False,
) -> HistoryMutationResult:
"""删除整理记录,并保持源文件失败时不提交数据库变更。"""
history = self._repository.get(history_id)
if not history:
return HistoryMutationResult(False, "记录不存在")
if delete_destination and history.dest_fileitem:
destination = self._file_item_factory(history.dest_fileitem)
self._delete_media_file(destination)
source_deleted = False
if delete_source and history.src_fileitem:
source = self._file_item_factory(history.src_fileitem)
if not self._delete_media_file(source):
return HistoryMutationResult(False, f"{source.path} 删除失败")
self._download_repository.stage_delete_file_by_fullpath(
Path(source.path).as_posix()
)
source_deleted = True
self._repository.stage_delete(history_id)
self._commit()
if source_deleted:
self._publish_download_file_deleted({
"src": history.src,
"hash": history.download_hash,
})
self._clear_failures(history.src, history.src_storage)
return HistoryMutationResult(True)
def truncate(self) -> HistoryMutationResult:
"""在单一事务中清空全部整理历史。"""
self._repository.stage_truncate()
self._commit()
return HistoryMutationResult(True)
def _commit(self) -> None:
"""提交历史事务,失败时回滚且不发布事件或清缓存。"""
try:
self._unit_of_work.commit()
except Exception:
self._unit_of_work.rollback()
raise
class HistoryGateAction:
"""
整理历史查重闸的判定结果。
+343
View File
@@ -0,0 +1,343 @@
"""应用级数据维护用例。
本模块拥有保留期、批次循环、进度和部分失败汇总语义。具体数据库表如何删除由
``CleanupRepository`` 端口提供,调度器只负责触发用例。
"""
import json
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, Callable, ContextManager, Dict, Optional, Protocol
from app.db.maintenance import DatabaseCleanupRepository
from app.db.session import SessionFactory
from app.runtime.config import settings
from app.runtime.log import logger
CleanupProgress = Callable[..., None]
@dataclass(frozen=True, slots=True)
class CleanupPolicy:
"""描述一次数据维护运行使用的总开关和各表保留期。"""
enabled: bool
message_days: int
download_history_days: int
site_userdata_days: int
transfer_history_days: int
download_failure_days: int
@dataclass(frozen=True, slots=True)
class CleanupPlan:
"""描述单张表的保留期、截止点和批量删除动作。"""
name: str
retention_days: int
cutoff: str
delete_batch: Callable[[Any], int]
class CleanupRepository(Protocol):
"""数据维护用例需要的最小持久化端口。"""
def session(self) -> ContextManager[Any]:
"""返回一次维护运行共用的数据库会话上下文。"""
...
def delete_messages(self, db: Any, cutoff: str, limit: int) -> int:
"""删除早于截止时间的消息。"""
...
def delete_download_history(self, db: Any, cutoff: str, limit: int) -> int:
"""删除早于截止时间的下载历史。"""
...
def delete_download_orphans(self, db: Any, limit: int) -> int:
"""删除已经失去父下载历史的文件记录。"""
...
def delete_site_userdata(self, db: Any, cutoff: str, limit: int) -> int:
"""删除早于截止日期的站点用户数据快照。"""
...
def delete_transfer_history(self, db: Any, cutoff: str, limit: int) -> int:
"""删除早于截止时间的整理历史。"""
...
def delete_download_failures(self, db: Any, cutoff: str, limit: int) -> int:
"""删除已经过期的下载失败冷却记录。"""
...
class DataCleanupService:
"""按配置执行分批数据清理并生成兼容报告。"""
DEFAULT_BATCH_SIZE = 500
def __init__(
self,
*,
repository: CleanupRepository,
policy_reader: Callable[[], CleanupPolicy],
clock: Callable[[], datetime] = datetime.now,
) -> None:
"""保存持久化端口、动态配置读取器和可测试时钟。"""
self._repository = repository
self._policy_reader = policy_reader
self._clock = clock
def execute(
self,
batch_size: Optional[int] = None,
progress_callback: Optional[CleanupProgress] = None,
) -> Dict[str, Any]:
"""执行全部清理计划,保持旧调度入口的报告和异常语义。"""
started_at = self._clock()
normalized_batch_size = batch_size or self.DEFAULT_BATCH_SIZE
if normalized_batch_size <= 0:
normalized_batch_size = self.DEFAULT_BATCH_SIZE
policy = self._policy_reader()
report: Dict[str, Any] = {
"started_at": started_at.strftime("%Y-%m-%d %H:%M:%S"),
"batch_size": normalized_batch_size,
"enabled": policy.enabled,
"tables": {},
"total_deleted": 0,
}
if not policy.enabled:
report["skipped_reason"] = "disabled"
logger.info("数据表清理总开关未开启,跳过执行")
return report
plans = self._build_plans(
policy=policy,
started_at=started_at,
batch_size=normalized_batch_size,
)
if progress_callback:
progress_callback(value=0, text="开始清理数据表 ...")
errors: list[str] = []
with self._repository.session() as db:
for plan_index, plan in enumerate(plans):
self._execute_plan(
db=db,
plan=plan,
plan_index=plan_index,
total_plans=len(plans),
report=report,
errors=errors,
progress_callback=progress_callback,
)
if errors:
report["errors"] = errors
logger.error(
f"数据表清理部分失败:{json.dumps(report, ensure_ascii=False)}"
)
raise RuntimeError("".join(errors))
logger.info(f"数据表清理完成:{json.dumps(report, ensure_ascii=False)}")
return report
def _execute_plan(
self,
*,
db: Any,
plan: CleanupPlan,
plan_index: int,
total_plans: int,
report: Dict[str, Any],
errors: list[str],
progress_callback: Optional[CleanupProgress],
) -> None:
"""执行单表计划并把成功、跳过或失败状态写入总报告。"""
if plan.retention_days <= 0:
report["tables"][plan.name] = {
"deleted": 0,
"batches": 0,
"cutoff": None,
"retention_days": plan.retention_days,
"skipped": True,
"reason": "retention_days<=0",
}
if progress_callback:
progress_callback(
value=(plan_index + 1) / total_plans * 100,
text=f"数据表 {plan.name} 跳过清理",
)
return
try:
if progress_callback:
progress_callback(
value=plan_index / total_plans * 100,
text=f"正在清理数据表 {plan.name} ...",
)
table_report = self._cleanup_in_batches(
db=db,
table_name=plan.name,
delete_batch=plan.delete_batch,
)
table_report["cutoff"] = plan.cutoff
table_report["retention_days"] = plan.retention_days
report["tables"][plan.name] = table_report
report["total_deleted"] += table_report["deleted"]
except Exception as err:
errors.append(f"{plan.name}: {str(err)}")
logger.error(f"数据表 {plan.name} 清理失败:{str(err)}")
report["tables"][plan.name] = {
"deleted": 0,
"batches": 0,
"cutoff": plan.cutoff,
"retention_days": plan.retention_days,
"error": str(err),
}
finally:
if progress_callback:
progress_callback(
value=(plan_index + 1) / total_plans * 100,
text=f"数据表 {plan.name} 清理处理完成",
)
def _build_plans(
self,
*,
policy: CleanupPolicy,
started_at: datetime,
batch_size: int,
) -> list[CleanupPlan]:
"""把一次动态配置快照转换为固定顺序的清理计划。"""
message_cutoff = self._cutoff(started_at, policy.message_days, "%Y-%m-%d")
download_history_cutoff = self._cutoff(
started_at,
policy.download_history_days,
"%Y-%m-%d",
)
site_userdata_cutoff = self._cutoff(
started_at,
policy.site_userdata_days,
"%Y-%m-%d",
)
transfer_history_cutoff = self._cutoff(
started_at,
policy.transfer_history_days,
"%Y-%m-%d",
)
download_failure_cutoff = self._cutoff(
started_at,
policy.download_failure_days,
"%Y-%m-%d %H:%M:%S",
)
return [
CleanupPlan(
"message",
policy.message_days,
message_cutoff,
lambda db: self._repository.delete_messages(
db, message_cutoff, batch_size
),
),
CleanupPlan(
"downloadhistory",
policy.download_history_days,
download_history_cutoff,
lambda db: self._repository.delete_download_history(
db, download_history_cutoff, batch_size
),
),
CleanupPlan(
"downloadfiles",
policy.download_history_days,
"follow-parent-history",
lambda db: self._repository.delete_download_orphans(db, batch_size),
),
CleanupPlan(
"siteuserdata",
policy.site_userdata_days,
site_userdata_cutoff,
lambda db: self._repository.delete_site_userdata(
db, site_userdata_cutoff, batch_size
),
),
CleanupPlan(
"transferhistory",
policy.transfer_history_days,
transfer_history_cutoff,
lambda db: self._repository.delete_transfer_history(
db, transfer_history_cutoff, batch_size
),
),
CleanupPlan(
"downloadfailure",
policy.download_failure_days,
download_failure_cutoff,
lambda db: self._repository.delete_download_failures(
db, download_failure_cutoff, batch_size
),
),
]
@staticmethod
def _cleanup_in_batches(
*,
db: Any,
table_name: str,
delete_batch: Callable[[Any], int],
) -> Dict[str, int]:
"""循环执行单表分批删除,直到持久化端口返回零。"""
total_deleted = 0
batches = 0
while True:
deleted = delete_batch(db) or 0
if deleted <= 0:
break
batches += 1
total_deleted += deleted
logger.info(
f"数据表 {table_name} 清理第 {batches} 批完成,删除 {deleted} 条记录"
)
return {"deleted": total_deleted, "batches": batches}
@staticmethod
def _cutoff(started_at: datetime, retention_days: int, pattern: str) -> str:
"""按兼容格式计算一个清理截止时间。"""
return (started_at - timedelta(days=retention_days)).strftime(pattern)
def read_cleanup_policy() -> CleanupPolicy:
"""读取并规范化当前数据清理配置,单次运行期间保持快照一致。"""
return CleanupPolicy(
enabled=bool(settings.DATA_CLEANUP_ENABLE),
message_days=_normalize_days(settings.DATA_CLEANUP_MESSAGE_DAYS),
download_history_days=_normalize_days(
settings.DATA_CLEANUP_DOWNLOAD_HISTORY_DAYS
),
site_userdata_days=_normalize_days(settings.DATA_CLEANUP_SITE_USERDATA_DAYS),
transfer_history_days=_normalize_days(
settings.DATA_CLEANUP_TRANSFER_HISTORY_DAYS
),
download_failure_days=_normalize_days(
settings.DATA_CLEANUP_DOWNLOAD_FAILURE_DAYS
),
)
def build_cleanup_service() -> DataCleanupService:
"""在应用边界组装默认数据库适配器,供兼容调度门面触发。"""
return DataCleanupService(
repository=DatabaseCleanupRepository(session_factory=SessionFactory),
policy_reader=read_cleanup_policy,
)
def _normalize_days(retention_days: Any) -> int:
"""把配置保留期规范为非负整数,非法值按关闭单表清理处理。"""
try:
normalized_days = int(retention_days or 0)
except (TypeError, ValueError):
return 0
return max(normalized_days, 0)
+7 -6
View File
@@ -2,11 +2,12 @@ import re
from collections.abc import Iterable, Mapping
from typing import Any, Optional
from app import schemas
from app.schemas.mediaserver import MediaServerItem as _SchemaMediaServerItem
from app.domain.context import MusicInfo
from app.schemas.media import normalize_media_source, resolve_media_identity
from app.runtime.extensions.service_registry import ServiceBaseHelper
from app.schemas import MediaServerConf, ServiceInfo
from app.schemas.system import MediaServerConf
from app.schemas.system import ServiceInfo
from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
MediaSource,
@@ -68,7 +69,7 @@ class MediaServerIdentityHelper:
@classmethod
def is_compatible(
cls,
item: schemas.MediaServerItem,
item: _SchemaMediaServerItem,
media_source: Optional[MediaSource | str],
media_id: Optional[str],
) -> bool:
@@ -196,7 +197,7 @@ class MusicMediaServerHelper:
def item_matches(
cls,
mediainfo: MusicInfo,
item: schemas.MediaServerItem,
item: _SchemaMediaServerItem,
) -> bool:
"""校验媒体库条目是否精确对应单曲,或完整覆盖目标专辑。"""
note = item.note if isinstance(item.note, Mapping) else {}
@@ -237,8 +238,8 @@ class MusicMediaServerHelper:
def find_match(
cls,
mediainfo: MusicInfo,
items: Optional[Iterable[schemas.MediaServerItem]],
) -> Optional[schemas.MediaServerItem]:
items: Optional[Iterable[_SchemaMediaServerItem]],
) -> Optional[_SchemaMediaServerItem]:
"""返回首个满足单曲精确匹配或整专完整性要求的媒体库条目。"""
return next(
(item for item in items or [] if item and cls.item_matches(mediainfo, item)),
+1 -1
View File
@@ -5,7 +5,7 @@ from datetime import datetime, timedelta
from threading import Lock
from typing import Any, Dict, List, Optional, Protocol, Sequence, Tuple, Union
from app.schemas import Message
from app.schemas.message import Message
from app.schemas.notification import ChannelCapabilityManager
from app.schemas.types import NotificationChannel
+1 -1
View File
@@ -6,7 +6,7 @@ from typing import Any, Dict, List, Optional, Tuple, Union
from app.application.messaging.interaction import InteractionContext, MessageGateway
from app.runtime.events import EventManager
from app.schemas import Message
from app.schemas.message import Message
from app.schemas.types import EventType, NotificationChannel
+91
View File
@@ -0,0 +1,91 @@
"""消息入口的用户会话状态用例。"""
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Callable, MutableMapping, Optional, Union
UserId = Union[str, int]
SessionEntry = tuple[str, datetime]
ExpiredSessionHandler = Callable[[str, UserId], None]
Clock = Callable[[], datetime]
SessionIdFactory = Callable[[UserId, datetime], str]
@dataclass(frozen=True, slots=True)
class SessionResolution:
"""描述用户会话解析结果及是否复用了旧会话。"""
session_id: str
reused: bool
inactive_minutes: float = 0.0
class MessageSessionService:
"""管理消息用户到 Agent 会话的绑定、复用和过期清理。"""
def __init__(
self,
*,
sessions: MutableMapping[UserId, SessionEntry],
timeout_minutes: int,
expired_handler: ExpiredSessionHandler,
clock: Clock = datetime.now,
session_id_factory: Optional[SessionIdFactory] = None,
) -> None:
"""保存共享会话映射和由 Chain 提供的 Agent 清理端口。"""
self._sessions = sessions
self._timeout = timedelta(minutes=timeout_minutes)
self._expired_handler = expired_handler
self._clock = clock
self._session_id_factory = session_id_factory or self._default_session_id
@staticmethod
def _default_session_id(user_id: UserId, now: datetime) -> str:
"""按历史格式生成新的用户会话 ID。"""
return f"user_{user_id}_{int(now.timestamp())}"
def cleanup(self, now: Optional[datetime] = None) -> None:
"""移除超时绑定,并通知拥有者释放对应 Agent 会话。"""
current_time = now or self._clock()
for user_id, (session_id, last_time) in list(self._sessions.items()):
if current_time - last_time <= self._timeout:
continue
self._sessions.pop(user_id, None)
self._expired_handler(session_id, user_id)
def resolve(self, user_id: UserId) -> SessionResolution:
"""复用有效绑定或为用户创建新会话。"""
current_time = self._clock()
self.cleanup(current_time)
current = self._sessions.get(user_id)
if current:
session_id, last_time = current
inactive = current_time - last_time
if inactive <= self._timeout:
self._sessions[user_id] = (session_id, current_time)
return SessionResolution(
session_id=session_id,
reused=True,
inactive_minutes=inactive.total_seconds() / 60,
)
session_id = self._session_id_factory(user_id, current_time)
self._sessions[user_id] = (session_id, current_time)
return SessionResolution(session_id=session_id, reused=False)
def bind(self, user_id: UserId, session_id: str) -> None:
"""绑定指定会话,并在替换时释放旧会话。"""
current = self._sessions.get(user_id)
if current and current[0] != session_id:
self._expired_handler(current[0], user_id)
self._sessions[user_id] = (session_id, self._clock())
def clear(self, user_id: UserId) -> Optional[str]:
"""清除用户绑定并返回被移除的会话 ID。"""
current = self._sessions.pop(user_id, None)
return current[0] if current else None
def get(self, user_id: UserId) -> Optional[SessionEntry]:
"""读取用户当前会话绑定,不改变最后活动时间。"""
return self._sessions.get(user_id)
+1 -1
View File
@@ -15,7 +15,7 @@ from app.application.messaging.interaction import (
update_or_post_message,
)
from app.runtime.log import logger
from app.schemas import Message
from app.schemas.message import Message
from app.schemas.types import NotificationChannel
+1 -1
View File
@@ -13,7 +13,7 @@ from app.application.messaging.interaction import (
supports_interaction_buttons,
update_or_post_message,
)
from app.schemas import Message
from app.schemas.message import Message
from app.schemas.types import NotificationChannel
+1 -1
View File
@@ -14,7 +14,7 @@ from app.application.messaging.interaction import (
)
from app.db.models.subscribe import Subscribe
from app.db.oper.subscribe import SubscribeOper
from app.schemas import Message
from app.schemas.message import Message
from app.schemas.types import NotificationChannel, MediaType
+1
View File
@@ -0,0 +1 @@
"""音乐应用服务。"""
+128
View File
@@ -0,0 +1,128 @@
"""多来源音乐目录搜索应用服务。"""
import asyncio
from typing import Any, Callable, Iterable, Optional
from app.domain.context import MusicInfo
from app.domain.meta.metamusic import MetaMusic
from app.schemas.media import normalize_media_source
from app.schemas.types import MediaSource, MediaSourceSelection
class MusicCatalogService:
"""编排音乐来源选择、容错搜索和候选归一化。"""
def __init__(
self,
source_resolver: Callable[[MediaSource], Any],
warning: Callable[[str], None],
primary_source: MediaSource = MediaSource.MusicBrainz,
) -> None:
"""注入来源解析器、告警输出和默认音乐来源。"""
self._source_resolver = source_resolver
self._warning = warning
self._primary_source = primary_source
def search_sources(
self,
media_source: Optional[MediaSourceSelection],
) -> list[MediaSource]:
"""解析有序音乐来源,保留合法插件扩展来源并去重。"""
if not media_source:
return [self._primary_source]
raw_sources = (
(media_source,)
if isinstance(media_source, MediaSource)
else media_source
)
sources = []
for raw_source in raw_sources:
source = normalize_media_source(raw_source)
if source and source not in sources:
sources.append(source)
return sources
@staticmethod
def normalize_candidates(
candidates: Optional[Iterable[MusicInfo | dict[str, Any]]],
limit: Optional[int] = None,
) -> list[MusicInfo]:
"""标准化并按来源身份或元数据去重音乐候选。"""
results = []
identities = set()
for candidate in candidates or []:
info = candidate if isinstance(candidate, MusicInfo) else MusicInfo.from_dict(candidate)
if info.media_source and info.media_id:
identity = (
"id",
str(info.media_source).casefold(),
str(info.music_type).casefold(),
str(info.media_id).casefold(),
)
else:
identity = (
"metadata",
str(info.music_type).casefold(),
MetaMusic.compact_text(info.title),
MetaMusic.compact_text(info.artist),
MetaMusic.compact_text(info.album),
)
if identity in identities:
continue
identities.add(identity)
results.append(info)
if limit and len(results) >= limit:
break
return results
def search(
self,
query: str,
limit: int = 20,
media_source: Optional[MediaSourceSelection] = None,
) -> list[MusicInfo]:
"""顺序搜索一个或多个音乐来源,隔离单一来源失败。"""
meta = MetaMusic.parse_query(query)
candidates = []
for source in self.search_sources(media_source):
chain = self._source_resolver(source)
if not chain:
continue
try:
candidates.extend(chain.search_music(meta, limit=limit))
except Exception as error:
self._warning(f"音乐来源 {source} 搜索失败:{str(error)}")
return self.normalize_candidates(candidates, limit=limit)
async def async_search(
self,
query: str,
limit: int = 20,
media_source: Optional[MediaSourceSelection] = None,
) -> list[MusicInfo]:
"""并行搜索一个或多个音乐来源,隔离单一来源失败。"""
meta = MetaMusic.parse_query(query)
searches = []
for source in self.search_sources(media_source):
chain = self._source_resolver(source)
if chain:
searches.append(self._async_search_source(chain, source, meta, limit))
source_results = await asyncio.gather(*searches) if searches else []
return self.normalize_candidates(
[candidate for results in source_results for candidate in results],
limit=limit,
)
async def _async_search_source(
self,
chain: Any,
source: MediaSource,
meta: MetaMusic,
limit: int,
) -> list[MusicInfo]:
"""异步搜索单个来源,并把异常降级为空候选。"""
try:
return await chain.async_search_music(meta, limit=limit)
except Exception as error:
self._warning(f"音乐来源 {source} 搜索失败:{str(error)}")
return []
+2 -1
View File
@@ -1,7 +1,8 @@
from typing import Optional
from app.runtime.extensions.service_registry import ServiceBaseHelper
from app.schemas import NotificationConf, ServiceInfo
from app.schemas.system import NotificationConf
from app.schemas.system import ServiceInfo
from app.schemas.types import ModuleType, SystemConfigKey
+1
View File
@@ -0,0 +1 @@
"""插件应用端口与用例。"""
+275
View File
@@ -0,0 +1,275 @@
"""插件市场目录应用服务。"""
from __future__ import annotations
import asyncio
import concurrent.futures
from collections.abc import Awaitable, Callable
from typing import Any, Optional
MarketLoader = Callable[[str, Optional[str], bool], Optional[dict[str, dict]]]
AsyncMarketLoader = Callable[
[str, Optional[str], bool],
Awaitable[Optional[dict[str, dict]]],
]
PluginMapper = Callable[[str, dict, str, list[str], int, Optional[str]], Any]
ProgressCallback = Callable[..., Any]
class PluginCatalogService:
"""负责插件市场索引映射、并发收集、代际合并和来源去重。"""
def __init__(
self,
*,
market_loader: MarketLoader,
async_market_loader: AsyncMarketLoader,
installed_plugins_provider: Callable[[], list[str]],
plugin_mapper: PluginMapper,
is_local_repo: Callable[[Optional[str]], bool],
version_compare: Callable[[str, str, str], bool],
warning: Callable[[str], Any],
error: Callable[[str], Any],
) -> None:
"""保存市场读取、插件映射和版本比较端口。"""
self._market_loader = market_loader
self._async_market_loader = async_market_loader
self._installed_plugins_provider = installed_plugins_provider
self._plugin_mapper = plugin_mapper
self._is_local_repo = is_local_repo
self._version_compare = version_compare
self._warning = warning
self._error = error
def load(
self,
market: str,
package_version: Optional[str] = None,
force: bool = False,
) -> list[Any]:
"""同步读取并映射指定市场和插件代际。"""
if not market:
return []
online_plugins = self._market_loader(market, package_version, force)
if online_plugins is None:
self._warning(
f"获取{package_version if package_version else ''}插件库失败:"
f"{market},请检查 GitHub 网络连接"
)
return []
return self._map_plugins(online_plugins, market, package_version)
async def async_load(
self,
market: str,
package_version: Optional[str] = None,
force: bool = False,
) -> list[Any]:
"""异步读取并映射指定市场和插件代际。"""
if not market:
return []
online_plugins = await self._async_market_loader(
market,
package_version,
force,
)
if online_plugins is None:
self._warning(
f"获取{package_version if package_version else ''}插件库失败:"
f"{market},请检查 GitHub 网络连接"
)
return []
return self._map_plugins(online_plugins, market, package_version)
def collect(
self,
*,
markets: list[str],
compatible_flags: list[str],
force: bool,
loader: Callable[[str, Optional[str], bool], list[Any]],
) -> list[Any]:
"""并发读取多个市场和代际,并按稳定优先级合并。"""
with concurrent.futures.ThreadPoolExecutor() as executor:
futures_meta: dict[
concurrent.futures.Future,
tuple[int, bool, int],
] = {}
for market_index, market in enumerate(markets):
base_future = executor.submit(loader, market, None, force)
futures_meta[base_future] = (market_index, False, 0)
for flag_priority, flag in enumerate(compatible_flags):
higher_future = executor.submit(loader, market, flag, force)
futures_meta[higher_future] = (
market_index,
True,
flag_priority,
)
collected = []
for future in concurrent.futures.as_completed(futures_meta):
plugins = future.result()
market_index, is_higher, flag_priority = futures_meta[future]
collected.append((
market_index,
is_higher,
flag_priority,
plugins or [],
))
collected.sort(key=lambda item: (item[0], 0 if item[1] else 1, item[2]))
higher_plugins = []
base_plugins = []
for _market_index, is_higher, _flag_priority, plugins in collected:
(higher_plugins if is_higher else base_plugins).extend(plugins)
return self.merge(higher_plugins, base_plugins, markets)
async def async_collect(
self,
*,
markets: list[str],
compatible_flags: list[str],
force: bool,
loader: Callable[
[str, Optional[str], bool],
Awaitable[list[Any]],
],
progress_callback: Optional[ProgressCallback] = None,
) -> list[Any]:
"""异步读取多个市场和代际,并持续报告稳定进度。"""
async def fetch(
market: str,
package_version: Optional[str],
result_version: str,
task_index: int,
) -> tuple[int, str, list[Any]]:
"""读取一个市场代际并保留创建时的稳定任务序号。"""
plugins = await loader(market, package_version, force)
return task_index, result_version, plugins or []
tasks = []
for market in markets:
tasks.append(asyncio.create_task(
fetch(market, None, "base_version", len(tasks))
))
for flag in compatible_flags:
tasks.append(asyncio.create_task(
fetch(market, flag, "higher_version", len(tasks))
))
higher_plugins = []
base_plugins = []
if tasks:
total_tasks = len(tasks)
finished_tasks = 0
task_results = {}
if progress_callback:
progress_callback(
value=0,
text=f"开始刷新插件市场,共 {total_tasks} 个请求 ...",
data={"total": total_tasks, "finished": 0},
)
for completed_task in asyncio.as_completed(tasks):
try:
task_index, version, plugins = await completed_task
task_results[task_index] = (version, plugins)
except Exception as err:
self._error(f"获取插件市场数据失败:{str(err)}")
finished_tasks += 1
if progress_callback:
progress_callback(
value=finished_tasks / total_tasks * 100,
text=(
f"插件市场请求({finished_tasks}/{total_tasks}"
"处理完成"
),
data={"total": total_tasks, "finished": finished_tasks},
)
for task_index in sorted(task_results):
version, plugins = task_results[task_index]
(higher_plugins if version == "higher_version" else base_plugins).extend(
plugins
)
result = self.merge(higher_plugins, base_plugins, markets)
if progress_callback:
progress_callback(value=100, text="插件市场缓存刷新完成")
return result
def merge(
self,
higher_plugins: list[Any],
base_plugins: list[Any],
markets: list[str],
) -> list[Any]:
"""按代际、来源顺序和版本合并插件目录。"""
all_plugins = list(higher_plugins)
higher_keys = {
f"{plugin.id}{plugin.plugin_version}"
for plugin in higher_plugins
}
all_plugins.extend(
plugin
for plugin in base_plugins
if f"{plugin.id}{plugin.plugin_version}" not in higher_keys
)
def repo_order(plugin: Any) -> int:
"""本地来源排在远程市场之后,远程来源保持配置顺序。"""
if self._is_local_repo(plugin.repo_url):
return len(markets) + 1
if plugin.repo_url in markets:
return markets.index(plugin.repo_url)
return len(markets)
deduplicated = {}
for plugin in sorted(all_plugins, key=repo_order):
key = f"{plugin.id}{plugin.plugin_version}"
exists = deduplicated.get(key)
if not exists or (
self._is_local_repo(exists.repo_url)
and not self._is_local_repo(plugin.repo_url)
):
deduplicated[key] = plugin
result_by_id = {}
for plugin in sorted(deduplicated.values(), key=repo_order):
exists = result_by_id.get(plugin.id)
if not exists \
or self._version_compare(
plugin.plugin_version,
">",
exists.plugin_version,
) \
or (
plugin.plugin_version == exists.plugin_version
and self._is_local_repo(exists.repo_url)
and not self._is_local_repo(plugin.repo_url)
):
result_by_id[plugin.id] = plugin
return list(result_by_id.values())
def _map_plugins(
self,
online_plugins: dict[str, dict],
market: str,
package_version: Optional[str],
) -> list[Any]:
"""把一个市场索引映射为宿主插件 DTO。"""
installed_plugins = self._installed_plugins_provider()
result = []
add_time = len(online_plugins)
for plugin_id, plugin_info in online_plugins.items():
plugin = self._plugin_mapper(
plugin_id,
plugin_info,
market,
installed_plugins,
add_time,
package_version,
)
if plugin:
result.append(plugin)
add_time -= 1
return result
+59
View File
@@ -0,0 +1,59 @@
"""插件配置保存、重置和运行态重建应用用例。"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True, slots=True)
class PluginConfigResult:
"""描述插件配置写操作是否成功及提示信息。"""
success: bool
message: str = ""
class PluginConfigCommand:
"""协调插件配置持久化、实例初始化和运行时注册刷新。"""
def __init__(
self,
*,
save_config: Callable[[str, dict, bool], bool],
initialize: Callable[[str, dict], Any],
stop: Callable[[str], Any],
delete_config: Callable[[str, bool], bool],
delete_data: Callable[[str, bool], bool],
reload_runtime: Callable[[str], Any],
publish_reset: Callable[[str], Any],
refresh_registrations: Callable[[str], Any],
) -> None:
"""保存插件管理 Facade 和运行时注册刷新端口。"""
self._save_config = save_config
self._initialize = initialize
self._stop = stop
self._delete_config = delete_config
self._delete_data = delete_data
self._reload_runtime = reload_runtime
self._publish_reset = publish_reset
self._refresh_registrations = refresh_registrations
def update(self, plugin_id: str, config: dict) -> PluginConfigResult:
"""保存配置并按既有顺序重新初始化实例及运行时注册。"""
if not self._save_config(plugin_id, config, False):
return PluginConfigResult(False, "插件配置保存失败")
self._initialize(plugin_id, config)
self._refresh_registrations(plugin_id)
return PluginConfigResult(True)
def reset(self, plugin_id: str) -> PluginConfigResult:
"""通知插件补偿后停止实例、删除配置数据并重建运行态。"""
self._publish_reset(plugin_id)
self._stop(plugin_id)
self._delete_config(plugin_id, True)
self._delete_data(plugin_id, True)
self._reload_runtime(plugin_id)
self._refresh_registrations(plugin_id)
return PluginConfigResult(True)
+384
View File
@@ -0,0 +1,384 @@
"""插件安装应用用例。"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any, Optional
InstalledPluginsReader = Callable[[], list[str]]
InstalledPluginsWriter = Callable[[list[str]], Awaitable[object]]
PluginIdsProvider = Callable[[], list[str]]
CompatibilityChecker = Callable[[str, str], Awaitable[Optional[str]]]
PackageInstaller = Callable[
[str, str, Optional[str], bool],
Awaitable[tuple[bool, str]],
]
PackageCheckpointer = Callable[[str], Awaitable[Any]]
PackageCheckpointAction = Callable[[Any], Awaitable[object]]
InstallReporter = Callable[[str, Optional[str]], Awaitable[object]]
PluginReloader = Callable[[str], Awaitable[object]]
PluginRegistrationRefresher = Callable[[str], Awaitable[object]]
@dataclass(frozen=True, slots=True)
class PluginInstallRollback:
"""描述失败安装中各类可补偿副作用的恢复结果。"""
file_attempted: bool = False
file_restored: bool = False
installed_list_attempted: bool = False
installed_list_restored: bool = False
runtime_attempted: bool = False
runtime_restored: bool = False
registrations_attempted: bool = False
registrations_restored: bool = False
dependency_supported: bool = False
errors: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class PluginInstallResult:
"""描述插件安装结果、失败阶段和可观察补偿状态。"""
success: bool
message: str = ""
refreshed_only: bool = False
package_installed: bool = False
installed_list_persisted: bool = False
runtime_reloaded: bool = False
registrations_refreshed: bool = False
reported: bool = False
report_error: str = ""
failure_stage: Optional[str] = None
checkpoint_cleanup_error: str = ""
rollback: PluginInstallRollback = field(default_factory=PluginInstallRollback)
class PluginInstallCommand:
"""协调插件检查、包事务、持久化、运行态刷新和安装上报。"""
def __init__(
self,
*,
installed_plugins_reader: InstalledPluginsReader,
installed_plugins_writer: InstalledPluginsWriter,
plugin_ids_provider: PluginIdsProvider,
compatibility_checker: CompatibilityChecker,
package_installer: PackageInstaller,
package_checkpointer: PackageCheckpointer,
package_committer: PackageCheckpointAction,
package_rollback: PackageCheckpointAction,
install_reporter: InstallReporter,
plugin_reloader: PluginReloader,
registration_refresher: PluginRegistrationRefresher,
) -> None:
"""保存安装用例所需端口,不绑定数据库、网络或运行时实现。"""
self._installed_plugins_reader = installed_plugins_reader
self._installed_plugins_writer = installed_plugins_writer
self._plugin_ids_provider = plugin_ids_provider
self._compatibility_checker = compatibility_checker
self._package_installer = package_installer
self._package_checkpointer = package_checkpointer
self._package_committer = package_committer
self._package_rollback = package_rollback
self._install_reporter = install_reporter
self._plugin_reloader = plugin_reloader
self._registration_refresher = registration_refresher
async def execute(
self,
*,
plugin_id: str,
repo_url: Optional[str],
release_version: Optional[str] = None,
force: bool = False,
) -> PluginInstallResult:
"""执行插件安装,并在关键阶段失败时恢复可补偿状态。"""
installed_plugins = list(self._installed_plugins_reader() or [])
refreshed_only = not force and plugin_id in self._plugin_ids_provider()
if refreshed_only:
return await self._refresh_existing(
plugin_id=plugin_id,
repo_url=repo_url,
)
if not repo_url:
return PluginInstallResult(
success=False,
message="没有传入仓库地址,无法正确安装插件,请检查配置",
failure_stage="validation",
)
try:
checkpoint = await self._package_checkpointer(plugin_id)
except Exception as err:
return PluginInstallResult(
success=False,
message=f"创建插件安装快照失败:{err}",
failure_stage="package_checkpoint",
)
try:
state, message = await self._package_installer(
plugin_id,
repo_url,
release_version,
force,
)
except Exception as err:
return await self._failure(
plugin_id=plugin_id,
original_plugins=installed_plugins,
checkpoint=checkpoint,
stage="package_install",
message=str(err),
package_installed=False,
)
if not state:
return await self._failure(
plugin_id=plugin_id,
original_plugins=installed_plugins,
checkpoint=checkpoint,
stage="package_install",
message=message,
package_installed=False,
)
installed_list_persisted = False
if plugin_id not in installed_plugins:
updated_plugins = [*installed_plugins, plugin_id]
try:
await self._installed_plugins_writer(updated_plugins)
installed_list_persisted = True
except Exception as err:
return await self._failure(
plugin_id=plugin_id,
original_plugins=installed_plugins,
checkpoint=checkpoint,
stage="installed_list_persistence",
message=str(err),
package_installed=True,
)
try:
await self._plugin_reloader(plugin_id)
except Exception as err:
return await self._failure(
plugin_id=plugin_id,
original_plugins=installed_plugins,
checkpoint=checkpoint,
stage="runtime_reload",
message=str(err),
package_installed=True,
installed_list_persisted=installed_list_persisted,
runtime_touched=True,
)
try:
await self._registration_refresher(plugin_id)
except Exception as err:
return await self._failure(
plugin_id=plugin_id,
original_plugins=installed_plugins,
checkpoint=checkpoint,
stage="registration_refresh",
message=str(err),
package_installed=True,
installed_list_persisted=installed_list_persisted,
runtime_touched=True,
registrations_touched=True,
)
checkpoint_cleanup_error = ""
try:
await self._package_committer(checkpoint)
except Exception as err:
checkpoint_cleanup_error = str(err)
reported = False
report_error = ""
try:
report_result = await self._install_reporter(plugin_id, repo_url)
reported = report_result is not False
if not reported:
report_error = "安装上报未确认"
except Exception as err:
report_error = str(err)
result_message = message or "插件安装成功"
if checkpoint_cleanup_error:
result_message = f"{result_message};临时安装快照清理失败"
if report_error:
result_message = f"{result_message};安装上报失败,不影响本地安装"
return PluginInstallResult(
success=True,
message=result_message,
package_installed=True,
installed_list_persisted=installed_list_persisted,
runtime_reloaded=True,
registrations_refreshed=True,
reported=reported,
report_error=report_error,
checkpoint_cleanup_error=checkpoint_cleanup_error,
)
async def _refresh_existing(
self,
*,
plugin_id: str,
repo_url: Optional[str],
) -> PluginInstallResult:
"""刷新已存在插件,不触碰包文件和已安装列表。"""
if repo_url:
compatible_message = await self._compatibility_checker(
plugin_id,
repo_url,
)
if compatible_message:
return PluginInstallResult(
success=False,
message=compatible_message,
refreshed_only=True,
failure_stage="compatibility",
)
failure_stage = "runtime_reload"
try:
await self._plugin_reloader(plugin_id)
failure_stage = "registration_refresh"
await self._registration_refresher(plugin_id)
except Exception as err:
rollback_errors = []
runtime_restored = False
registrations_restored = False
try:
await self._plugin_reloader(plugin_id)
runtime_restored = True
except Exception as rollback_err:
rollback_errors.append(f"运行态恢复失败:{rollback_err}")
if runtime_restored:
try:
await self._registration_refresher(plugin_id)
registrations_restored = True
except Exception as rollback_err:
rollback_errors.append(f"路由和服务注册恢复失败:{rollback_err}")
return PluginInstallResult(
success=False,
message=f"刷新插件运行态失败:{err}",
refreshed_only=True,
failure_stage=failure_stage,
rollback=PluginInstallRollback(
runtime_attempted=True,
runtime_restored=runtime_restored,
registrations_attempted=True,
registrations_restored=registrations_restored,
errors=tuple(rollback_errors),
),
)
reported = False
report_error = ""
try:
report_result = await self._install_reporter(plugin_id, repo_url)
reported = report_result is not False
if not reported:
report_error = "安装上报未确认"
except Exception as err:
report_error = str(err)
return PluginInstallResult(
success=True,
message=(
"插件已存在,已刷新加载"
if not report_error
else "插件已存在,已刷新加载;安装上报失败,不影响本地刷新"
),
refreshed_only=True,
runtime_reloaded=True,
registrations_refreshed=True,
reported=reported,
report_error=report_error,
)
async def _failure(
self,
*,
plugin_id: str,
original_plugins: list[str],
checkpoint: Any,
stage: str,
message: str,
package_installed: bool,
installed_list_persisted: bool = False,
runtime_touched: bool = False,
registrations_touched: bool = False,
) -> PluginInstallResult:
"""按持久化、文件、运行态顺序补偿失败安装并记录结果。"""
errors = []
installed_list_restored = False
if installed_list_persisted:
try:
await self._installed_plugins_writer(list(original_plugins))
installed_list_restored = True
except Exception as err:
errors.append(f"已安装列表恢复失败:{err}")
file_restored = False
try:
await self._package_rollback(checkpoint)
file_restored = True
except Exception as err:
errors.append(f"插件文件恢复失败:{err}")
runtime_restored = False
registrations_restored = False
if runtime_touched:
try:
await self._plugin_reloader(plugin_id)
runtime_restored = True
except Exception as err:
errors.append(f"插件运行态恢复失败:{err}")
if runtime_restored:
try:
await self._registration_refresher(plugin_id)
registrations_restored = True
except Exception as err:
errors.append(f"插件路由和服务注册恢复失败:{err}")
rollback = PluginInstallRollback(
file_attempted=True,
file_restored=file_restored,
installed_list_attempted=installed_list_persisted,
installed_list_restored=installed_list_restored,
runtime_attempted=runtime_touched,
runtime_restored=runtime_restored,
registrations_attempted=runtime_touched or registrations_touched,
registrations_restored=registrations_restored,
dependency_supported=False,
errors=tuple(errors),
)
rollback_message = []
rollback_message.append("插件文件已恢复" if file_restored else "插件文件恢复失败")
if installed_list_persisted:
rollback_message.append(
"已安装列表已恢复"
if installed_list_restored
else "已安装列表恢复失败"
)
if runtime_touched:
rollback_message.append(
"旧运行态已恢复" if runtime_restored else "旧运行态恢复失败"
)
rollback_message.append(
"旧路由和服务注册已恢复"
if registrations_restored
else "旧路由和服务注册恢复失败"
)
rollback_message.append("Python依赖变更不支持自动回滚")
return PluginInstallResult(
success=False,
message=f"{message}{''.join(rollback_message)}",
package_installed=package_installed,
installed_list_persisted=installed_list_persisted,
failure_stage=stage,
rollback=rollback,
)
+15
View File
@@ -0,0 +1,15 @@
"""动态插件路由应用端口。"""
from typing import Optional, Protocol
class DynamicRouteRegistry(Protocol):
"""插件生命周期操作动态 HTTP 路由所需的最小端口。"""
def update(self, plugin_id: Optional[str], action: str) -> None:
"""新增或移除指定插件的动态路由。"""
...
def remove(self, plugin_id: str) -> bool:
"""移除指定插件的全部动态路由。"""
...
+23 -71
View File
@@ -11,8 +11,9 @@ FastAPI 实例由组合根(factory 创建应用后)注入,端点与 Agent
from typing import Optional
from fastapi import Depends, FastAPI
from fastapi import FastAPI
from app.adapters.web.plugin.routes import FastAPIDynamicRouteRegistry
from app.application.security.access import verify_apikey, verify_token
from app.db.oper.systemconfig import SystemConfigOper
from app.runtime.config import settings
@@ -45,7 +46,21 @@ def get_api_app() -> FastAPI:
return _api_app
def register_plugin_api(plugin_id: Optional[str] = None):
def _route_registry() -> FastAPIDynamicRouteRegistry:
"""组装绑定当前 FastAPI 应用与插件管理器的动态路由适配器。"""
return FastAPIDynamicRouteRegistry(
app=get_api_app(),
plugin_ids=lambda: PluginManager().get_running_plugin_ids(),
plugin_apis=lambda plugin_id: PluginManager().get_plugin_apis(plugin_id),
verify_token=verify_token,
verify_apikey=verify_apikey,
prefix=PLUGIN_PREFIX,
protected_routes=PROTECTED_ROUTES,
log=logger,
)
def register_plugin_api(plugin_id: Optional[str] = None) -> None:
"""
动态注册插件 API
:param plugin_id: 插件 ID,如果为 None,则注册所有插件
@@ -53,7 +68,7 @@ def register_plugin_api(plugin_id: Optional[str] = None):
_update_plugin_api_routes(plugin_id, action="add")
def remove_plugin_api(plugin_id: str):
def remove_plugin_api(plugin_id: str) -> None:
"""
动态移除单个插件的 API
:param plugin_id: 插件 ID
@@ -61,55 +76,14 @@ def remove_plugin_api(plugin_id: str):
_update_plugin_api_routes(plugin_id, action="remove")
def _update_plugin_api_routes(plugin_id: Optional[str], action: str):
def _update_plugin_api_routes(plugin_id: Optional[str], action: str) -> None:
"""
插件 API 路由注册和移除
:param plugin_id: 插件 ID,如果 action 为 "add" 且 plugin_id 为 None,则处理所有插件
如果 action 为 "remove",plugin_id 必须是有效的插件 ID
:param action: "add""remove",决定是添加还是移除路由
"""
if action not in {"add", "remove"}:
raise ValueError("Action must be 'add' or 'remove'")
app = get_api_app()
is_modified = False
existing_paths = {route.path: route for route in app.routes}
plugin_ids = [plugin_id] if plugin_id else PluginManager().get_running_plugin_ids()
for plugin_id in plugin_ids:
routes_removed = _remove_routes(plugin_id)
if routes_removed:
is_modified = True
if action != "add":
continue
# 获取插件的 API 路由信息
plugin_apis = PluginManager().get_plugin_apis(plugin_id)
for api in plugin_apis:
api_path = f"{PLUGIN_PREFIX}{api.get('path', '')}"
try:
api["path"] = api_path
allow_anonymous = api.pop("allow_anonymous", False)
auth_mode = api.pop("auth", "apikey")
dependencies = api.setdefault("dependencies", [])
if not allow_anonymous:
if (
auth_mode == "bear"
and Depends(verify_token) not in dependencies
):
dependencies.append(Depends(verify_token))
elif Depends(verify_apikey) not in dependencies:
dependencies.append(Depends(verify_apikey))
app.add_api_route(**api, tags=["plugin"])
is_modified = True
logger.debug(f"Added plugin route: {api_path}")
except Exception as e:
logger.error(f"Error adding plugin route {api_path}: {str(e)}")
if is_modified:
_clean_protected_routes(existing_paths)
app.openapi_schema = None
app.setup()
_route_registry().update(plugin_id, action)
def _remove_routes(plugin_id: str) -> bool:
@@ -118,37 +92,15 @@ def _remove_routes(plugin_id: str) -> bool:
:param plugin_id: 插件 ID
:return: 是否有路由被移除
"""
if not plugin_id:
return False
app = get_api_app()
prefix = f"{PLUGIN_PREFIX}/{plugin_id}/"
routes_to_remove = [
route for route in app.routes if route.path.startswith(prefix)
]
removed = False
for route in routes_to_remove:
try:
app.routes.remove(route)
removed = True
logger.debug(f"Removed plugin route: {route.path}")
except Exception as e:
logger.error(f"Error removing plugin route {route.path}: {str(e)}")
return removed
return _route_registry().remove(plugin_id)
def _clean_protected_routes(existing_paths: dict):
def _clean_protected_routes(existing_paths: dict) -> None:
"""
清理受保护的路由,防止在插件操作中被删除或重复添加
:param existing_paths: 当前应用的路由路径映射
"""
app = get_api_app()
for protected_route in PROTECTED_ROUTES:
try:
existing_route = existing_paths.get(protected_route)
if existing_route:
app.routes.remove(existing_route)
except Exception as e:
logger.error(f"Error removing protected route {protected_route}: {str(e)}")
_route_registry().clean(existing_paths)
def remove_plugin_from_folders(plugin_id: str):
-1
View File
@@ -1,4 +1,3 @@
import re
import traceback
from typing import List, Tuple, Union, Optional
from urllib.parse import urljoin, urlparse
+2 -1
View File
@@ -11,7 +11,8 @@ from pyparsing import Forward, Literal, Word, alphas, infix_notation, opAssoc, a
from app.adapters.system import rust as rust_accel
from app.db.oper.systemconfig import SystemConfigOper
from app.domain.context import MediaInfo
from app.schemas import CustomRule, FilterRuleGroup
from app.schemas.rule import CustomRule
from app.schemas.system import FilterRuleGroup
from app.schemas.types import SystemConfigKey
+1
View File
@@ -0,0 +1 @@
"""搜索应用服务。"""
+135
View File
@@ -0,0 +1,135 @@
"""搜索参数与结果缓存的应用服务。"""
from typing import Any, Awaitable, Callable, Dict, List, Optional
from app.schemas.media import parse_media_key, resolve_media_identity
from app.schemas.types import MediaSource, MediaType
def stringify_sites(sites: Optional[List[int]]) -> str:
"""将站点 ID 列表转换为前端可复用的逗号分隔值。"""
return ",".join(str(site) for site in sites) if sites else ""
def normalize_search_params(
params: Optional[Dict[str, Any]],
) -> Optional[Dict[str, str]]:
"""把搜索缓存归一为前端重新搜索使用的稳定字段。"""
if not isinstance(params, dict):
return None
media_source, media_id = resolve_media_identity(
media_source=params.get("media_source"),
media_id=params.get("media_id"),
)
keyword = str(params.get("keyword") or "")
if not media_source and keyword:
media_source, media_id = parse_media_key(keyword)
if media_source and media_id:
keyword = ""
normalized = {
"keyword": keyword,
"media_source": str(media_source) if media_source else "",
"media_id": media_id or "",
"type": str(params.get("type") or ""),
"area": str(params.get("area") or ""),
"title": str(params.get("title") or ""),
"year": str(params.get("year") or ""),
"season": str(params["season"]) if params.get("season") is not None else "",
"episode": str(params.get("episode") or ""),
"sites": str(params.get("sites") or ""),
"result_type": str(params.get("result_type") or "torrent"),
}
if params.get("music_type"):
normalized["music_type"] = str(params["music_type"])
return normalized if normalized["keyword"] or media_id else None
class SearchStateService:
"""通过注入的缓存端口保存和读取搜索状态。"""
def __init__(
self,
save_cache: Callable[[Any, str], None],
load_cache: Callable[[str], Any],
async_save_cache: Callable[[Any, str], Awaitable[None]],
async_load_cache: Callable[[str], Awaitable[Any]],
params_key: str,
result_key: str,
subtitle_result_key: str,
) -> None:
"""保存缓存端口和兼容缓存键。"""
self._save_cache = save_cache
self._load_cache = load_cache
self._async_save_cache = async_save_cache
self._async_load_cache = async_load_cache
self._params_key = params_key
self._result_key = result_key
self._subtitle_result_key = subtitle_result_key
@staticmethod
def build_params(
*,
keyword: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
mtype: Optional[MediaType] = None,
area: Optional[str] = "title",
title: Optional[str] = None,
year: Optional[str] = None,
season: Optional[int] = None,
episode: Optional[int] = None,
sites: Optional[List[int]] = None,
music_type: Optional[str] = None,
result_type: Optional[str] = "torrent",
) -> Optional[Dict[str, str]]:
"""把公开搜索参数构造成可持久化的兼容字典。"""
return normalize_search_params(
{
"keyword": keyword,
"media_source": media_source,
"media_id": media_id,
"type": mtype.value if isinstance(mtype, MediaType) else mtype,
"area": area,
"title": title,
"year": year,
"season": season,
"episode": episode,
"sites": stringify_sites(sites),
"music_type": music_type,
"result_type": result_type or "torrent",
}
)
def save_params(self, **kwargs: Any) -> None:
"""同步保存最后一次有效搜索参数。"""
params = self.build_params(**kwargs)
if params:
self._save_cache(params, self._params_key)
async def async_save_params(self, **kwargs: Any) -> None:
"""异步保存最后一次有效搜索参数。"""
params = self.build_params(**kwargs)
if params:
await self._async_save_cache(params, self._params_key)
def load_params(self) -> Optional[Dict[str, str]]:
"""同步读取并归一化最后一次搜索参数。"""
return normalize_search_params(self._load_cache(self._params_key))
async def async_load_params(self) -> Optional[Dict[str, str]]:
"""异步读取并归一化最后一次搜索参数。"""
return normalize_search_params(await self._async_load_cache(self._params_key))
def load_results(self) -> Any:
"""同步读取最后一次资源搜索结果。"""
return self._load_cache(self._result_key)
async def async_load_results(self) -> Any:
"""异步读取最后一次资源搜索结果。"""
return await self._async_load_cache(self._result_key)
async def async_load_subtitle_results(self) -> Any:
"""异步读取最后一次字幕搜索结果。"""
return await self._async_load_cache(self._subtitle_result_key)
+9 -9
View File
@@ -15,7 +15,7 @@ from Crypto.Util.Padding import pad
from cryptography.fernet import Fernet
from fastapi import HTTPException, status, Security, Request, Response
from fastapi.security import OAuth2PasswordBearer, APIKeyHeader, APIKeyQuery, APIKeyCookie, HTTPBearer
from app import schemas
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.runtime.cache import cached
from app.runtime.config import settings
from app.runtime.log import logger
@@ -23,7 +23,7 @@ from app.runtime.log import logger
BCRYPT_PASSWORD_MAX_BYTES = 72
BCRYPT_ROUNDS = 12
ALGORITHM = "HS256"
SuperuserTokenPayloadProvider = Callable[[], schemas.TokenPayload]
SuperuserTokenPayloadProvider = Callable[[], _SchemaTokenPayload]
_superuser_token_payload_provider: Optional[SuperuserTokenPayloadProvider] = None
@@ -102,7 +102,7 @@ def __get_api_key(
@cached(maxsize=1, ttl=600)
def __create_superuser_token_payload() -> schemas.TokenPayload:
def __create_superuser_token_payload() -> _SchemaTokenPayload:
"""
创建管理员用户的TokenPayload
@@ -164,7 +164,7 @@ def create_access_token(
def set_or_refresh_resource_token_cookie(
request: Request, response: Response, payload: schemas.TokenPayload
request: Request, response: Response, payload: _SchemaTokenPayload
) -> None:
"""
设置资源令牌 Cookie
@@ -229,7 +229,7 @@ def set_or_refresh_resource_token_cookie(
)
def __verify_token(token: str, purpose: Optional[str] = "authentication") -> schemas.TokenPayload:
def __verify_token(token: str, purpose: Optional[str] = "authentication") -> _SchemaTokenPayload:
"""
使用 JWT Token 进行身份认证并解析 Token 的内容
:param token: JWT 令牌
@@ -253,12 +253,12 @@ def __verify_token(token: str, purpose: Optional[str] = "authentication") -> sch
token, secret_key, algorithms=[ALGORITHM]
)
token_payload = schemas.TokenPayload(**payload)
token_payload = _SchemaTokenPayload(**payload)
if token_payload.purpose != purpose:
raise jwt.InvalidTokenError("令牌用途不匹配")
return schemas.TokenPayload(**payload)
return _SchemaTokenPayload(**payload)
except (jwt.DecodeError, jwt.InvalidTokenError, jwt.ImmatureSignatureError):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
@@ -272,7 +272,7 @@ def verify_token(
jwt_token: Annotated[str | None, Security(oauth2_scheme_manual_error)],
api_key: Annotated[str | None, Security(__get_api_key)],
api_token: Annotated[str | None, Security(__get_api_token)],
) -> schemas.TokenPayload:
) -> _SchemaTokenPayload:
"""
验证 JWT 令牌并自动处理 resource_token 写入
@@ -310,7 +310,7 @@ def verify_token(
def verify_resource_token(
resource_token: Annotated[str, Security(resource_token_cookie)]
) -> schemas.TokenPayload:
) -> _SchemaTokenPayload:
"""
验证资源访问令牌 Cookie 中获取
:param resource_token: Cookie 中获取的资源访问令牌
+6 -5
View File
@@ -6,7 +6,8 @@ from typing import Any, Optional
from fastapi import HTTPException, status
from app import schemas
from app.schemas.token import Token as _SchemaToken
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.application.security import access as security
from app.runtime.config import settings
from app.db.models.user import User
@@ -118,7 +119,7 @@ def consume_plugin_auth_ticket(ticket: str) -> Optional[dict[str, Any]]:
return AuthTicketStore().consume(ticket)
def build_superuser_token_payload() -> schemas.TokenPayload:
def build_superuser_token_payload() -> _SchemaTokenPayload:
"""从持久化用户和站点认证状态构造超级用户令牌载荷。"""
user = UserOper().get_by_name(settings.SUPERUSER)
if not user or not user.is_superuser:
@@ -126,7 +127,7 @@ def build_superuser_token_payload() -> schemas.TokenPayload:
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户权限不足",
)
return schemas.TokenPayload(
return _SchemaTokenPayload(
sub=user.id,
username=user.name,
super_user=user.is_superuser,
@@ -135,7 +136,7 @@ def build_superuser_token_payload() -> schemas.TokenPayload:
)
def build_token_response(user: User) -> schemas.Token:
def build_token_response(user: User) -> _SchemaToken:
"""
使用系统统一逻辑构造登录 Token 响应
@@ -147,7 +148,7 @@ def build_token_response(user: User) -> schemas.Token:
not SystemConfigOper().get(SystemConfigKey.SetupWizardState)
and not settings.ADVANCED_MODE
)
return schemas.Token(
return _SchemaToken(
access_token=security.create_access_token(
userid=user.id,
username=user.name,
+1
View File
@@ -0,0 +1 @@
"""MoviePilot 中心服务应用用例。"""
+134
View File
@@ -0,0 +1,134 @@
"""中心服务存量上报用例。"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any, Optional
from app.schemas.media import resolve_media_identity
class ServerReportService:
"""协调本地订阅、插件清单和中心服务统计上报。"""
SUBSCRIBE_FIELDS = frozenset({
"name", "year", "type", "media_source", "media_id", "music_type",
"total_tracks", "genre_ids", "season", "poster", "backdrop", "vote",
"description",
})
def __init__(
self,
*,
config_reader: Callable[[Any], Any],
config_writer: Callable[[Any, Any], Any],
installed_plugins_provider: Callable[[], list[str]],
subscribes_provider: Callable[[], list[Any]],
plugin_report_sender: Callable[[list[dict]], Any],
async_plugin_report_sender: Callable[[list[dict]], Awaitable[Any]],
subscribe_report_sender: Callable[[list[dict]], Any],
repo_url_sanitizer: Callable[[Optional[str]], Optional[str]],
) -> None:
"""保存本地读取端口和只负责 I/O 的中心服务发送端口。"""
self._config_reader = config_reader
self._config_writer = config_writer
self._installed_plugins_provider = installed_plugins_provider
self._subscribes_provider = subscribes_provider
self._plugin_report_sender = plugin_report_sender
self._async_plugin_report_sender = async_plugin_report_sender
self._subscribe_report_sender = subscribe_report_sender
self._repo_url_sanitizer = repo_url_sanitizer
def init_report(
self,
*,
enabled: bool,
state_key: Any,
reporter: Callable[[], bool],
) -> None:
"""首次成功上报后写入对应的完成标记。"""
if enabled and not self._config_reader(state_key) and reporter():
self._config_writer(state_key, "1")
def build_subscribe_payload(self, item: Optional[dict]) -> Optional[dict]:
"""构造中心服务订阅统计载荷并移除本地运行字段。"""
if not isinstance(item, dict):
return None
media_source, media_id = resolve_media_identity(media=item)
if not media_source or not media_id:
return None
payload = {
key: value
for key, value in item.items()
if key in self.SUBSCRIBE_FIELDS
}
payload["media_source"] = str(media_source)
payload["media_id"] = media_id
return payload
def build_plugin_payload(
self,
items: Optional[list[tuple[str, Optional[str]]]] = None,
) -> list[dict[str, Any]]:
"""构造插件安装统计载荷并脱敏本地仓库路径。"""
if items:
return [
{
"plugin_id": plugin_id,
"repo_url": self._repo_url_sanitizer(repo_url),
}
for plugin_id, repo_url in items
if plugin_id
]
return [
{"plugin_id": plugin_id, "repo_url": None}
for plugin_id in self._installed_plugins_provider()
if plugin_id
]
def report_subscribes(self, *, enabled: bool) -> bool:
"""上报当前全部有效订阅的公开统计字段。"""
if not enabled:
return False
subscribes = self._subscribes_provider()
if not subscribes:
return True
payloads = [
payload
for subscribe in subscribes
if (payload := self.build_subscribe_payload(subscribe.to_dict()))
]
if not payloads:
return True
response = self._subscribe_report_sender(payloads)
return bool(response is not None and response.status_code == 200)
def report_plugins(
self,
*,
enabled: bool,
items: Optional[list[tuple[str, Optional[str]]]] = None,
) -> bool:
"""同步上报当前插件安装清单。"""
if not enabled:
return False
payload = self.build_plugin_payload(items)
if not payload:
return False
response = self._plugin_report_sender(payload)
return bool(response is not None and response.status_code == 200)
async def async_report_plugins(
self,
*,
enabled: bool,
items: Optional[list[tuple[str, Optional[str]]]] = None,
) -> bool:
"""异步上报当前插件安装清单。"""
if not enabled:
return False
payload = self.build_plugin_payload(items)
if not payload:
return False
response = await self._async_plugin_report_sender(payload)
return bool(response is not None and response.status_code == 200)
+201
View File
@@ -0,0 +1,201 @@
"""中心服务订阅和工作流分享用例。"""
from __future__ import annotations
import json
from collections.abc import Awaitable, Callable
from typing import Any, Optional
from app.schemas.media import resolve_media_identity
class ServerSharingService:
"""协调本地订阅、工作流读取与中心服务分享传输。"""
SUBSCRIBE_FIELDS = frozenset({
"share_title", "share_comment", "share_user", "share_uid", "name",
"year", "type", "keyword", "media_source", "media_id", "music_type",
"total_tracks", "season", "poster", "backdrop", "vote", "description",
"genre_ids", "include", "exclude", "quality", "resolution", "effect",
"total_episode", "custom_words", "media_category", "episode_group",
"date",
})
def __init__(
self,
*,
subscribe_provider: Callable[[int], Any],
async_subscribe_provider: Callable[[int], Awaitable[Any]],
workflow_provider: Callable[[int], Any],
async_workflow_provider: Callable[[int], Awaitable[Any]],
user_uuid_provider: Callable[[], str],
subscribe_sender: Callable[[dict], Any],
async_subscribe_sender: Callable[[dict], Awaitable[Any]],
workflow_sender: Callable[[dict], Any],
async_workflow_sender: Callable[[dict], Awaitable[Any]],
response_handler: Callable[[Any, Callable[[], None]], tuple[bool, str]],
subscribe_cache_clearer: Callable[[], None],
workflow_cache_clearer: Callable[[], None],
) -> None:
"""保存本地数据端口、中心服务传输端口和缓存失效端口。"""
self._subscribe_provider = subscribe_provider
self._async_subscribe_provider = async_subscribe_provider
self._workflow_provider = workflow_provider
self._async_workflow_provider = async_workflow_provider
self._user_uuid_provider = user_uuid_provider
self._subscribe_sender = subscribe_sender
self._async_subscribe_sender = async_subscribe_sender
self._workflow_sender = workflow_sender
self._async_workflow_sender = async_workflow_sender
self._response_handler = response_handler
self._subscribe_cache_clearer = subscribe_cache_clearer
self._workflow_cache_clearer = workflow_cache_clearer
def build_subscribe_payload(self, item: Optional[dict]) -> Optional[dict]:
"""构造订阅分享载荷并隔离本地字段和旧专用 ID。"""
if not isinstance(item, dict):
return None
media_source, media_id = resolve_media_identity(media=item)
if not media_source or not media_id:
return None
payload = {
key: value
for key, value in item.items()
if key in self.SUBSCRIBE_FIELDS
}
payload["media_source"] = str(media_source)
payload["media_id"] = media_id
return payload
@staticmethod
def prepare_workflow(workflow: Any) -> dict:
"""移除本地字段并把动作和流程编码为中心服务兼容格式。"""
workflow_dict = workflow.to_dict()
workflow_dict.pop("id", None)
workflow_dict.pop("context", None)
workflow_dict["actions"] = json.dumps(workflow_dict["actions"] or [])
workflow_dict["flows"] = json.dumps(workflow_dict["flows"] or [])
return workflow_dict
@staticmethod
def validate_workflow(workflow: Any) -> tuple[bool, str]:
"""验证工作流存在且同时包含动作与流程。"""
if not workflow:
return False, "工作流不存在"
if not workflow.actions or not workflow.flows:
return False, "请分享有动作和流程的工作流"
return True, ""
def share_subscribe(
self,
*,
enabled: bool,
subscribe_id: int,
share_title: str,
share_comment: str,
share_user: str,
) -> tuple[bool, str]:
"""同步读取并分享指定订阅。"""
if not enabled:
return False, "当前没有开启订阅数据共享功能"
subscribe = self._subscribe_provider(subscribe_id)
if not subscribe:
return False, "订阅不存在"
payload = self.build_subscribe_payload({
"share_title": share_title,
"share_comment": share_comment,
"share_user": share_user,
"share_uid": self._user_uuid_provider(),
**subscribe.to_dict(),
})
if not payload:
return False, "订阅媒体身份不完整"
return self._response_handler(
self._subscribe_sender(payload),
self._subscribe_cache_clearer,
)
async def async_share_subscribe(
self,
*,
enabled: bool,
subscribe_id: int,
share_title: str,
share_comment: str,
share_user: str,
) -> tuple[bool, str]:
"""异步读取并分享指定订阅。"""
if not enabled:
return False, "当前没有开启订阅数据共享功能"
subscribe = await self._async_subscribe_provider(subscribe_id)
if not subscribe:
return False, "订阅不存在"
payload = self.build_subscribe_payload({
"share_title": share_title,
"share_comment": share_comment,
"share_user": share_user,
"share_uid": self._user_uuid_provider(),
**subscribe.to_dict(),
})
if not payload:
return False, "订阅媒体身份不完整"
return self._response_handler(
await self._async_subscribe_sender(payload),
self._subscribe_cache_clearer,
)
def share_workflow(
self,
*,
enabled: bool,
workflow_id: int,
share_title: str,
share_comment: str,
share_user: str,
) -> tuple[bool, str]:
"""同步读取并分享指定工作流。"""
if not enabled:
return False, "当前没有开启工作流数据共享功能"
workflow = self._workflow_provider(workflow_id)
valid, message = self.validate_workflow(workflow)
if not valid:
return False, message
payload = {
"share_title": share_title,
"share_comment": share_comment,
"share_user": share_user,
"share_uid": self._user_uuid_provider(),
**self.prepare_workflow(workflow),
}
return self._response_handler(
self._workflow_sender(payload),
self._workflow_cache_clearer,
)
async def async_share_workflow(
self,
*,
enabled: bool,
workflow_id: int,
share_title: str,
share_comment: str,
share_user: str,
) -> tuple[bool, str]:
"""异步读取并分享指定工作流。"""
if not enabled:
return False, "当前没有开启工作流数据共享功能"
workflow = await self._async_workflow_provider(workflow_id)
valid, message = self.validate_workflow(workflow)
if not valid:
return False, message
payload = {
"share_title": share_title,
"share_comment": share_comment,
"share_user": share_user,
"share_uid": self._user_uuid_provider(),
**self.prepare_workflow(workflow),
}
return self._response_handler(
await self._async_workflow_sender(payload),
self._workflow_cache_clearer,
)
+142
View File
@@ -0,0 +1,142 @@
"""站点写操作应用用例。"""
from dataclasses import dataclass
from typing import Any, Awaitable, Callable, Mapping, Optional, Protocol
from app.application.subscription.delete import AsyncUnitOfWork
@dataclass(frozen=True, slots=True)
class SiteMutationResult:
"""描述站点写操作是否成功及兼容提示信息。"""
success: bool
message: str = ""
class SiteMutationRepository(Protocol):
"""站点写用例需要的最小持久化端口。"""
async def get_by_id(self, site_id: int) -> Optional[Any]:
"""读取指定站点。"""
...
async def get_by_domain(self, domain: str) -> Optional[Any]:
"""按域名读取站点。"""
...
async def stage_create(self, payload: Mapping[str, Any]) -> None:
"""暂存新增站点。"""
...
async def stage_update(self, site_id: int, payload: Mapping[str, Any]) -> bool:
"""暂存站点更新并返回目标是否存在。"""
...
async def stage_delete(self, site_id: int) -> None:
"""暂存站点删除。"""
...
async def stage_priorities(self, priorities: list[dict]) -> None:
"""暂存一组站点优先级变更。"""
...
SiteIndexerLoader = Callable[[str], Awaitable[Optional[dict]]]
SiteEventPublisher = Callable[[dict], Awaitable[None]]
DomainExtractor = Callable[[str], str]
UrlNormalizer = Callable[[str], str]
class SiteMutationCommand:
"""统一执行站点新增、更新、优先级和删除事务。"""
def __init__(
self,
*,
repository: SiteMutationRepository,
unit_of_work: AsyncUnitOfWork,
auth_level_provider: Callable[[], int],
indexer_loader: SiteIndexerLoader,
domain_extractor: DomainExtractor,
url_normalizer: UrlNormalizer,
publish_updated: SiteEventPublisher,
publish_deleted: SiteEventPublisher,
) -> None:
"""保存站点验证、持久化、事务和提交后事件端口。"""
self._repository = repository
self._unit_of_work = unit_of_work
self._auth_level_provider = auth_level_provider
self._indexer_loader = indexer_loader
self._domain_extractor = domain_extractor
self._url_normalizer = url_normalizer
self._publish_updated = publish_updated
self._publish_deleted = publish_deleted
async def create(self, payload: Mapping[str, Any]) -> SiteMutationResult:
"""校验并新增站点,提交成功后发布站点更新事件。"""
values = dict(payload)
raw_url = values.get("url")
if not raw_url:
return SiteMutationResult(False, "站点地址不能为空")
if self._auth_level_provider() < 2:
return SiteMutationResult(False, "用户未通过认证,无法使用站点功能!")
domain = self._domain_extractor(raw_url)
site_info = await self._indexer_loader(domain)
if not site_info:
return SiteMutationResult(False, "该站点不支持,请检查站点域名是否正确")
if await self._repository.get_by_domain(domain):
return SiteMutationResult(False, f"{domain} 站点己存在")
values.update({
"id": None,
"domain": domain,
"url": self._url_normalizer(raw_url),
"name": site_info.get("name"),
"public": 1 if site_info.get("public") else 0,
})
await self._repository.stage_create(values)
await self._commit()
await self._publish_updated({"domain": domain})
return SiteMutationResult(True)
async def update(self, payload: Mapping[str, Any]) -> SiteMutationResult:
"""更新站点并在提交后发布完整的兼容事件载荷。"""
values = dict(payload)
site_id = values.get("id")
if not site_id or not await self._repository.get_by_id(site_id):
return SiteMutationResult(False, "站点不存在")
values["url"] = self._url_normalizer(values.get("url") or "")
values["domain"] = self._domain_extractor(values["url"])
await self._repository.stage_update(site_id, values)
await self._commit()
await self._publish_updated({
"site_id": site_id,
"domain": values["domain"],
"name": values.get("name"),
"site_url": values["url"],
})
return SiteMutationResult(True)
async def update_priorities(self, priorities: list[dict]) -> SiteMutationResult:
"""在同一事务中更新全部站点优先级。"""
await self._repository.stage_priorities(priorities)
await self._commit()
return SiteMutationResult(True)
async def delete(self, site_id: int) -> SiteMutationResult:
"""删除站点,并确保删除事件只在提交成功后发送。"""
await self._repository.stage_delete(site_id)
await self._commit()
await self._publish_deleted({"site_id": site_id})
return SiteMutationResult(True)
async def _commit(self) -> None:
"""提交当前站点事务,失败时回滚并保留原始异常。"""
try:
await self._unit_of_work.commit()
except Exception:
await self._unit_of_work.rollback()
raise
+7 -7
View File
@@ -1,6 +1,6 @@
from typing import List, Optional
from app import schemas
from app.schemas.system import StorageConf as _SchemaStorageConf
from app.db.oper.systemconfig import SystemConfigOper
from app.schemas.types import SystemConfigKey
@@ -11,16 +11,16 @@ class StorageHelper:
"""
@staticmethod
def get_storagies() -> List[schemas.StorageConf]:
def get_storagies() -> List[_SchemaStorageConf]:
"""
获取所有存储设置
"""
storage_confs: List[dict] = SystemConfigOper().get(SystemConfigKey.Storages)
if not storage_confs:
return []
return [schemas.StorageConf(**s) for s in storage_confs]
return [_SchemaStorageConf(**s) for s in storage_confs]
def get_storage(self, storage: str) -> Optional[schemas.StorageConf]:
def get_storage(self, storage: str) -> Optional[_SchemaStorageConf]:
"""
获取指定存储配置
"""
@@ -37,7 +37,7 @@ class StorageHelper:
storagies = self.get_storagies()
if not storagies:
storagies = [
schemas.StorageConf(
_SchemaStorageConf(
type=storage,
config=conf
)
@@ -56,14 +56,14 @@ class StorageHelper:
storagies = self.get_storagies()
if not storagies:
storagies = [
schemas.StorageConf(
_SchemaStorageConf(
type=storage,
name=name,
config=conf
)
]
else:
storagies.append(schemas.StorageConf(
storagies.append(_SchemaStorageConf(
type=storage,
name=name,
config=conf
+1
View File
@@ -0,0 +1 @@
"""订阅应用契约与写用例。"""
+67
View File
@@ -0,0 +1,67 @@
"""订阅编排共享的媒体元数据与身份契约。"""
from typing import Optional, Protocol, Union
from app.domain.meta.metabase import MetaBase
from app.domain.meta.metamusic import MetaMusic
from app.domain.metainfo import MetaInfo
from app.schemas.media import build_media_key, resolve_media_identity
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaType
class SubscribeSnapshot(Protocol):
"""构造订阅媒体契约所需的最小只读字段集合。"""
name: str
type: str
year: Optional[str]
season: Optional[int]
media_source: object
media_id: object
music_type: Optional[str]
total_tracks: Optional[int]
def build_subscribe_meta(subscribe: SubscribeSnapshot) -> MetaBase:
"""按订阅快照构造主程序链路共用的媒体元数据。"""
if subscribe.type == MediaType.MUSIC.value:
is_album = getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM
return MetaMusic(
title=subscribe.name,
album=subscribe.name if is_album else None,
year=subscribe.year,
total_tracks=(
getattr(subscribe, "total_tracks", None) if is_album else None
),
media_source=subscribe.media_source,
media_id=(
str(subscribe.media_id)
if subscribe.media_id is not None
else None
),
)
meta = MetaInfo(subscribe.name)
meta.year = subscribe.year
meta.begin_season = subscribe.season
meta.type = MediaType(subscribe.type)
meta.media_source = subscribe.media_source
meta.media_id = subscribe.media_id
return meta
def subscribe_media_key(
subscribe: SubscribeSnapshot,
) -> Union[str, int, None]:
"""返回订阅缺失集映射使用的稳定媒体键。"""
media_source, media_id = resolve_media_identity(media=subscribe)
return build_media_key(media_source, media_id) or media_id
def subscribe_media_keys(subscribe: SubscribeSnapshot) -> list[Union[str, int]]:
"""返回缺失集缓存可识别的规范媒体键与旧纯 ID 键。"""
media_source, media_id = resolve_media_identity(media=subscribe)
candidates = [
build_media_key(media_source, media_id),
media_id,
]
return [candidate for candidate in candidates if candidate not in (None, "")]
+117
View File
@@ -0,0 +1,117 @@
"""订阅删除应用用例及其依赖端口。"""
from dataclasses import dataclass
from typing import Awaitable, Callable, Mapping, Protocol
@dataclass(frozen=True)
class SubscribeDeletionActor:
"""执行订阅删除的用户身份。"""
username: str
is_superuser: bool
@dataclass(frozen=True)
class SubscribeDeletionCandidate:
"""删除前读取出的订阅快照,不向应用层暴露 ORM 对象。"""
subscribe_id: int
username: str | None
event_payload: Mapping[str, object]
class SubscribeDeletionRepository(Protocol):
"""订阅删除用例需要的最小数据访问端口。"""
async def get_candidate(
self,
subscribe_id: int,
) -> SubscribeDeletionCandidate | None:
"""读取订阅及删除事件所需的稳定快照。"""
...
async def stage_delete(self, subscribe_id: int) -> None:
"""把已读取的订阅登记为待删除,但不自行提交事务。"""
...
class AsyncUnitOfWork(Protocol):
"""订阅写用例使用的异步事务端口。"""
async def commit(self) -> None:
"""提交当前事务。"""
...
async def rollback(self) -> None:
"""回滚当前事务。"""
...
SubscribeDeletedPublisher = Callable[
[int, Mapping[str, object]],
Awaitable[None],
]
SubscribeDeletedReporter = Callable[[Mapping[str, object]], object]
class DeleteSubscribeCommand:
"""按权限删除订阅,并在提交成功后依次发送事件和统计上报。"""
def __init__(
self,
repository: SubscribeDeletionRepository,
unit_of_work: AsyncUnitOfWork,
publish_deleted: SubscribeDeletedPublisher,
report_deleted: SubscribeDeletedReporter,
) -> None:
"""注入数据访问、事务与提交后副作用端口。"""
self._repository = repository
self._unit_of_work = unit_of_work
self._publish_deleted = publish_deleted
self._report_deleted = report_deleted
async def execute(
self,
subscribe_id: int,
actor: SubscribeDeletionActor,
) -> bool:
"""
删除当前用户可访问的订阅
返回 False 表示订阅不存在或无权访问该结果由 API 映射为历史兼容的成功响应
提交后的事件与上报保持原有顺序任一副作用失败都会继续向调用方抛出
"""
candidate = await self._repository.get_candidate(subscribe_id)
if not self._can_delete(candidate, actor):
return False
await self._repository.stage_delete(subscribe_id)
try:
await self._unit_of_work.commit()
except Exception:
await self._unit_of_work.rollback()
raise
event_payload = dict(candidate.event_payload)
await self._publish_deleted(subscribe_id, event_payload)
self._report_deleted(
{
"media_source": event_payload.get("media_source"),
"media_id": event_payload.get("media_id"),
"season": event_payload.get("season"),
}
)
return True
@staticmethod
def _can_delete(
candidate: SubscribeDeletionCandidate | None,
actor: SubscribeDeletionActor,
) -> bool:
"""判断用户是否拥有目标订阅的删除权限。"""
if candidate is None:
return False
if actor.is_superuser:
return True
return bool(candidate.username) and candidate.username == actor.username
+98
View File
@@ -0,0 +1,98 @@
"""按媒体身份批量删除订阅的应用用例。"""
from typing import Callable, Protocol
from app.application.subscription.delete import (
AsyncUnitOfWork,
SubscribeDeletedPublisher,
SubscribeDeletionActor,
SubscribeDeletionCandidate,
)
from app.schemas.types import MediaSource
class SubscribeIdentityDeletionRepository(Protocol):
"""按媒体身份删除订阅所需的数据访问端口。"""
async def list_candidates_by_identity(
self,
media_source: MediaSource,
media_id: str,
season: int | None,
music_type: str | None,
) -> list[SubscribeDeletionCandidate]:
"""读取匹配媒体身份的去重订阅快照。"""
...
async def delete(self, subscribe_id: int) -> None:
"""把指定订阅登记为待删除。"""
...
SubscribeDeletionEventErrorHandler = Callable[[int, Exception], None]
class DeleteSubscriptionsByIdentityCommand:
"""按媒体身份删除当前用户可访问的全部订阅。"""
def __init__(
self,
repository: SubscribeIdentityDeletionRepository,
unit_of_work: AsyncUnitOfWork,
publish_deleted: SubscribeDeletedPublisher,
handle_event_error: SubscribeDeletionEventErrorHandler,
) -> None:
"""注入数据访问、事务、事件和事件错误处理端口。"""
self._repository = repository
self._unit_of_work = unit_of_work
self._publish_deleted = publish_deleted
self._handle_event_error = handle_event_error
async def execute(
self,
media_source: MediaSource,
media_id: str,
season: int | None,
music_type: str | None,
actor: SubscribeDeletionActor,
) -> int:
"""删除匹配订阅,并在提交后逐条发送兼容事件。"""
candidates = await self._repository.list_candidates_by_identity(
media_source,
media_id,
season,
music_type,
)
deletions = [
candidate
for candidate in candidates
if self._can_delete(candidate, actor)
]
for candidate in deletions:
await self._repository.stage_delete(candidate.subscribe_id)
try:
await self._unit_of_work.commit()
except Exception:
await self._unit_of_work.rollback()
raise
for candidate in deletions:
try:
await self._publish_deleted(
candidate.subscribe_id,
dict(candidate.event_payload),
)
except Exception as error:
self._handle_event_error(candidate.subscribe_id, error)
return len(deletions)
@staticmethod
def _can_delete(
candidate: SubscribeDeletionCandidate,
actor: SubscribeDeletionActor,
) -> bool:
"""判断用户是否拥有候选订阅的删除权限。"""
if actor.is_superuser:
return True
return bool(candidate.username) and candidate.username == actor.username
+76
View File
@@ -0,0 +1,76 @@
"""订阅存在性、来源定位和类型状态查询应用服务。"""
from __future__ import annotations
from typing import Any, Optional, Protocol
from app.domain.context import MediaInfo
from app.domain.meta.metabase import MetaBase
from app.schemas.media import resolve_media_identity
from app.schemas.types import MediaType
class SubscriptionQueryRepository(Protocol):
"""描述订阅查询切片所需的最小仓储能力。"""
def exists(self, **identity: Any) -> bool:
"""按完整订阅身份判断记录是否存在。"""
...
def get_by(self, **identity: Any) -> Optional[Any]:
"""按来源关键字中的订阅身份读取单条记录。"""
...
def list(self, state: Optional[str] = None) -> list[Any]:
"""按可选状态集合读取订阅记录。"""
...
class SubscriptionQueryService:
"""封装不修改订阅状态的三个公开查询用例。"""
_SOURCE_FIELDS = {
"type",
"season",
"media_source",
"media_id",
"music_type",
}
def __init__(self, repository: SubscriptionQueryRepository) -> None:
"""保存订阅查询仓储端口。"""
self._repository = repository
def exists(
self,
mediainfo: MediaInfo,
meta: Optional[MetaBase] = None,
) -> bool:
"""按媒体身份、季、剧集组和音乐实体类型判断订阅是否存在。"""
media_source, media_id = resolve_media_identity(media=mediainfo)
return bool(self._repository.exists(
media_source=media_source,
media_id=media_id,
music_type=getattr(mediainfo, "music_type", None)
if mediainfo.type == MediaType.MUSIC else None,
season=meta.begin_season if meta else None,
episode_group=mediainfo.episode_group,
))
def get_by_source(self, source_keyword: Optional[dict]) -> Optional[Any]:
"""从已解析来源关键字筛出稳定身份字段并读取订阅。"""
if not source_keyword:
return None
identity = {
key: value
for key, value in source_keyword.items()
if key in self._SOURCE_FIELDS
}
return self._repository.get_by(**identity)
def has_music(self, searchable_states: str) -> bool:
"""判断给定可搜索状态内是否至少存在一个音乐订阅。"""
return any(
subscribe.type == MediaType.MUSIC.value
for subscribe in self._repository.list(searchable_states) or []
)
+82
View File
@@ -0,0 +1,82 @@
"""手工订阅搜索应用用例。"""
from dataclasses import dataclass
from typing import Callable, Protocol
from app.application.subscription.delete import SubscribeDeletionCandidate
@dataclass(frozen=True, slots=True)
class SubscribeSearchActor:
"""执行手工订阅搜索的用户身份。"""
username: str
is_superuser: bool
class SubscribeSearchRepository(Protocol):
"""手工订阅搜索所需的最小读取端口。"""
async def get_candidate(
self,
subscribe_id: int,
) -> SubscribeDeletionCandidate | None:
"""读取单条订阅的归属信息。"""
...
async def list_search_ids(self, username: str, state: str) -> list[int]:
"""返回用户当前可搜索状态下的订阅编号。"""
...
SubscribeSearchScheduler = Callable[[int | None, str | None], None]
class SearchSubscriptionsCommand:
"""按用户权限生成并提交手工订阅搜索任务。"""
def __init__(
self,
repository: SubscribeSearchRepository,
schedule_search: SubscribeSearchScheduler,
) -> None:
"""注入订阅读取端口和后台任务提交端口。"""
self._repository = repository
self._schedule_search = schedule_search
async def execute(
self,
actor: SubscribeSearchActor,
subscribe_id: int | None = None,
) -> bool:
"""提交单条或当前用户全部可搜索订阅,返回目标是否存在。"""
if subscribe_id is not None:
candidate = await self._repository.get_candidate(subscribe_id)
if not self._can_access(candidate, actor):
return False
self._schedule_search(subscribe_id, None)
return True
if actor.is_superuser:
self._schedule_search(None, "R")
return True
subscribe_ids = await self._repository.list_search_ids(
actor.username,
"R",
)
for current_id in subscribe_ids:
self._schedule_search(current_id, None)
return True
@staticmethod
def _can_access(
candidate: SubscribeDeletionCandidate | None,
actor: SubscribeSearchActor,
) -> bool:
"""沿用订阅读取接口的超级用户和归属用户权限语义。"""
if candidate is None:
return False
if actor.is_superuser:
return True
return bool(candidate.username) and candidate.username == actor.username
+56 -11
View File
@@ -22,7 +22,10 @@ from typing import Callable, Dict, List, Optional, Tuple, Union
from pydantic import BaseModel, ConfigDict
from app import schemas
from app.schemas.transfer import MetaInfo as _SchemaMetaInfo
from app.schemas.transfer import MusicInfo as _SchemaMusicInfo
from app.schemas.transfer import MusicMeta as _SchemaMusicMeta
from app.schemas.workflow import MediaInfo as _SchemaMediaInfo
from app.adapters.system.host import SystemUtils
from app.application.agent import get_prompt_manager, get_running_agent_manager
from app.domain.context import MediaInfo, MusicInfo
@@ -31,7 +34,6 @@ from app.domain.meta.metabase import MetaBase
from app.domain.meta.metamusic import MetaMusic
from app.foundation import text as text_tools
from app.runtime.log import logger
from app.schemas.agent import ReplyMode
from app.schemas.file import FileItem
from app.schemas.history import DownloadHistory
from app.schemas.media import OptionalMediaIdentityMixin, resolve_media_identity
@@ -43,6 +45,7 @@ from app.schemas.types import (
MUSIC_ENTITY_RECORDING,
MediaSource,
MediaType,
ReplyMode,
)
@@ -109,6 +112,49 @@ class TransferQueue(BaseModel):
result: Optional[TransferInfo] = None
class TransferQueueService:
"""协调整理任务登记、入队、移除和队列视图查询。"""
def __init__(
self,
*,
register_task: Callable[[TransferTask], bool],
enqueue: Callable[[TransferQueue], None],
before_enqueue: Callable[[TransferTask], None],
after_enqueue: Callable[[TransferTask], None],
remove_task: Callable[[FileItem], None],
list_tasks: Callable[[], List[TransferJob]],
expire_tasks: Callable[[], None],
) -> None:
"""保存队列用例依赖,避免 Application 服务绑定具体线程队列实现。"""
self._register_task = register_task
self._enqueue = enqueue
self._before_enqueue = before_enqueue
self._after_enqueue = after_enqueue
self._remove_task = remove_task
self._list_tasks = list_tasks
self._expire_tasks = expire_tasks
def put(self, task: TransferTask, callback: Callable) -> bool:
"""登记并入队一个整理任务,保持原有副作用顺序。"""
if not task or not self._register_task(task):
return False
self._before_enqueue(task)
self._enqueue(TransferQueue(task=task, callback=callback))
self._after_enqueue(task)
return True
def remove(self, fileitem: FileItem) -> None:
"""从整理任务视图移除指定文件。"""
if fileitem:
self._remove_task(fileitem)
def list(self) -> List[TransferJob]:
"""先处理失活任务,再返回当前整理作业视图。"""
self._expire_tasks()
return self._list_tasks()
# 作业锁:JobManager 与 TransferChain 共享,保护整理作业视图。
job_lock = threading.Lock()
@@ -214,7 +260,7 @@ class JobManager:
return self.__get_id(task)
@staticmethod
def __get_media(task: TransferTask) -> Union[schemas.MediaInfo, schemas.MusicInfo]:
def __get_media(task: TransferTask) -> Union[_SchemaMediaInfo, _SchemaMusicInfo]:
"""
获取媒体信息
"""
@@ -223,15 +269,15 @@ class JobManager:
mediainfo = deepcopy(task.mediainfo)
mediainfo.clear()
if isinstance(mediainfo, MusicInfo):
return schemas.MusicInfo(**mediainfo.to_dict())
return schemas.MediaInfo(**mediainfo.to_dict())
return _SchemaMusicInfo(**mediainfo.to_dict())
return _SchemaMediaInfo(**mediainfo.to_dict())
else:
# 没有媒体信息
meta: MetaBase = task.meta
if isinstance(meta, MetaMusic):
# 未识别的音乐按已解析元数据兜底展示;音乐年份为 int,
# 不能复用 MediaInfoyear 为 str),否则触发 pydantic 校验异常
return schemas.MusicInfo(
return _SchemaMusicInfo(
title=meta.name,
artists=list(meta.artists or []),
artist=meta.artist,
@@ -242,7 +288,7 @@ class JobManager:
media_source=meta.media_source,
media_id=meta.media_id,
)
return schemas.MediaInfo(
return _SchemaMediaInfo(
title=meta.name,
year=meta.year,
title_year=f"{meta.name} ({meta.year})",
@@ -250,13 +296,13 @@ class JobManager:
)
@staticmethod
def __get_meta(task: TransferTask) -> schemas.MetaInfo:
def __get_meta(task: TransferTask) -> _SchemaMetaInfo:
"""
获取元数据
"""
if isinstance(task.meta, MetaMusic):
return schemas.MusicMeta(**task.meta.to_dict())
return schemas.MetaInfo(**task.meta.to_dict())
return _SchemaMusicMeta(**task.meta.to_dict())
return _SchemaMetaInfo(**task.meta.to_dict())
def add_task(self, task: TransferTask, state: Optional[str] = "waiting") -> bool:
"""
@@ -975,4 +1021,3 @@ class FailedRetryScheduler:
logger.error(
f"智能体重试整理失败 (IDs=[{ids_str}], group={group_key}): {err}"
)
+302
View File
@@ -0,0 +1,302 @@
"""工作流状态与定义写操作应用用例。"""
from dataclasses import dataclass
import json
from collections.abc import Awaitable
from datetime import datetime
from typing import Any, Callable, Mapping, Optional, Protocol
WORKFLOW_TRIGGER_TIMER = "timer"
WORKFLOW_TRIGGER_EVENT = "event"
WORKFLOW_TRIGGER_MANUAL = "manual"
SUPPORTED_WORKFLOW_TRIGGERS = {
WORKFLOW_TRIGGER_TIMER,
WORKFLOW_TRIGGER_EVENT,
WORKFLOW_TRIGGER_MANUAL,
}
@dataclass(frozen=True, slots=True)
class WorkflowMutationResult:
"""描述工作流写操作是否成功及兼容提示信息。"""
success: bool
message: str = ""
class WorkflowMutationRepository(Protocol):
"""工作流写用例需要的最小持久化端口。"""
def get(self, workflow_id: int) -> Optional[Any]:
"""读取工作流。"""
...
def stage_state(self, workflow_id: int, state: str) -> bool:
"""暂存工作流状态变更。"""
...
def stage_update(self, workflow_id: int, payload: Mapping[str, Any]) -> Optional[Any]:
"""暂存工作流定义更新并返回更新后的对象。"""
...
def stage_delete(self, workflow_id: int) -> None:
"""暂存工作流删除。"""
...
class UnitOfWork(Protocol):
"""同步工作流写用例使用的事务端口。"""
def commit(self) -> None:
"""提交当前事务。"""
...
def rollback(self) -> None:
"""回滚当前事务。"""
...
class WorkflowMutationCommand:
"""协调工作流状态、定义、调度和事件注册变更。"""
def __init__(
self,
*,
repository: WorkflowMutationRepository,
unit_of_work: UnitOfWork,
add_timer: Callable[[Any], None],
remove_timer: Callable[[Any], None],
load_event: Callable[[int], None],
remove_event: Callable[[int, Optional[str]], None],
refresh_event: Callable[[Any], None],
stop_running: Callable[[int], None],
delete_cache: Callable[[int], None],
) -> None:
"""保存工作流事务和提交后运行时副作用端口。"""
self._repository = repository
self._unit_of_work = unit_of_work
self._add_timer = add_timer
self._remove_timer = remove_timer
self._load_event = load_event
self._remove_event = remove_event
self._refresh_event = refresh_event
self._stop_running = stop_running
self._delete_cache = delete_cache
def start(self, workflow_id: int) -> WorkflowMutationResult:
"""启用工作流,并在提交后登记定时器或事件触发器。"""
workflow = self._repository.get(workflow_id)
if not workflow:
return WorkflowMutationResult(False, "工作流不存在")
trigger_type = workflow.trigger_type or WORKFLOW_TRIGGER_TIMER
if trigger_type == WORKFLOW_TRIGGER_TIMER and not workflow.timer:
return WorkflowMutationResult(False, "定时工作流缺少定时器配置")
if trigger_type not in SUPPORTED_WORKFLOW_TRIGGERS:
return WorkflowMutationResult(False, "工作流触发类型不支持")
self._repository.stage_state(workflow_id, "W")
self._commit()
if trigger_type == WORKFLOW_TRIGGER_TIMER:
self._add_timer(workflow)
elif trigger_type == WORKFLOW_TRIGGER_EVENT:
self._load_event(workflow_id)
return WorkflowMutationResult(True)
def pause(self, workflow_id: int) -> WorkflowMutationResult:
"""停用工作流,并在提交后移除运行时触发器和执行状态。"""
workflow = self._repository.get(workflow_id)
if not workflow:
return WorkflowMutationResult(False, "工作流不存在")
self._repository.stage_state(workflow_id, "P")
self._commit()
if workflow.trigger_type == WORKFLOW_TRIGGER_TIMER:
self._remove_timer(workflow)
elif workflow.trigger_type == WORKFLOW_TRIGGER_EVENT:
self._remove_event(workflow_id, workflow.event_type)
self._stop_running(workflow_id)
return WorkflowMutationResult(True)
def update(self, payload: Mapping[str, Any]) -> WorkflowMutationResult:
"""更新工作流定义,并在提交后刷新调度器和事件注册。"""
values = dict(payload)
workflow_id = values.get("id")
if not workflow_id:
return WorkflowMutationResult(False, "工作流ID不能为空")
current = self._repository.get(workflow_id)
if not current:
return WorkflowMutationResult(False, "工作流不存在")
if not current.trigger_type:
values["trigger_type"] = WORKFLOW_TRIGGER_TIMER
updated = self._repository.stage_update(workflow_id, values)
self._commit()
self._remove_timer(updated)
if (
not updated.trigger_type
or updated.trigger_type == WORKFLOW_TRIGGER_TIMER
) and updated.timer:
self._add_timer(updated)
self._refresh_event(updated)
return WorkflowMutationResult(True, "更新成功")
def delete(self, workflow_id: int) -> WorkflowMutationResult:
"""删除工作流,并在提交后清除缓存和运行时触发器。"""
workflow = self._repository.get(workflow_id)
if not workflow:
return WorkflowMutationResult(False, "工作流不存在")
self._repository.stage_delete(workflow_id)
self._commit()
self._delete_cache(workflow_id)
if not workflow.trigger_type or workflow.trigger_type == WORKFLOW_TRIGGER_TIMER:
self._remove_timer(workflow)
elif workflow.trigger_type == WORKFLOW_TRIGGER_EVENT:
self._remove_event(workflow_id, workflow.event_type)
return WorkflowMutationResult(True, "删除成功")
def _commit(self) -> None:
"""提交工作流事务,失败时回滚且不执行后续运行时副作用。"""
try:
self._unit_of_work.commit()
except Exception:
self._unit_of_work.rollback()
raise
class AsyncWorkflowDefinitionRepository(Protocol):
"""工作流创建、复用和重置需要的异步持久化端口。"""
async def async_get_by_name(self, name: str) -> Optional[Any]:
"""按名称读取工作流。"""
...
async def stage_create(self, payload: Mapping[str, Any]) -> Any:
"""暂存新工作流。"""
...
async def stage_reset(self, workflow_id: int, reset_count: bool = False) -> Optional[Any]:
"""暂存工作流重置。"""
...
async def async_get(self, workflow_id: int) -> Optional[Any]:
"""读取指定工作流。"""
...
class AsyncUnitOfWork(Protocol):
"""异步工作流定义用例使用的事务端口。"""
async def commit(self) -> None:
"""提交当前事务。"""
...
async def rollback(self) -> None:
"""回滚当前事务。"""
...
class WorkflowDefinitionCommand:
"""协调工作流创建、分享复用和重置的异步写用例。"""
def __init__(
self,
*,
repository: AsyncWorkflowDefinitionRepository,
unit_of_work: AsyncUnitOfWork,
stop_running: Callable[[int], None],
delete_cache: Callable[[int], None],
report_fork: Optional[Callable[[int], Awaitable[object]]] = None,
) -> None:
"""保存异步事务和提交后运行时副作用端口。"""
self._repository = repository
self._unit_of_work = unit_of_work
self._stop_running = stop_running
self._delete_cache = delete_cache
self._report_fork = report_fork
async def create(self, payload: Mapping[str, Any]) -> WorkflowMutationResult:
"""校验名称并暂存新工作流,提交失败时不产生运行时副作用。"""
values = dict(payload)
name = values.get("name")
if name and await self._repository.async_get_by_name(name):
return WorkflowMutationResult(False, "已存在相同名称的工作流")
if not values.get("add_time"):
values["add_time"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if not values.get("state"):
values["state"] = "P"
if not values.get("trigger_type"):
values["trigger_type"] = WORKFLOW_TRIGGER_TIMER
try:
await self._repository.stage_create(values)
await self._commit()
except Exception:
raise
return WorkflowMutationResult(True, "创建工作流成功")
async def fork(
self,
payload: Mapping[str, Any],
share_id: Optional[int] = None,
) -> WorkflowMutationResult:
"""解析共享工作流内容并在提交后更新远程复用次数。"""
values = dict(payload)
if not values.get("name"):
return WorkflowMutationResult(False, "工作流名称不能为空")
parsed = {}
for field, default, error_message in (
("actions", "[]", "actions字段JSON格式错误"),
("flows", "[]", "flows字段JSON格式错误"),
("context", "{}", "context字段JSON格式错误"),
("event_conditions", "{}", "event_conditions字段JSON格式错误"),
):
raw = values.get(field)
try:
parsed[field] = json.loads(raw or default)
except json.JSONDecodeError:
return WorkflowMutationResult(False, error_message)
workflow_values = {
"name": values["name"],
"description": values.get("description"),
"timer": values.get("timer"),
"trigger_type": values.get("trigger_type") or WORKFLOW_TRIGGER_TIMER,
"event_type": values.get("event_type"),
"event_conditions": parsed["event_conditions"],
"actions": parsed["actions"],
"flows": parsed["flows"],
"context": parsed["context"],
"state": "P",
}
if await self._repository.async_get_by_name(workflow_values["name"]):
return WorkflowMutationResult(False, "已存在相同名称的工作流")
try:
created = await self._repository.stage_create(workflow_values)
await self._commit()
except Exception:
raise
if created and share_id and self._report_fork:
try:
await self._report_fork(share_id)
except Exception:
return WorkflowMutationResult(True, "复用成功;共享统计上报失败")
return WorkflowMutationResult(True, "复用成功")
async def reset(self, workflow_id: int) -> WorkflowMutationResult:
"""重置工作流并在提交后停止运行态、清除缓存。"""
workflow = await self._repository.async_get(workflow_id)
if not workflow:
return WorkflowMutationResult(False, "工作流不存在")
await self._repository.stage_reset(workflow_id, reset_count=True)
await self._commit()
self._stop_running(workflow_id)
self._delete_cache(workflow_id)
return WorkflowMutationResult(True)
async def _commit(self) -> None:
"""提交异步事务,失败时回滚且不继续执行运行时副作用。"""
try:
await self._unit_of_work.commit()
except Exception:
await self._unit_of_work.rollback()
raise