mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-31 21:17:06 +08:00
Merge pull request #6485 from InfinityPacer/codex/feat/plugin-data-query-sdk-v3
feat: 提供插件统一订阅与历史查询 SDK
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
"""插件只读数据查询的应用服务与领域端口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
from typing import Any, Callable, Generic, Protocol, TypeVar, cast
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.application.database import AsyncDatabaseExecutor
|
||||
from app.schemas.query import (
|
||||
DownloadHistoryFilter,
|
||||
DownloadHistorySnapshot,
|
||||
QueryPage,
|
||||
QueryPageRequest,
|
||||
SubscriptionFilter,
|
||||
SubscriptionHistoryFilter,
|
||||
SubscriptionHistorySnapshot,
|
||||
SubscriptionSnapshot,
|
||||
TransferHistoryFilter,
|
||||
TransferHistorySnapshot,
|
||||
)
|
||||
|
||||
RecordT = TypeVar("RecordT", covariant=True)
|
||||
DtoT = TypeVar("DtoT", bound=BaseModel)
|
||||
ResultT = TypeVar("ResultT")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class QueryRows(Generic[RecordT]):
|
||||
"""查询端口返回的短生命周期结果,不跨越应用服务边界。"""
|
||||
|
||||
items: Sequence[RecordT]
|
||||
total: int
|
||||
|
||||
|
||||
class SubscriptionQueryPort(Protocol):
|
||||
"""订阅领域查询端口,包含订阅和订阅完成历史两个只读切片。"""
|
||||
|
||||
def list_subscriptions(
|
||||
self,
|
||||
*,
|
||||
filters: SubscriptionFilter,
|
||||
page: QueryPageRequest,
|
||||
) -> QueryRows[object]:
|
||||
"""按组合筛选和稳定分页读取订阅记录。"""
|
||||
...
|
||||
|
||||
def get_subscription(self, subscription_id: int) -> object | None:
|
||||
"""按 ID 读取一条订阅记录。"""
|
||||
...
|
||||
|
||||
def list_subscription_history(
|
||||
self,
|
||||
*,
|
||||
filters: SubscriptionHistoryFilter,
|
||||
page: QueryPageRequest,
|
||||
) -> QueryRows[object]:
|
||||
"""按组合筛选和稳定分页读取订阅完成历史。"""
|
||||
...
|
||||
|
||||
def get_subscription_history(self, history_id: int) -> object | None:
|
||||
"""按 ID 读取一条订阅完成历史。"""
|
||||
...
|
||||
|
||||
|
||||
class HistoryQueryPort(Protocol):
|
||||
"""下载与整理历史查询端口。"""
|
||||
|
||||
def list_download_history(
|
||||
self,
|
||||
*,
|
||||
filters: DownloadHistoryFilter,
|
||||
page: QueryPageRequest,
|
||||
) -> QueryRows[object]:
|
||||
"""按组合筛选和稳定分页读取下载历史。"""
|
||||
...
|
||||
|
||||
def get_download_history(self, history_id: int) -> object | None:
|
||||
"""按 ID 读取一条下载历史。"""
|
||||
...
|
||||
|
||||
def list_transfer_history(
|
||||
self,
|
||||
*,
|
||||
filters: TransferHistoryFilter,
|
||||
page: QueryPageRequest,
|
||||
) -> QueryRows[object]:
|
||||
"""按组合筛选和稳定分页读取整理历史。"""
|
||||
...
|
||||
|
||||
def get_transfer_history(self, history_id: int) -> object | None:
|
||||
"""按 ID 读取一条整理历史。"""
|
||||
...
|
||||
|
||||
|
||||
class DataQueryService:
|
||||
"""把订阅和历史查询统一投影为不含持久化实现的 DTO。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
subscriptions: SubscriptionQueryPort,
|
||||
histories: HistoryQueryPort,
|
||||
async_executor: AsyncDatabaseExecutor,
|
||||
) -> None:
|
||||
"""保存两个领域查询端口和异步数据库执行边界。"""
|
||||
self._subscriptions = subscriptions
|
||||
self._histories = histories
|
||||
self._async_executor = async_executor
|
||||
|
||||
@staticmethod
|
||||
def _page_request(page: QueryPageRequest | dict[str, Any] | None) -> QueryPageRequest:
|
||||
"""把调用方输入规范化为有界分页合同。"""
|
||||
if page is None:
|
||||
return QueryPageRequest()
|
||||
if isinstance(page, QueryPageRequest):
|
||||
return page
|
||||
return cast(QueryPageRequest, QueryPageRequest.model_validate(page))
|
||||
|
||||
@staticmethod
|
||||
def _filter(
|
||||
value: Any,
|
||||
filter_type: type[SubscriptionFilter]
|
||||
| type[SubscriptionHistoryFilter]
|
||||
| type[DownloadHistoryFilter]
|
||||
| type[TransferHistoryFilter],
|
||||
) -> Any:
|
||||
"""把字典筛选条件转换为带媒体身份校验的模型。"""
|
||||
if value is None:
|
||||
return filter_type()
|
||||
if isinstance(value, filter_type):
|
||||
return value
|
||||
return filter_type.model_validate(value)
|
||||
|
||||
@staticmethod
|
||||
def _rows(value: QueryRows[object]) -> QueryRows[object]:
|
||||
"""校验端口结果的总数,避免负数污染分页合同。"""
|
||||
if not isinstance(value, QueryRows):
|
||||
raise TypeError("查询端口必须返回 QueryRows")
|
||||
return QueryRows(
|
||||
items=value.items,
|
||||
total=max(int(value.total), 0),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _to_page(
|
||||
cls,
|
||||
dto_type: type[DtoT],
|
||||
page: QueryPageRequest,
|
||||
rows: QueryRows[object],
|
||||
) -> QueryPage[DtoT]:
|
||||
"""在查询端口结果离开应用层前完成 DTO 投影。"""
|
||||
normalized_rows = cls._rows(rows)
|
||||
return cast(
|
||||
QueryPage[DtoT],
|
||||
QueryPage(
|
||||
items=[dto_type.model_validate(item) for item in normalized_rows.items],
|
||||
total=normalized_rows.total,
|
||||
page=page.page,
|
||||
count=page.count,
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _to_item(dto_type: type[DtoT], item: object | None) -> DtoT | None:
|
||||
"""把单条端口记录投影为稳定 DTO。"""
|
||||
return dto_type.model_validate(item) if item is not None else None
|
||||
|
||||
async def _async_run(self, operation: Callable[[], ResultT]) -> ResultT:
|
||||
"""通过统一数据库执行器运行一个同步查询用例。"""
|
||||
return cast(ResultT, await self._async_executor.run(operation))
|
||||
|
||||
def list_subscriptions(
|
||||
self,
|
||||
filters: SubscriptionFilter | dict[str, Any] | None = None,
|
||||
page: QueryPageRequest | dict[str, Any] | None = None,
|
||||
) -> QueryPage[SubscriptionSnapshot]:
|
||||
"""同步分页查询订阅。"""
|
||||
normalized_page = self._page_request(page)
|
||||
normalized_filters = self._filter(filters, SubscriptionFilter)
|
||||
rows = self._subscriptions.list_subscriptions(
|
||||
filters=normalized_filters,
|
||||
page=normalized_page,
|
||||
)
|
||||
return self._to_page(SubscriptionSnapshot, normalized_page, rows)
|
||||
|
||||
async def async_list_subscriptions(
|
||||
self,
|
||||
filters: SubscriptionFilter | dict[str, Any] | None = None,
|
||||
page: QueryPageRequest | dict[str, Any] | None = None,
|
||||
) -> QueryPage[SubscriptionSnapshot]:
|
||||
"""异步分页查询订阅,业务规则在数据库 worker 中复用同步入口。"""
|
||||
return await self._async_run(partial(self.list_subscriptions, filters, page))
|
||||
|
||||
def get_subscription(self, subscription_id: int) -> SubscriptionSnapshot | None:
|
||||
"""同步按 ID 查询订阅。"""
|
||||
return self._to_item(
|
||||
SubscriptionSnapshot,
|
||||
self._subscriptions.get_subscription(subscription_id),
|
||||
)
|
||||
|
||||
async def async_get_subscription(
|
||||
self,
|
||||
subscription_id: int,
|
||||
) -> SubscriptionSnapshot | None:
|
||||
"""异步按 ID 查询订阅。"""
|
||||
return await self._async_run(partial(self.get_subscription, subscription_id))
|
||||
|
||||
def list_subscription_history(
|
||||
self,
|
||||
filters: SubscriptionHistoryFilter | dict[str, Any] | None = None,
|
||||
page: QueryPageRequest | dict[str, Any] | None = None,
|
||||
) -> QueryPage[SubscriptionHistorySnapshot]:
|
||||
"""同步分页查询订阅完成历史。"""
|
||||
normalized_page = self._page_request(page)
|
||||
normalized_filters = self._filter(filters, SubscriptionHistoryFilter)
|
||||
rows = self._subscriptions.list_subscription_history(
|
||||
filters=normalized_filters,
|
||||
page=normalized_page,
|
||||
)
|
||||
return self._to_page(SubscriptionHistorySnapshot, normalized_page, rows)
|
||||
|
||||
async def async_list_subscription_history(
|
||||
self,
|
||||
filters: SubscriptionHistoryFilter | dict[str, Any] | None = None,
|
||||
page: QueryPageRequest | dict[str, Any] | None = None,
|
||||
) -> QueryPage[SubscriptionHistorySnapshot]:
|
||||
"""异步分页查询订阅完成历史。"""
|
||||
return await self._async_run(partial(self.list_subscription_history, filters, page))
|
||||
|
||||
def get_subscription_history(
|
||||
self,
|
||||
history_id: int,
|
||||
) -> SubscriptionHistorySnapshot | None:
|
||||
"""同步按 ID 查询订阅完成历史。"""
|
||||
return self._to_item(
|
||||
SubscriptionHistorySnapshot,
|
||||
self._subscriptions.get_subscription_history(history_id),
|
||||
)
|
||||
|
||||
async def async_get_subscription_history(
|
||||
self,
|
||||
history_id: int,
|
||||
) -> SubscriptionHistorySnapshot | None:
|
||||
"""异步按 ID 查询订阅完成历史。"""
|
||||
return await self._async_run(partial(self.get_subscription_history, history_id))
|
||||
|
||||
def list_download_history(
|
||||
self,
|
||||
filters: DownloadHistoryFilter | dict[str, Any] | None = None,
|
||||
page: QueryPageRequest | dict[str, Any] | None = None,
|
||||
) -> QueryPage[DownloadHistorySnapshot]:
|
||||
"""同步分页查询下载历史。"""
|
||||
normalized_page = self._page_request(page)
|
||||
normalized_filters = self._filter(filters, DownloadHistoryFilter)
|
||||
rows = self._histories.list_download_history(
|
||||
filters=normalized_filters,
|
||||
page=normalized_page,
|
||||
)
|
||||
return self._to_page(DownloadHistorySnapshot, normalized_page, rows)
|
||||
|
||||
async def async_list_download_history(
|
||||
self,
|
||||
filters: DownloadHistoryFilter | dict[str, Any] | None = None,
|
||||
page: QueryPageRequest | dict[str, Any] | None = None,
|
||||
) -> QueryPage[DownloadHistorySnapshot]:
|
||||
"""异步分页查询下载历史。"""
|
||||
return await self._async_run(partial(self.list_download_history, filters, page))
|
||||
|
||||
def get_download_history(self, history_id: int) -> DownloadHistorySnapshot | None:
|
||||
"""同步按 ID 查询下载历史。"""
|
||||
return self._to_item(
|
||||
DownloadHistorySnapshot,
|
||||
self._histories.get_download_history(history_id),
|
||||
)
|
||||
|
||||
async def async_get_download_history(
|
||||
self,
|
||||
history_id: int,
|
||||
) -> DownloadHistorySnapshot | None:
|
||||
"""异步按 ID 查询下载历史。"""
|
||||
return await self._async_run(partial(self.get_download_history, history_id))
|
||||
|
||||
def list_transfer_history(
|
||||
self,
|
||||
filters: TransferHistoryFilter | dict[str, Any] | None = None,
|
||||
page: QueryPageRequest | dict[str, Any] | None = None,
|
||||
) -> QueryPage[TransferHistorySnapshot]:
|
||||
"""同步分页查询整理历史。"""
|
||||
normalized_page = self._page_request(page)
|
||||
normalized_filters = self._filter(filters, TransferHistoryFilter)
|
||||
rows = self._histories.list_transfer_history(
|
||||
filters=normalized_filters,
|
||||
page=normalized_page,
|
||||
)
|
||||
return self._to_page(TransferHistorySnapshot, normalized_page, rows)
|
||||
|
||||
async def async_list_transfer_history(
|
||||
self,
|
||||
filters: TransferHistoryFilter | dict[str, Any] | None = None,
|
||||
page: QueryPageRequest | dict[str, Any] | None = None,
|
||||
) -> QueryPage[TransferHistorySnapshot]:
|
||||
"""异步分页查询整理历史。"""
|
||||
return await self._async_run(partial(self.list_transfer_history, filters, page))
|
||||
|
||||
def get_transfer_history(self, history_id: int) -> TransferHistorySnapshot | None:
|
||||
"""同步按 ID 查询整理历史。"""
|
||||
return self._to_item(
|
||||
TransferHistorySnapshot,
|
||||
self._histories.get_transfer_history(history_id),
|
||||
)
|
||||
|
||||
async def async_get_transfer_history(
|
||||
self,
|
||||
history_id: int,
|
||||
) -> TransferHistorySnapshot | None:
|
||||
"""异步按 ID 查询整理历史。"""
|
||||
return await self._async_run(partial(self.get_transfer_history, history_id))
|
||||
|
||||
|
||||
_configured_data_query_service: DataQueryService | None = None
|
||||
|
||||
|
||||
def configure_data_query_service(service: DataQueryService) -> None:
|
||||
"""由启动组合根登记插件数据查询服务。"""
|
||||
global _configured_data_query_service
|
||||
_configured_data_query_service = service
|
||||
|
||||
|
||||
def get_configured_data_query_service() -> DataQueryService:
|
||||
"""返回启动阶段登记的插件数据查询服务。"""
|
||||
if _configured_data_query_service is None:
|
||||
raise RuntimeError("插件数据查询服务尚未配置")
|
||||
return _configured_data_query_service
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DataQueryService",
|
||||
"HistoryQueryPort",
|
||||
"QueryRows",
|
||||
"SubscriptionQueryPort",
|
||||
"configure_data_query_service",
|
||||
"get_configured_data_query_service",
|
||||
]
|
||||
@@ -0,0 +1,140 @@
|
||||
"""插件只读数据查询的持久化端口适配器。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import TypeVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.application.query import QueryRows
|
||||
from app.db.oper.downloadhistory import DownloadHistoryOper
|
||||
from app.db.oper.subscribe import SubscribeOper
|
||||
from app.db.oper.subscribehistory import SubscribeHistoryOper
|
||||
from app.db.oper.transferhistory import TransferHistoryOper
|
||||
from app.schemas.query import (
|
||||
DownloadHistoryFilter,
|
||||
DownloadHistorySnapshot,
|
||||
QueryPageRequest,
|
||||
SubscriptionFilter,
|
||||
SubscriptionHistoryFilter,
|
||||
SubscriptionHistorySnapshot,
|
||||
SubscriptionSnapshot,
|
||||
TransferHistoryFilter,
|
||||
TransferHistorySnapshot,
|
||||
)
|
||||
|
||||
_SnapshotT = TypeVar("_SnapshotT", bound=BaseModel)
|
||||
|
||||
|
||||
class SqlAlchemyDataQueryAdapter:
|
||||
"""以短生命周期 Session 和显式 Oper 实现统一查询端口。
|
||||
|
||||
Oper 拥有表级筛选、排序和分页语义;适配器只管理一次操作使用的 Session,
|
||||
并在 Session 关闭前把持久化记录冻结为稳定 Pydantic 快照。
|
||||
"""
|
||||
|
||||
def __init__(self, session_factory: Callable[[], Session]) -> None:
|
||||
"""保存由启动组合根提供的同步 Session 工厂。"""
|
||||
self._session_factory = session_factory
|
||||
|
||||
@staticmethod
|
||||
def _rows(
|
||||
snapshot_type: type[_SnapshotT],
|
||||
records: Sequence[object],
|
||||
total: int,
|
||||
) -> QueryRows[_SnapshotT]:
|
||||
"""在会话有效期内投影一页记录,并保护非负总数合同。"""
|
||||
return QueryRows(
|
||||
items=[snapshot_type.model_validate(record) for record in records],
|
||||
total=max(int(total), 0),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _item(
|
||||
snapshot_type: type[_SnapshotT],
|
||||
record: object | None,
|
||||
) -> _SnapshotT | None:
|
||||
"""把单条持久化记录投影为稳定快照。"""
|
||||
return snapshot_type.model_validate(record) if record is not None else None
|
||||
|
||||
def list_subscriptions(
|
||||
self,
|
||||
*,
|
||||
filters: SubscriptionFilter,
|
||||
page: QueryPageRequest,
|
||||
) -> QueryRows[SubscriptionSnapshot]:
|
||||
"""通过订阅 Oper 读取并冻结一页当前订阅。"""
|
||||
with self._session_factory() as session:
|
||||
records, total = SubscribeOper(session).query(filters, page)
|
||||
return self._rows(SubscriptionSnapshot, records, total)
|
||||
|
||||
def get_subscription(self, subscription_id: int) -> SubscriptionSnapshot | None:
|
||||
"""通过订阅 Oper 按 ID 读取并冻结当前订阅。"""
|
||||
with self._session_factory() as session:
|
||||
record = SubscribeOper(session).get_by_id(subscription_id)
|
||||
return self._item(SubscriptionSnapshot, record)
|
||||
|
||||
def list_subscription_history(
|
||||
self,
|
||||
*,
|
||||
filters: SubscriptionHistoryFilter,
|
||||
page: QueryPageRequest,
|
||||
) -> QueryRows[SubscriptionHistorySnapshot]:
|
||||
"""通过订阅历史 Oper 读取并冻结一页完成历史。"""
|
||||
with self._session_factory() as session:
|
||||
records, total = SubscribeHistoryOper(session).query(filters, page)
|
||||
return self._rows(SubscriptionHistorySnapshot, records, total)
|
||||
|
||||
def get_subscription_history(
|
||||
self,
|
||||
history_id: int,
|
||||
) -> SubscriptionHistorySnapshot | None:
|
||||
"""通过订阅历史 Oper 按 ID 读取并冻结完成历史。"""
|
||||
with self._session_factory() as session:
|
||||
record = SubscribeHistoryOper(session).get_by_id(history_id)
|
||||
return self._item(SubscriptionHistorySnapshot, record)
|
||||
|
||||
def list_download_history(
|
||||
self,
|
||||
*,
|
||||
filters: DownloadHistoryFilter,
|
||||
page: QueryPageRequest,
|
||||
) -> QueryRows[DownloadHistorySnapshot]:
|
||||
"""通过下载历史 Oper 读取并冻结一页下载记录。"""
|
||||
with self._session_factory() as session:
|
||||
records, total = DownloadHistoryOper(session).query(filters, page)
|
||||
return self._rows(DownloadHistorySnapshot, records, total)
|
||||
|
||||
def get_download_history(
|
||||
self,
|
||||
history_id: int,
|
||||
) -> DownloadHistorySnapshot | None:
|
||||
"""通过下载历史 Oper 按 ID 读取并冻结下载记录。"""
|
||||
with self._session_factory() as session:
|
||||
record = DownloadHistoryOper(session).get_by_id(history_id)
|
||||
return self._item(DownloadHistorySnapshot, record)
|
||||
|
||||
def list_transfer_history(
|
||||
self,
|
||||
*,
|
||||
filters: TransferHistoryFilter,
|
||||
page: QueryPageRequest,
|
||||
) -> QueryRows[TransferHistorySnapshot]:
|
||||
"""通过整理历史 Oper 读取并冻结一页整理记录。"""
|
||||
with self._session_factory() as session:
|
||||
records, total = TransferHistoryOper(session).query(filters, page)
|
||||
return self._rows(TransferHistorySnapshot, records, total)
|
||||
|
||||
def get_transfer_history(
|
||||
self,
|
||||
history_id: int,
|
||||
) -> TransferHistorySnapshot | None:
|
||||
"""通过整理历史 Oper 按 ID 读取并冻结整理记录。"""
|
||||
with self._session_factory() as session:
|
||||
record = TransferHistoryOper(session).get_by_id(history_id)
|
||||
return self._item(TransferHistorySnapshot, record)
|
||||
|
||||
|
||||
__all__ = ["SqlAlchemyDataQueryAdapter"]
|
||||
@@ -1,10 +1,25 @@
|
||||
from typing import Dict, List, Optional, cast
|
||||
|
||||
from sqlalchemy import delete as sqlalchemy_delete, update as sqlalchemy_update
|
||||
from sqlalchemy import delete as sqlalchemy_delete
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import update as sqlalchemy_update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.base import DbOper
|
||||
from app.db.models.downloadhistory import DownloadHistory, DownloadFiles
|
||||
from app.db.models.downloadhistory import DownloadFiles, DownloadHistory
|
||||
from app.db.oper.query import (
|
||||
descending,
|
||||
enum_values,
|
||||
execute_page,
|
||||
literal_contains,
|
||||
media_identity_conditions,
|
||||
music_type_condition,
|
||||
)
|
||||
from app.schemas.query import (
|
||||
DownloadHistoryFilter,
|
||||
QueryPageRequest,
|
||||
QuerySortField,
|
||||
)
|
||||
from app.schemas.types import MediaSource
|
||||
|
||||
|
||||
@@ -13,6 +28,95 @@ class DownloadHistoryOper(DbOper):
|
||||
下载历史管理
|
||||
"""
|
||||
|
||||
def get_by_id(self, record_id: int) -> Optional[DownloadHistory]:
|
||||
"""按稳定记录 ID 读取单条下载历史。"""
|
||||
return cast(
|
||||
Optional[DownloadHistory],
|
||||
self._execute_sync_query(
|
||||
lambda session: session.execute(
|
||||
select(DownloadHistory).where(DownloadHistory.id == record_id)
|
||||
).scalars().first()
|
||||
),
|
||||
)
|
||||
|
||||
def query(
|
||||
self,
|
||||
filters: DownloadHistoryFilter,
|
||||
page: QueryPageRequest,
|
||||
) -> tuple[list[DownloadHistory], int]:
|
||||
"""按稳定筛选和分页合同读取下载历史记录及总数。"""
|
||||
def execute(session: Session) -> tuple[list[DownloadHistory], int]:
|
||||
"""在同一会话中构造并执行下载历史 count/page 查询。"""
|
||||
conditions = media_identity_conditions(DownloadHistory, filters)
|
||||
ids = enum_values(filters.ids)
|
||||
media_types = enum_values(filters.media_types)
|
||||
usernames = enum_values(filters.usernames)
|
||||
if ids:
|
||||
conditions.append(DownloadHistory.id.in_(ids))
|
||||
if media_types:
|
||||
conditions.append(DownloadHistory.type.in_(media_types))
|
||||
for column, value in (
|
||||
(DownloadHistory.title, filters.title),
|
||||
(DownloadHistory.year, filters.year),
|
||||
(DownloadHistory.seasons, filters.seasons),
|
||||
(DownloadHistory.episodes, filters.episodes),
|
||||
(DownloadHistory.path, filters.path),
|
||||
(DownloadHistory.download_hash, filters.download_hash),
|
||||
(DownloadHistory.username, filters.username),
|
||||
(DownloadHistory.episode_group, filters.episode_group),
|
||||
):
|
||||
if value is not None and value != "":
|
||||
conditions.append(column == value)
|
||||
if filters.text:
|
||||
conditions.append(
|
||||
literal_contains(DownloadHistory.title, filters.text)
|
||||
| literal_contains(DownloadHistory.path, filters.text)
|
||||
)
|
||||
if usernames:
|
||||
conditions.append(DownloadHistory.username.in_(usernames))
|
||||
music_condition = music_type_condition(
|
||||
DownloadHistory.music_type,
|
||||
filters.music_type,
|
||||
)
|
||||
if music_condition is not None:
|
||||
conditions.append(music_condition)
|
||||
|
||||
count_statement = select(func.count(DownloadHistory.id))
|
||||
page_statement = select(DownloadHistory)
|
||||
if conditions:
|
||||
count_statement = count_statement.where(*conditions)
|
||||
page_statement = page_statement.where(*conditions)
|
||||
descending_order = descending(page)
|
||||
if page.sort.field == QuerySortField.ID:
|
||||
primary = (
|
||||
DownloadHistory.id.desc()
|
||||
if descending_order
|
||||
else DownloadHistory.id.asc()
|
||||
)
|
||||
secondary = (
|
||||
DownloadHistory.date.desc()
|
||||
if descending_order
|
||||
else DownloadHistory.date.asc()
|
||||
)
|
||||
else:
|
||||
primary = (
|
||||
DownloadHistory.date.desc().nullslast()
|
||||
if descending_order
|
||||
else DownloadHistory.date.asc().nullsfirst()
|
||||
)
|
||||
secondary = (
|
||||
DownloadHistory.id.desc()
|
||||
if descending_order
|
||||
else DownloadHistory.id.asc()
|
||||
)
|
||||
page_statement = page_statement.order_by(primary, secondary)
|
||||
return cast(
|
||||
tuple[list[DownloadHistory], int],
|
||||
execute_page(session, count_statement, page_statement, page),
|
||||
)
|
||||
|
||||
return self._execute_sync_query(execute)
|
||||
|
||||
def get_by_path(self, path: str) -> Optional[DownloadHistory]:
|
||||
"""
|
||||
按路径查询下载记录
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""只读查询 Oper 共享的持久化原语。
|
||||
|
||||
本模块只承载跨表完全相同的查询表示规则和分页执行步骤。每个具体 Oper 仍负责
|
||||
声明本表模型、筛选字段、SQLAlchemy 条件、count/page 语句以及排序,避免这里退化
|
||||
成任意表的 Repository。
|
||||
"""
|
||||
|
||||
from collections.abc import Iterable
|
||||
from enum import Enum
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.base import Base
|
||||
from app.schemas.query import QueryPageRequest, QuerySortDirection
|
||||
from app.schemas.types import MEDIA_SOURCE_IDENTIFIER_PATTERN, MUSIC_ENTITY_RECORDING
|
||||
|
||||
ModelT = TypeVar("ModelT", bound=Base)
|
||||
|
||||
|
||||
def enum_value(value: Any) -> Any:
|
||||
"""返回筛选枚举对应的数据库值。"""
|
||||
return value.value if isinstance(value, Enum) else value
|
||||
|
||||
|
||||
def enum_values(values: Iterable[Any]) -> tuple[Any, ...]:
|
||||
"""去除空筛选值、归一枚举,并保留调用方顺序。"""
|
||||
normalized: list[Any] = []
|
||||
for value in values:
|
||||
value = enum_value(value)
|
||||
if value in (None, ""):
|
||||
continue
|
||||
normalized.append(value)
|
||||
return tuple(dict.fromkeys(normalized))
|
||||
|
||||
|
||||
def literal_contains(column: Any, value: str) -> Any:
|
||||
"""构造不区分大小写且不解释 ``%``、``_`` 通配符的字面包含条件。"""
|
||||
escaped = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
return column.ilike(f"%{escaped}%", escape="\\")
|
||||
|
||||
|
||||
def media_identity_conditions(model: Any, query: Any) -> list[Any]:
|
||||
"""按媒体来源与原生 ID 的成对合同构造条件,并对非法身份 fail-closed。"""
|
||||
media_source = query.media_source
|
||||
media_id = query.media_id
|
||||
if (media_source is None) != (media_id is None):
|
||||
raise ValueError("media_source 和 media_id 必须同时提供")
|
||||
if media_source is None:
|
||||
return []
|
||||
normalized_id = str(media_id).strip()
|
||||
if not normalized_id or normalized_id == "0":
|
||||
raise ValueError("media_id 必须是非零的来源原生 ID")
|
||||
return [
|
||||
model.media_source == enum_value(media_source),
|
||||
model.media_id == normalized_id,
|
||||
]
|
||||
|
||||
|
||||
def required_media_identity_conditions(model: Any) -> list[Any]:
|
||||
"""构造只保留可解析来源与非空、非零原生 ID 的条件。"""
|
||||
return [
|
||||
model.media_source.is_not(None),
|
||||
func.trim(model.media_source) != "",
|
||||
func.lower(func.trim(model.media_source)).regexp_match(
|
||||
MEDIA_SOURCE_IDENTIFIER_PATTERN
|
||||
),
|
||||
model.media_id.is_not(None),
|
||||
func.trim(model.media_id) != "",
|
||||
func.trim(model.media_id) != "0",
|
||||
]
|
||||
|
||||
|
||||
def music_type_condition(column: Any, music_type: str | None) -> Any | None:
|
||||
"""兼容未标注音乐类型的历史单曲记录。"""
|
||||
music_type = enum_value(music_type)
|
||||
if not music_type:
|
||||
return None
|
||||
if music_type == MUSIC_ENTITY_RECORDING:
|
||||
return or_(column == music_type, column.is_(None))
|
||||
return column == music_type
|
||||
|
||||
|
||||
def execute_page(
|
||||
session: Session,
|
||||
count_statement: Any,
|
||||
page_statement: Any,
|
||||
page: QueryPageRequest,
|
||||
) -> tuple[list[ModelT], int]:
|
||||
"""在同一同步 Session 内先统计再读取一页已构造的查询语句。"""
|
||||
total = int(session.execute(count_statement).scalar_one() or 0)
|
||||
records = list(
|
||||
session.execute(page_statement.offset((page.page - 1) * page.count).limit(page.count)).scalars().all()
|
||||
)
|
||||
return records, total
|
||||
|
||||
|
||||
def descending(page: QueryPageRequest) -> bool:
|
||||
"""返回公开分页排序是否为降序,集中处理枚举表示。"""
|
||||
return page.sort.direction == QuerySortDirection.DESC
|
||||
|
||||
|
||||
__all__ = [
|
||||
"descending",
|
||||
"enum_value",
|
||||
"enum_values",
|
||||
"execute_page",
|
||||
"literal_contains",
|
||||
"media_identity_conditions",
|
||||
"music_type_condition",
|
||||
"required_media_identity_conditions",
|
||||
]
|
||||
@@ -9,9 +9,10 @@
|
||||
"""
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Tuple, List, Optional
|
||||
from typing import Any, List, Optional, Tuple, cast
|
||||
|
||||
from sqlalchemy import delete as sqlalchemy_delete
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -19,6 +20,14 @@ from app.application.subscription.delete import SubscribeDeletionCandidate
|
||||
from app.db.base import DbOper
|
||||
from app.db.models.subscribe import Subscribe
|
||||
from app.db.models.subscribehistory import SubscribeHistory
|
||||
from app.db.oper.query import (
|
||||
descending,
|
||||
enum_values,
|
||||
execute_page,
|
||||
media_identity_conditions,
|
||||
music_type_condition,
|
||||
)
|
||||
from app.schemas.query import QueryPageRequest, QuerySortField, SubscriptionFilter
|
||||
from app.schemas.types import MediaSource
|
||||
|
||||
INTEGER_FLAG_FIELDS = ("best_version", "best_version_full", "search_imdbid", "manual_total_episode")
|
||||
@@ -276,7 +285,77 @@ class SubscribeOper(DbOper):
|
||||
"""
|
||||
获取订阅
|
||||
"""
|
||||
return self._execute_sync_query(lambda session: Subscribe.get(session, sid))
|
||||
return self.get_by_id(sid)
|
||||
|
||||
def get_by_id(self, record_id: int) -> Optional[Subscribe]:
|
||||
"""按稳定记录 ID 读取单条订阅。"""
|
||||
return cast(
|
||||
Optional[Subscribe],
|
||||
self._execute_sync_query(
|
||||
lambda session: session.execute(
|
||||
select(Subscribe).where(Subscribe.id == record_id)
|
||||
).scalars().first()
|
||||
),
|
||||
)
|
||||
|
||||
def query(
|
||||
self,
|
||||
filters: SubscriptionFilter,
|
||||
page: QueryPageRequest,
|
||||
) -> tuple[list[Subscribe], int]:
|
||||
"""按稳定筛选和分页合同读取订阅记录及总数。"""
|
||||
def execute(session: Session) -> tuple[list[Subscribe], int]:
|
||||
"""在同一会话中构造并执行订阅 count/page 查询。"""
|
||||
conditions = media_identity_conditions(Subscribe, filters)
|
||||
ids = enum_values(filters.ids)
|
||||
names = enum_values(filters.names)
|
||||
states = enum_values(filters.states)
|
||||
usernames = enum_values(filters.usernames)
|
||||
media_types = enum_values(filters.media_types)
|
||||
if ids:
|
||||
conditions.append(Subscribe.id.in_(ids))
|
||||
if names:
|
||||
conditions.append(Subscribe.name.in_(names))
|
||||
if states:
|
||||
conditions.append(Subscribe.state.in_(states))
|
||||
if usernames:
|
||||
conditions.append(Subscribe.username.in_(usernames))
|
||||
if media_types:
|
||||
conditions.append(Subscribe.type.in_(media_types))
|
||||
if filters.season is not None:
|
||||
conditions.append(Subscribe.season == filters.season)
|
||||
if filters.episode_group is not None:
|
||||
conditions.append(Subscribe.episode_group == filters.episode_group)
|
||||
music_condition = music_type_condition(
|
||||
Subscribe.music_type,
|
||||
filters.music_type,
|
||||
)
|
||||
if music_condition is not None:
|
||||
conditions.append(music_condition)
|
||||
|
||||
count_statement = select(func.count(Subscribe.id))
|
||||
page_statement = select(Subscribe)
|
||||
if conditions:
|
||||
count_statement = count_statement.where(*conditions)
|
||||
page_statement = page_statement.where(*conditions)
|
||||
descending_order = descending(page)
|
||||
if page.sort.field == QuerySortField.ID:
|
||||
primary = Subscribe.id.desc() if descending_order else Subscribe.id.asc()
|
||||
secondary = Subscribe.date.desc() if descending_order else Subscribe.date.asc()
|
||||
else:
|
||||
primary = (
|
||||
Subscribe.date.desc().nullslast()
|
||||
if descending_order
|
||||
else Subscribe.date.asc().nullsfirst()
|
||||
)
|
||||
secondary = Subscribe.id.desc() if descending_order else Subscribe.id.asc()
|
||||
page_statement = page_statement.order_by(primary, secondary)
|
||||
return cast(
|
||||
tuple[list[Subscribe], int],
|
||||
execute_page(session, count_statement, page_statement, page),
|
||||
)
|
||||
|
||||
return self._execute_sync_query(execute)
|
||||
|
||||
async def async_get(self, sid: int) -> Optional[Subscribe]:
|
||||
"""
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
from typing import List, Optional
|
||||
from typing import List, Optional, cast
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.base import DbOper
|
||||
from app.db.models.subscribehistory import SubscribeHistory
|
||||
from app.db.oper.query import (
|
||||
descending,
|
||||
enum_values,
|
||||
execute_page,
|
||||
media_identity_conditions,
|
||||
music_type_condition,
|
||||
)
|
||||
from app.schemas.query import (
|
||||
QueryPageRequest,
|
||||
QuerySortField,
|
||||
SubscriptionHistoryFilter,
|
||||
)
|
||||
|
||||
|
||||
class SubscribeHistoryOper(DbOper):
|
||||
@@ -12,6 +25,87 @@ class SubscribeHistoryOper(DbOper):
|
||||
订阅历史管理。
|
||||
"""
|
||||
|
||||
def get_by_id(self, record_id: int) -> Optional[SubscribeHistory]:
|
||||
"""按稳定记录 ID 读取单条订阅历史。"""
|
||||
return cast(
|
||||
Optional[SubscribeHistory],
|
||||
self._execute_sync_query(
|
||||
lambda session: session.execute(
|
||||
select(SubscribeHistory).where(SubscribeHistory.id == record_id)
|
||||
).scalars().first()
|
||||
),
|
||||
)
|
||||
|
||||
def query(
|
||||
self,
|
||||
filters: SubscriptionHistoryFilter,
|
||||
page: QueryPageRequest,
|
||||
) -> tuple[list[SubscribeHistory], int]:
|
||||
"""按稳定筛选和分页合同读取订阅历史记录及总数。"""
|
||||
def execute(session: Session) -> tuple[list[SubscribeHistory], int]:
|
||||
"""在同一会话中构造并执行订阅历史 count/page 查询。"""
|
||||
conditions = media_identity_conditions(SubscribeHistory, filters)
|
||||
ids = enum_values(filters.ids)
|
||||
names = enum_values(filters.names)
|
||||
usernames = enum_values(filters.usernames)
|
||||
media_types = enum_values(filters.media_types)
|
||||
if ids:
|
||||
conditions.append(SubscribeHistory.id.in_(ids))
|
||||
if names:
|
||||
conditions.append(SubscribeHistory.name.in_(names))
|
||||
if usernames:
|
||||
conditions.append(SubscribeHistory.username.in_(usernames))
|
||||
if media_types:
|
||||
conditions.append(SubscribeHistory.type.in_(media_types))
|
||||
if filters.season is not None:
|
||||
conditions.append(SubscribeHistory.season == filters.season)
|
||||
if filters.episode_group is not None:
|
||||
conditions.append(
|
||||
SubscribeHistory.episode_group == filters.episode_group
|
||||
)
|
||||
music_condition = music_type_condition(
|
||||
SubscribeHistory.music_type,
|
||||
filters.music_type,
|
||||
)
|
||||
if music_condition is not None:
|
||||
conditions.append(music_condition)
|
||||
|
||||
count_statement = select(func.count(SubscribeHistory.id))
|
||||
page_statement = select(SubscribeHistory)
|
||||
if conditions:
|
||||
count_statement = count_statement.where(*conditions)
|
||||
page_statement = page_statement.where(*conditions)
|
||||
descending_order = descending(page)
|
||||
if page.sort.field == QuerySortField.ID:
|
||||
primary = (
|
||||
SubscribeHistory.id.desc()
|
||||
if descending_order
|
||||
else SubscribeHistory.id.asc()
|
||||
)
|
||||
secondary = (
|
||||
SubscribeHistory.date.desc()
|
||||
if descending_order
|
||||
else SubscribeHistory.date.asc()
|
||||
)
|
||||
else:
|
||||
primary = (
|
||||
SubscribeHistory.date.desc().nullslast()
|
||||
if descending_order
|
||||
else SubscribeHistory.date.asc().nullsfirst()
|
||||
)
|
||||
secondary = (
|
||||
SubscribeHistory.id.desc()
|
||||
if descending_order
|
||||
else SubscribeHistory.id.asc()
|
||||
)
|
||||
page_statement = page_statement.order_by(primary, secondary)
|
||||
return cast(
|
||||
tuple[list[SubscribeHistory], int],
|
||||
execute_page(session, count_statement, page_statement, page),
|
||||
)
|
||||
|
||||
return self._execute_sync_query(execute)
|
||||
|
||||
async def async_list_by_type(
|
||||
self,
|
||||
mtype: str,
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import time
|
||||
from typing import Any, List, Optional
|
||||
from typing import Any, List, Optional, cast
|
||||
|
||||
from sqlalchemy import delete as sqlalchemy_delete
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.base import DbOper
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.db.oper.query import (
|
||||
descending,
|
||||
enum_values,
|
||||
execute_page,
|
||||
literal_contains,
|
||||
media_identity_conditions,
|
||||
music_type_condition,
|
||||
required_media_identity_conditions,
|
||||
)
|
||||
from app.schemas.query import (
|
||||
QueryPageRequest,
|
||||
QuerySortField,
|
||||
TransferHistoryFilter,
|
||||
)
|
||||
from app.schemas.types import MediaSource
|
||||
|
||||
|
||||
@@ -20,10 +35,111 @@ class TransferHistoryOper(DbOper):
|
||||
获取转移历史
|
||||
:param historyid: 转移历史id
|
||||
"""
|
||||
return self._execute_sync_query(
|
||||
lambda session: TransferHistory.get(session, historyid)
|
||||
return self.get_by_id(historyid)
|
||||
|
||||
def get_by_id(self, record_id: int) -> Optional[TransferHistory]:
|
||||
"""按稳定记录 ID 读取单条整理历史。"""
|
||||
return cast(
|
||||
Optional[TransferHistory],
|
||||
self._execute_sync_query(
|
||||
lambda session: session.execute(
|
||||
select(TransferHistory).where(TransferHistory.id == record_id)
|
||||
).scalars().first()
|
||||
),
|
||||
)
|
||||
|
||||
def query(
|
||||
self,
|
||||
filters: TransferHistoryFilter,
|
||||
page: QueryPageRequest,
|
||||
) -> tuple[list[TransferHistory], int]:
|
||||
"""按稳定筛选和分页合同读取整理历史记录及总数。"""
|
||||
def execute(session: Session) -> tuple[list[TransferHistory], int]:
|
||||
"""在同一会话中构造并执行整理历史 count/page 查询。"""
|
||||
conditions = media_identity_conditions(TransferHistory, filters)
|
||||
ids = enum_values(filters.ids)
|
||||
media_types = enum_values(filters.media_types)
|
||||
media_sources = enum_values(filters.media_sources)
|
||||
if ids:
|
||||
conditions.append(TransferHistory.id.in_(ids))
|
||||
if media_types:
|
||||
conditions.append(TransferHistory.type.in_(media_types))
|
||||
if media_sources:
|
||||
conditions.append(TransferHistory.media_source.in_(media_sources))
|
||||
if filters.require_media_identity:
|
||||
conditions.extend(required_media_identity_conditions(TransferHistory))
|
||||
if filters.title:
|
||||
conditions.append(TransferHistory.title == filters.title)
|
||||
if filters.text:
|
||||
conditions.append(
|
||||
literal_contains(TransferHistory.title, filters.text)
|
||||
| literal_contains(TransferHistory.src, filters.text)
|
||||
| literal_contains(TransferHistory.dest, filters.text)
|
||||
)
|
||||
for column, value in (
|
||||
(TransferHistory.year, filters.year),
|
||||
(TransferHistory.seasons, filters.seasons),
|
||||
(TransferHistory.episodes, filters.episodes),
|
||||
(TransferHistory.src, filters.src),
|
||||
(TransferHistory.dest, filters.dest),
|
||||
(TransferHistory.download_hash, filters.download_hash),
|
||||
(TransferHistory.episode_group, filters.episode_group),
|
||||
):
|
||||
if value is not None and value != "":
|
||||
conditions.append(column == value)
|
||||
if filters.status is not None:
|
||||
if filters.status:
|
||||
conditions.append(TransferHistory.status.is_(True))
|
||||
else:
|
||||
conditions.append(
|
||||
or_(
|
||||
TransferHistory.status.is_(False),
|
||||
TransferHistory.status.is_(None),
|
||||
)
|
||||
)
|
||||
music_condition = music_type_condition(
|
||||
TransferHistory.music_type,
|
||||
filters.music_type,
|
||||
)
|
||||
if music_condition is not None:
|
||||
conditions.append(music_condition)
|
||||
|
||||
count_statement = select(func.count(TransferHistory.id))
|
||||
page_statement = select(TransferHistory)
|
||||
if conditions:
|
||||
count_statement = count_statement.where(*conditions)
|
||||
page_statement = page_statement.where(*conditions)
|
||||
descending_order = descending(page)
|
||||
if page.sort.field == QuerySortField.ID:
|
||||
primary = (
|
||||
TransferHistory.id.desc()
|
||||
if descending_order
|
||||
else TransferHistory.id.asc()
|
||||
)
|
||||
secondary = (
|
||||
TransferHistory.date.desc()
|
||||
if descending_order
|
||||
else TransferHistory.date.asc()
|
||||
)
|
||||
else:
|
||||
primary = (
|
||||
TransferHistory.date.desc().nullslast()
|
||||
if descending_order
|
||||
else TransferHistory.date.asc().nullsfirst()
|
||||
)
|
||||
secondary = (
|
||||
TransferHistory.id.desc()
|
||||
if descending_order
|
||||
else TransferHistory.id.asc()
|
||||
)
|
||||
page_statement = page_statement.order_by(primary, secondary)
|
||||
return cast(
|
||||
tuple[list[TransferHistory], int],
|
||||
execute_page(session, count_statement, page_statement, page),
|
||||
)
|
||||
|
||||
return self._execute_sync_query(execute)
|
||||
|
||||
async def async_get(self, historyid: int) -> Optional[TransferHistory]:
|
||||
"""
|
||||
异步获取转移历史。
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
"""插件只读数据查询使用的稳定筛选、分页与数据投影合同。"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any, Generic, Optional, TypeVar
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from app.schemas.common import JsonData
|
||||
from app.schemas.media import normalize_media_source
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
DEFAULT_QUERY_PAGE_SIZE = 50
|
||||
MAX_QUERY_PAGE_SIZE = 200
|
||||
|
||||
|
||||
class _QueryInput(BaseModel): # type: ignore[misc] # Pydantic imports are skipped by strict mypy
|
||||
"""查询输入共同的严格字段合同。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class QuerySortField(str, Enum):
|
||||
"""所有公开数据查询共同支持的稳定排序字段。"""
|
||||
|
||||
DATE = "date"
|
||||
ID = "id"
|
||||
|
||||
|
||||
class QuerySortDirection(str, Enum):
|
||||
"""公开查询的排序方向。"""
|
||||
|
||||
ASC = "asc"
|
||||
DESC = "desc"
|
||||
|
||||
|
||||
class QuerySort(_QueryInput):
|
||||
"""声明公开查询的稳定排序字段与方向。
|
||||
|
||||
日期排序将空日期置于升序开头、降序末尾,并以同方向 ID 打破日期并列;ID
|
||||
本身唯一,因此按 ID 排序不依赖数据库的隐式行顺序。
|
||||
"""
|
||||
|
||||
field: QuerySortField = QuerySortField.DATE
|
||||
direction: QuerySortDirection = QuerySortDirection.DESC
|
||||
|
||||
|
||||
class QueryPageRequest(_QueryInput):
|
||||
"""限制单次读取规模,并通过显式排序为跨页扫描提供稳定顺序。"""
|
||||
|
||||
page: int = Field(default=1, ge=1)
|
||||
count: int = Field(
|
||||
default=DEFAULT_QUERY_PAGE_SIZE,
|
||||
ge=1,
|
||||
le=MAX_QUERY_PAGE_SIZE,
|
||||
)
|
||||
sort: QuerySort = Field(default_factory=QuerySort)
|
||||
|
||||
|
||||
class QueryPage(BaseModel, Generic[T]): # type: ignore[misc] # Pydantic imports are skipped by strict mypy
|
||||
"""公开查询返回的分页 DTO。"""
|
||||
|
||||
items: list[T] = Field(default_factory=list)
|
||||
total: int = Field(default=0, ge=0)
|
||||
page: int = Field(default=1, ge=1)
|
||||
count: int = Field(
|
||||
default=DEFAULT_QUERY_PAGE_SIZE,
|
||||
ge=1,
|
||||
le=MAX_QUERY_PAGE_SIZE,
|
||||
)
|
||||
|
||||
@property
|
||||
def has_next(self) -> bool:
|
||||
"""返回当前分页后是否仍有记录。"""
|
||||
return self.page * self.count < self.total
|
||||
|
||||
|
||||
class MediaIdentityQuery(_QueryInput):
|
||||
"""允许省略身份,但显式筛选时要求来源与原生 ID 成对有效。"""
|
||||
|
||||
media_source: Optional[MediaSource] = None
|
||||
media_id: Optional[str] = None
|
||||
|
||||
@model_validator(mode="after") # type: ignore[misc]
|
||||
def _validate_media_identity(self) -> "MediaIdentityQuery":
|
||||
"""规范化 ID,并拒绝显式半对、空白或零值身份。"""
|
||||
source_provided = "media_source" in self.model_fields_set
|
||||
id_provided = "media_id" in self.model_fields_set
|
||||
normalized_id = str(self.media_id).strip() if self.media_id is not None else None
|
||||
if source_provided != id_provided or bool(self.media_source) != bool(normalized_id):
|
||||
raise ValueError("media_source 和 media_id 必须同时提供")
|
||||
if normalized_id == "0":
|
||||
raise ValueError("media_id 不能为 0")
|
||||
object.__setattr__(self, "media_id", normalized_id)
|
||||
return self
|
||||
|
||||
|
||||
class _QuerySnapshot(BaseModel): # type: ignore[misc] # Pydantic imports are skipped by strict mypy
|
||||
"""只读查询 DTO 的媒体身份与脏数据归一化边界。"""
|
||||
|
||||
media_source: Optional[MediaSource] = None
|
||||
media_id: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@field_validator("media_source", mode="before") # type: ignore[misc]
|
||||
@classmethod
|
||||
def _normalize_source(cls, value: Any) -> Optional[MediaSource]:
|
||||
"""未知旧来源不应使整页查询失败,统一降级为空身份。"""
|
||||
return normalize_media_source(value)
|
||||
|
||||
@model_validator(mode="after") # type: ignore[misc]
|
||||
def _normalize_identity_pair(self) -> "_QuerySnapshot":
|
||||
"""输出中的脏半对身份按无身份处理,避免向插件传播不可用主键。"""
|
||||
normalized_id = str(self.media_id).strip() if self.media_id is not None else ""
|
||||
if not self.media_source or not normalized_id or normalized_id == "0":
|
||||
object.__setattr__(self, "media_source", None)
|
||||
object.__setattr__(self, "media_id", None)
|
||||
else:
|
||||
object.__setattr__(self, "media_id", normalized_id)
|
||||
return self
|
||||
|
||||
|
||||
class SubscriptionSnapshot(_QuerySnapshot):
|
||||
"""当前订阅的稳定只读快照,不包含 ORM 或写入行为。"""
|
||||
|
||||
id: int
|
||||
name: Optional[str] = None
|
||||
year: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
keyword: Optional[str] = None
|
||||
music_type: Optional[str] = None
|
||||
total_tracks: Optional[int] = None
|
||||
season: Optional[int] = None
|
||||
poster: Optional[str] = None
|
||||
backdrop: Optional[str] = None
|
||||
vote: Optional[float] = None
|
||||
description: Optional[str] = None
|
||||
# 资源选择规则与质量约束均按宿主保存值只读返回。
|
||||
filter: Optional[str] = None
|
||||
include: Optional[str] = None
|
||||
exclude: Optional[str] = None
|
||||
quality: Optional[str] = None
|
||||
resolution: Optional[str] = None
|
||||
effect: Optional[str] = None
|
||||
audio_quality: Optional[str] = None
|
||||
audio_format: Optional[str] = None
|
||||
min_bitrate: Optional[int] = None
|
||||
min_bit_depth: Optional[int] = None
|
||||
min_sample_rate: Optional[int] = None
|
||||
# 订阅进度字段由宿主维护,插件查询不得据此直接写库。
|
||||
total_episode: Optional[int] = None
|
||||
start_episode: Optional[int] = None
|
||||
lack_episode: Optional[int] = None
|
||||
note: Optional[JsonData] = None
|
||||
state: Optional[str] = None
|
||||
last_update: Optional[str] = None
|
||||
date: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
sites: Optional[list[int]] = None
|
||||
downloader: Optional[str] = None
|
||||
best_version: Optional[int] = None
|
||||
best_version_full: Optional[int] = None
|
||||
current_priority: Optional[int] = None
|
||||
current_audio_format: Optional[str] = None
|
||||
current_bitrate: Optional[int] = None
|
||||
current_bit_depth: Optional[int] = None
|
||||
current_sample_rate: Optional[int] = None
|
||||
episode_priority: Optional[dict[str, int]] = None
|
||||
save_path: Optional[str] = None
|
||||
search_imdbid: Optional[int] = None
|
||||
manual_total_episode: Optional[int] = None
|
||||
custom_words: Optional[str] = None
|
||||
media_category: Optional[str] = None
|
||||
filter_groups: Optional[list[str]] = None
|
||||
episode_group: Optional[str] = None
|
||||
|
||||
|
||||
class SubscriptionHistorySnapshot(_QuerySnapshot):
|
||||
"""订阅完成历史的稳定只读快照。"""
|
||||
|
||||
id: int
|
||||
name: Optional[str] = None
|
||||
year: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
keyword: Optional[str] = None
|
||||
music_type: Optional[str] = None
|
||||
total_tracks: Optional[int] = None
|
||||
season: Optional[int] = None
|
||||
poster: Optional[str] = None
|
||||
backdrop: Optional[str] = None
|
||||
vote: Optional[float] = None
|
||||
description: Optional[str] = None
|
||||
filter: Optional[str] = None
|
||||
include: Optional[str] = None
|
||||
exclude: Optional[str] = None
|
||||
quality: Optional[str] = None
|
||||
resolution: Optional[str] = None
|
||||
effect: Optional[str] = None
|
||||
audio_quality: Optional[str] = None
|
||||
audio_format: Optional[str] = None
|
||||
min_bitrate: Optional[int] = None
|
||||
min_bit_depth: Optional[int] = None
|
||||
min_sample_rate: Optional[int] = None
|
||||
total_episode: Optional[int] = None
|
||||
start_episode: Optional[int] = None
|
||||
date: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
sites: Optional[list[int]] = None
|
||||
best_version: Optional[int] = None
|
||||
best_version_full: Optional[int] = None
|
||||
current_priority: Optional[int] = None
|
||||
current_audio_format: Optional[str] = None
|
||||
current_bitrate: Optional[int] = None
|
||||
current_bit_depth: Optional[int] = None
|
||||
current_sample_rate: Optional[int] = None
|
||||
episode_priority: Optional[dict[str, int]] = None
|
||||
save_path: Optional[str] = None
|
||||
search_imdbid: Optional[int] = None
|
||||
custom_words: Optional[str] = None
|
||||
media_category: Optional[str] = None
|
||||
filter_groups: Optional[list[str]] = None
|
||||
episode_group: Optional[str] = None
|
||||
|
||||
|
||||
class DownloadHistorySnapshot(_QuerySnapshot):
|
||||
"""下载历史的稳定只读快照。"""
|
||||
|
||||
id: int
|
||||
path: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
year: Optional[str] = None
|
||||
music_type: Optional[str] = None
|
||||
seasons: Optional[str] = None
|
||||
episodes: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
poster: Optional[str] = None
|
||||
downloader: Optional[str] = None
|
||||
download_hash: Optional[str] = None
|
||||
torrent_name: Optional[str] = None
|
||||
torrent_description: Optional[str] = None
|
||||
torrent_site: Optional[str] = None
|
||||
userid: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
channel: Optional[str] = None
|
||||
date: Optional[str] = None
|
||||
note: Optional[JsonData] = None
|
||||
media_category: Optional[str] = None
|
||||
episode_group: Optional[str] = None
|
||||
custom_words: Optional[str] = None
|
||||
|
||||
|
||||
class TransferHistorySnapshot(_QuerySnapshot):
|
||||
"""整理历史的稳定只读快照;内部任务结算标识不会暴露给插件。"""
|
||||
|
||||
id: int
|
||||
src: Optional[str] = None
|
||||
src_storage: Optional[str] = None
|
||||
src_fileitem: Optional[JsonData] = None
|
||||
dest: Optional[str] = None
|
||||
dest_storage: Optional[str] = None
|
||||
dest_fileitem: Optional[JsonData] = None
|
||||
mode: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
year: Optional[str] = None
|
||||
music_type: Optional[str] = None
|
||||
total_tracks: Optional[int] = None
|
||||
audio_format: Optional[str] = None
|
||||
audio_lossless: Optional[bool] = None
|
||||
bit_depth: Optional[int] = None
|
||||
sample_rate: Optional[int] = None
|
||||
bitrate: Optional[int] = None
|
||||
seasons: Optional[str] = None
|
||||
episodes: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
downloader: Optional[str] = None
|
||||
download_hash: Optional[str] = None
|
||||
status: bool = False
|
||||
errmsg: Optional[str] = None
|
||||
date: Optional[str] = None
|
||||
files: Optional[JsonData] = None
|
||||
episode_group: Optional[str] = None
|
||||
|
||||
@field_validator("status", mode="before") # type: ignore[misc]
|
||||
@classmethod
|
||||
def _normalize_status(cls, value: object) -> bool:
|
||||
"""旧记录的 NULL 状态按失败处理,避免错误地向插件声明整理成功。"""
|
||||
return bool(value)
|
||||
|
||||
|
||||
class SubscriptionFilter(MediaIdentityQuery):
|
||||
"""当前订阅的组合筛选合同。
|
||||
|
||||
所有非空字段按 AND 组合,tuple 字段内部按 IN 匹配,其余字段均精确匹配;
|
||||
``music_type=recording`` 同时匹配未标注音乐类型的旧单曲记录。
|
||||
"""
|
||||
|
||||
ids: tuple[int, ...] = ()
|
||||
names: tuple[str, ...] = ()
|
||||
states: tuple[str, ...] = ()
|
||||
usernames: tuple[str, ...] = ()
|
||||
media_types: tuple[MediaType, ...] = ()
|
||||
season: Optional[int] = None
|
||||
episode_group: Optional[str] = None
|
||||
music_type: Optional[str] = None
|
||||
|
||||
|
||||
class SubscriptionHistoryFilter(MediaIdentityQuery):
|
||||
"""订阅完成历史的组合筛选合同。
|
||||
|
||||
所有非空字段按 AND 组合,tuple 字段内部按 IN 匹配,其余字段均精确匹配;
|
||||
``music_type=recording`` 同时匹配未标注音乐类型的旧单曲记录。
|
||||
"""
|
||||
|
||||
ids: tuple[int, ...] = ()
|
||||
names: tuple[str, ...] = ()
|
||||
usernames: tuple[str, ...] = ()
|
||||
media_types: tuple[MediaType, ...] = ()
|
||||
season: Optional[int] = None
|
||||
episode_group: Optional[str] = None
|
||||
music_type: Optional[str] = None
|
||||
|
||||
|
||||
class DownloadHistoryFilter(MediaIdentityQuery):
|
||||
"""下载历史的组合筛选合同。
|
||||
|
||||
所有非空字段按 AND 组合,tuple 字段内部按 IN 匹配;除 ``text`` 外的字符串
|
||||
字段均精确匹配。``text`` 对标题和路径执行转义后的字面包含查询,不把 ``%``
|
||||
或 ``_`` 解释为通配符。``music_type=recording`` 同时匹配旧 NULL 记录。
|
||||
"""
|
||||
|
||||
ids: tuple[int, ...] = ()
|
||||
media_types: tuple[MediaType, ...] = ()
|
||||
title: Optional[str] = None
|
||||
text: Optional[str] = None
|
||||
year: Optional[str] = None
|
||||
seasons: Optional[str] = None
|
||||
episodes: Optional[str] = None
|
||||
path: Optional[str] = None
|
||||
download_hash: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
usernames: tuple[str, ...] = ()
|
||||
music_type: Optional[str] = None
|
||||
episode_group: Optional[str] = None
|
||||
|
||||
|
||||
class TransferHistoryFilter(MediaIdentityQuery):
|
||||
"""整理历史的组合筛选合同。
|
||||
|
||||
所有非空字段按 AND 组合,tuple 字段内部按 IN 匹配;除 ``text`` 外的字符串
|
||||
字段均精确匹配。``text`` 对标题、源路径和目标路径执行转义后的字面包含查询。
|
||||
``require_media_identity`` 只保留来源可解析且原生 ID 非空、非零的记录;
|
||||
``status=False`` 包含旧 NULL 状态,``music_type=recording`` 包含旧 NULL 类型。
|
||||
"""
|
||||
|
||||
ids: tuple[int, ...] = ()
|
||||
media_types: tuple[MediaType, ...] = ()
|
||||
media_sources: tuple[MediaSource, ...] = ()
|
||||
require_media_identity: bool = False
|
||||
title: Optional[str] = None
|
||||
text: Optional[str] = None
|
||||
year: Optional[str] = None
|
||||
seasons: Optional[str] = None
|
||||
episodes: Optional[str] = None
|
||||
src: Optional[str] = None
|
||||
dest: Optional[str] = None
|
||||
status: Optional[bool] = None
|
||||
download_hash: Optional[str] = None
|
||||
music_type: Optional[str] = None
|
||||
episode_group: Optional[str] = None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_QUERY_PAGE_SIZE",
|
||||
"MAX_QUERY_PAGE_SIZE",
|
||||
"DownloadHistoryFilter",
|
||||
"DownloadHistorySnapshot",
|
||||
"MediaIdentityQuery",
|
||||
"QueryPage",
|
||||
"QueryPageRequest",
|
||||
"QuerySort",
|
||||
"QuerySortDirection",
|
||||
"QuerySortField",
|
||||
"SubscriptionFilter",
|
||||
"SubscriptionHistorySnapshot",
|
||||
"SubscriptionHistoryFilter",
|
||||
"SubscriptionSnapshot",
|
||||
"TransferHistoryFilter",
|
||||
"TransferHistorySnapshot",
|
||||
]
|
||||
@@ -0,0 +1,273 @@
|
||||
"""插件可使用的订阅与历史只读查询门面。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Protocol, cast
|
||||
|
||||
from app.schemas.query import (
|
||||
DEFAULT_QUERY_PAGE_SIZE,
|
||||
MAX_QUERY_PAGE_SIZE,
|
||||
DownloadHistoryFilter,
|
||||
DownloadHistorySnapshot,
|
||||
MediaIdentityQuery,
|
||||
QueryPage,
|
||||
QueryPageRequest,
|
||||
QuerySort,
|
||||
QuerySortDirection,
|
||||
QuerySortField,
|
||||
SubscriptionFilter,
|
||||
SubscriptionHistoryFilter,
|
||||
SubscriptionHistorySnapshot,
|
||||
SubscriptionSnapshot,
|
||||
TransferHistoryFilter,
|
||||
TransferHistorySnapshot,
|
||||
)
|
||||
|
||||
|
||||
class _DataQueryBackend(Protocol):
|
||||
"""SDK 转发所需的最小类型合同,不向插件公开应用服务实现。"""
|
||||
|
||||
def list_subscriptions(
|
||||
self,
|
||||
filters: SubscriptionFilter | dict[str, Any] | None = None,
|
||||
page: QueryPageRequest | dict[str, Any] | None = None,
|
||||
) -> QueryPage[SubscriptionSnapshot]: ...
|
||||
|
||||
async def async_list_subscriptions(
|
||||
self,
|
||||
filters: SubscriptionFilter | dict[str, Any] | None = None,
|
||||
page: QueryPageRequest | dict[str, Any] | None = None,
|
||||
) -> QueryPage[SubscriptionSnapshot]: ...
|
||||
|
||||
def get_subscription(
|
||||
self,
|
||||
subscription_id: int,
|
||||
) -> SubscriptionSnapshot | None: ...
|
||||
|
||||
async def async_get_subscription(
|
||||
self,
|
||||
subscription_id: int,
|
||||
) -> SubscriptionSnapshot | None: ...
|
||||
|
||||
def list_subscription_history(
|
||||
self,
|
||||
filters: SubscriptionHistoryFilter | dict[str, Any] | None = None,
|
||||
page: QueryPageRequest | dict[str, Any] | None = None,
|
||||
) -> QueryPage[SubscriptionHistorySnapshot]: ...
|
||||
|
||||
async def async_list_subscription_history(
|
||||
self,
|
||||
filters: SubscriptionHistoryFilter | dict[str, Any] | None = None,
|
||||
page: QueryPageRequest | dict[str, Any] | None = None,
|
||||
) -> QueryPage[SubscriptionHistorySnapshot]: ...
|
||||
|
||||
def get_subscription_history(
|
||||
self,
|
||||
history_id: int,
|
||||
) -> SubscriptionHistorySnapshot | None: ...
|
||||
|
||||
async def async_get_subscription_history(
|
||||
self,
|
||||
history_id: int,
|
||||
) -> SubscriptionHistorySnapshot | None: ...
|
||||
|
||||
def list_download_history(
|
||||
self,
|
||||
filters: DownloadHistoryFilter | dict[str, Any] | None = None,
|
||||
page: QueryPageRequest | dict[str, Any] | None = None,
|
||||
) -> QueryPage[DownloadHistorySnapshot]: ...
|
||||
|
||||
async def async_list_download_history(
|
||||
self,
|
||||
filters: DownloadHistoryFilter | dict[str, Any] | None = None,
|
||||
page: QueryPageRequest | dict[str, Any] | None = None,
|
||||
) -> QueryPage[DownloadHistorySnapshot]: ...
|
||||
|
||||
def get_download_history(
|
||||
self,
|
||||
history_id: int,
|
||||
) -> DownloadHistorySnapshot | None: ...
|
||||
|
||||
async def async_get_download_history(
|
||||
self,
|
||||
history_id: int,
|
||||
) -> DownloadHistorySnapshot | None: ...
|
||||
|
||||
def list_transfer_history(
|
||||
self,
|
||||
filters: TransferHistoryFilter | dict[str, Any] | None = None,
|
||||
page: QueryPageRequest | dict[str, Any] | None = None,
|
||||
) -> QueryPage[TransferHistorySnapshot]: ...
|
||||
|
||||
async def async_list_transfer_history(
|
||||
self,
|
||||
filters: TransferHistoryFilter | dict[str, Any] | None = None,
|
||||
page: QueryPageRequest | dict[str, Any] | None = None,
|
||||
) -> QueryPage[TransferHistorySnapshot]: ...
|
||||
|
||||
def get_transfer_history(
|
||||
self,
|
||||
history_id: int,
|
||||
) -> TransferHistorySnapshot | None: ...
|
||||
|
||||
async def async_get_transfer_history(
|
||||
self,
|
||||
history_id: int,
|
||||
) -> TransferHistorySnapshot | None: ...
|
||||
|
||||
|
||||
def _service() -> _DataQueryBackend:
|
||||
"""获取启动阶段登记的查询服务,避免把应用服务暴露为 SDK 合同。"""
|
||||
from app.application.query import (
|
||||
get_configured_data_query_service,
|
||||
)
|
||||
|
||||
return cast(_DataQueryBackend, get_configured_data_query_service())
|
||||
|
||||
|
||||
def list_subscriptions(
|
||||
filters: SubscriptionFilter | dict[str, Any] | None = None,
|
||||
page: QueryPageRequest | dict[str, Any] | None = None,
|
||||
) -> QueryPage[SubscriptionSnapshot]:
|
||||
"""同步分页读取订阅 DTO。"""
|
||||
return _service().list_subscriptions(filters, page)
|
||||
|
||||
|
||||
async def async_list_subscriptions(
|
||||
filters: SubscriptionFilter | dict[str, Any] | None = None,
|
||||
page: QueryPageRequest | dict[str, Any] | None = None,
|
||||
) -> QueryPage[SubscriptionSnapshot]:
|
||||
"""异步分页读取订阅 DTO。"""
|
||||
return await _service().async_list_subscriptions(filters, page)
|
||||
|
||||
|
||||
def get_subscription(subscription_id: int) -> SubscriptionSnapshot | None:
|
||||
"""同步按 ID 读取订阅 DTO。"""
|
||||
return _service().get_subscription(subscription_id)
|
||||
|
||||
|
||||
async def async_get_subscription(
|
||||
subscription_id: int,
|
||||
) -> SubscriptionSnapshot | None:
|
||||
"""异步按 ID 读取订阅 DTO。"""
|
||||
return await _service().async_get_subscription(subscription_id)
|
||||
|
||||
|
||||
def list_subscription_history(
|
||||
filters: SubscriptionHistoryFilter | dict[str, Any] | None = None,
|
||||
page: QueryPageRequest | dict[str, Any] | None = None,
|
||||
) -> QueryPage[SubscriptionHistorySnapshot]:
|
||||
"""同步分页读取订阅完成历史 DTO。"""
|
||||
return _service().list_subscription_history(filters, page)
|
||||
|
||||
|
||||
async def async_list_subscription_history(
|
||||
filters: SubscriptionHistoryFilter | dict[str, Any] | None = None,
|
||||
page: QueryPageRequest | dict[str, Any] | None = None,
|
||||
) -> QueryPage[SubscriptionHistorySnapshot]:
|
||||
"""异步分页读取订阅完成历史 DTO。"""
|
||||
return await _service().async_list_subscription_history(filters, page)
|
||||
|
||||
|
||||
def get_subscription_history(history_id: int) -> SubscriptionHistorySnapshot | None:
|
||||
"""同步按 ID 读取订阅完成历史 DTO。"""
|
||||
return _service().get_subscription_history(history_id)
|
||||
|
||||
|
||||
async def async_get_subscription_history(
|
||||
history_id: int,
|
||||
) -> SubscriptionHistorySnapshot | None:
|
||||
"""异步按 ID 读取订阅完成历史 DTO。"""
|
||||
return await _service().async_get_subscription_history(history_id)
|
||||
|
||||
|
||||
def list_download_history(
|
||||
filters: DownloadHistoryFilter | dict[str, Any] | None = None,
|
||||
page: QueryPageRequest | dict[str, Any] | None = None,
|
||||
) -> QueryPage[DownloadHistorySnapshot]:
|
||||
"""同步分页读取下载历史 DTO。"""
|
||||
return _service().list_download_history(filters, page)
|
||||
|
||||
|
||||
async def async_list_download_history(
|
||||
filters: DownloadHistoryFilter | dict[str, Any] | None = None,
|
||||
page: QueryPageRequest | dict[str, Any] | None = None,
|
||||
) -> QueryPage[DownloadHistorySnapshot]:
|
||||
"""异步分页读取下载历史 DTO。"""
|
||||
return await _service().async_list_download_history(filters, page)
|
||||
|
||||
|
||||
def get_download_history(history_id: int) -> DownloadHistorySnapshot | None:
|
||||
"""同步按 ID 读取下载历史 DTO。"""
|
||||
return _service().get_download_history(history_id)
|
||||
|
||||
|
||||
async def async_get_download_history(
|
||||
history_id: int,
|
||||
) -> DownloadHistorySnapshot | None:
|
||||
"""异步按 ID 读取下载历史 DTO。"""
|
||||
return await _service().async_get_download_history(history_id)
|
||||
|
||||
|
||||
def list_transfer_history(
|
||||
filters: TransferHistoryFilter | dict[str, Any] | None = None,
|
||||
page: QueryPageRequest | dict[str, Any] | None = None,
|
||||
) -> QueryPage[TransferHistorySnapshot]:
|
||||
"""同步分页读取整理历史 DTO。"""
|
||||
return _service().list_transfer_history(filters, page)
|
||||
|
||||
|
||||
async def async_list_transfer_history(
|
||||
filters: TransferHistoryFilter | dict[str, Any] | None = None,
|
||||
page: QueryPageRequest | dict[str, Any] | None = None,
|
||||
) -> QueryPage[TransferHistorySnapshot]:
|
||||
"""异步分页读取整理历史 DTO。"""
|
||||
return await _service().async_list_transfer_history(filters, page)
|
||||
|
||||
|
||||
def get_transfer_history(history_id: int) -> TransferHistorySnapshot | None:
|
||||
"""同步按 ID 读取整理历史 DTO。"""
|
||||
return _service().get_transfer_history(history_id)
|
||||
|
||||
|
||||
async def async_get_transfer_history(
|
||||
history_id: int,
|
||||
) -> TransferHistorySnapshot | None:
|
||||
"""异步按 ID 读取整理历史 DTO。"""
|
||||
return await _service().async_get_transfer_history(history_id)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_QUERY_PAGE_SIZE",
|
||||
"MAX_QUERY_PAGE_SIZE",
|
||||
"DownloadHistoryFilter",
|
||||
"DownloadHistorySnapshot",
|
||||
"MediaIdentityQuery",
|
||||
"QueryPage",
|
||||
"QueryPageRequest",
|
||||
"QuerySort",
|
||||
"QuerySortDirection",
|
||||
"QuerySortField",
|
||||
"SubscriptionFilter",
|
||||
"SubscriptionHistoryFilter",
|
||||
"SubscriptionHistorySnapshot",
|
||||
"SubscriptionSnapshot",
|
||||
"TransferHistoryFilter",
|
||||
"TransferHistorySnapshot",
|
||||
"async_get_download_history",
|
||||
"async_get_subscription",
|
||||
"async_get_subscription_history",
|
||||
"async_get_transfer_history",
|
||||
"async_list_download_history",
|
||||
"async_list_subscription_history",
|
||||
"async_list_subscriptions",
|
||||
"async_list_transfer_history",
|
||||
"get_download_history",
|
||||
"get_subscription",
|
||||
"get_subscription_history",
|
||||
"get_transfer_history",
|
||||
"list_download_history",
|
||||
"list_subscription_history",
|
||||
"list_subscriptions",
|
||||
"list_transfer_history",
|
||||
]
|
||||
@@ -88,6 +88,10 @@ from app.application.outbox import (
|
||||
validate_durable_event_handlers,
|
||||
)
|
||||
from app.application.plugin.runtime import configure_plugin_runtime
|
||||
from app.application.query import (
|
||||
DataQueryService,
|
||||
configure_data_query_service,
|
||||
)
|
||||
from app.application.security.auth import AuthService, build_superuser_token_payload, configure_auth_service
|
||||
from app.application.security.passkey import PasskeyService, configure_passkey_service
|
||||
from app.application.security.url import close_image_proxy_block_log_coalescer
|
||||
@@ -111,6 +115,7 @@ from app.command import CommandChain
|
||||
from app.db.adapters.chain import TransactionalChainDurableEventWriter
|
||||
from app.db.adapters.download import TransactionalDownloadFailureRepository
|
||||
from app.db.adapters.outbox import SqlAlchemyAsyncOutboxStager, SqlAlchemyOutboxRepository
|
||||
from app.db.adapters.query import SqlAlchemyDataQueryAdapter
|
||||
from app.db.adapters.site import TransactionalSiteRepository
|
||||
from app.db.adapters.subscription import TransactionalSubscribeWriter
|
||||
from app.db.adapters.transaction import TransactionalWriteRunner
|
||||
@@ -758,6 +763,14 @@ async def init_modules() -> HostRuntime:
|
||||
except Exception as cleanup_error: # noqa: BLE001 保留原始启动异常
|
||||
logger.error(f"启动失败后的数据库任务清理失败:{cleanup_error}")
|
||||
raise
|
||||
data_query_adapter = SqlAlchemyDataQueryAdapter(SessionFactory)
|
||||
configure_data_query_service(
|
||||
DataQueryService(
|
||||
subscriptions=data_query_adapter,
|
||||
histories=data_query_adapter,
|
||||
async_executor=database_worker,
|
||||
)
|
||||
)
|
||||
configure_plugin_persistence(
|
||||
PluginPersistenceService(
|
||||
executor=database_worker,
|
||||
|
||||
@@ -69,7 +69,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
|
||||
|
||||
| 指标 | 当前值 | 解释 |
|
||||
|---|---:|---|
|
||||
| 宿主 Python 模块 / 内部依赖边 | 845 / 6,902 | `dependency-baseline.json` 当前快照 |
|
||||
| 宿主 Python 模块 / 内部依赖边 | 850 / 6,944 | `dependency-baseline.json` 当前快照 |
|
||||
| 非平凡 SCC | 2 | 新增 Chain 包根环;另一个是隔离的 29 模块 TMDB 移植包环 |
|
||||
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
|
||||
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
|
||||
@@ -78,8 +78,8 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
|
||||
| Python 源码量 | 约 271,400 行 | 60 个文件超过 1,000 行,14 个超过 2,000 行 |
|
||||
| 长方法 | 281 个超过 80 行 | 67 个超过 150 行,23 个超过 250 行;大量是私有方法 |
|
||||
| 全量 mypy 历史债务 | 11,809 / 596 文件 | strict frontier 当前覆盖 41 个文件,本批迁移路径的类型债务已清零 |
|
||||
| Ruff 历史诊断 | 878 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
|
||||
| 覆盖率低水位 | Application 78.79%,Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
|
||||
| Ruff 历史诊断 | 875 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
|
||||
| 覆盖率低水位 | Application 78.89%,Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
|
||||
|
||||
### 3.3 热点文件
|
||||
|
||||
|
||||
@@ -697,15 +697,15 @@ flowchart LR
|
||||
SDK 导出(若公开)、`docs/rules/05-architecture.md` 与上述架构测试。
|
||||
- 延迟导入不被接受为隐藏循环依赖的手段。
|
||||
|
||||
### 10.1 2026-08-26 当前收口状态与后续边界
|
||||
### 10.1 2026-08-27 当前收口状态与后续边界
|
||||
|
||||
当前宿主架构基线(排除 `app/plugins/**`)如下;数字来自
|
||||
`tests/fixtures/architecture/`,更新基线前必须先审查语义变化:
|
||||
|
||||
| 指标 | 当前值 |
|
||||
|---|---:|
|
||||
| Python 模块 | 845 |
|
||||
| 内部导入边 | 6,902 |
|
||||
| Python 模块 | 850 |
|
||||
| 内部导入边 | 6,944 |
|
||||
| 非平凡 SCC | 2(`ARCH-107` 临时 Chain 包根环;精确 containment 的 TMDB 移植包环) |
|
||||
| Direct egress | 66(12 条待迁移债务,54 条精确 containment) |
|
||||
| Module Contract V2 spec | 217(其中 215 个进入 `run_module` 观察面) |
|
||||
|
||||
@@ -153,7 +153,7 @@ canonical 主程序;兼容只经统一 Compat/SDK 门面提供。
|
||||
| S4-L2 Event strict contract | `PLANNED` | S0-L2.6,S1-L6 | 宿主事件输入/输出按风险 strict,诊断例外只属于第三方插件兼容 |
|
||||
| S4-L3 Complexity v2 | `PLANNED` | S3 | 私有方法、class/file、圈复杂度进入门禁;所有超限通过职责拆分归零 |
|
||||
| S4-L4 全量 mypy 清零 | `PLANNED` | S3,S4-L1,S4-L2 | `mypy-baseline.json` 归零并删除债务接受路径,全宿主 strict 类型通过 |
|
||||
| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 878 条诊断归零,规则集扩展经过独立审查且新增诊断为零 |
|
||||
| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 875 条诊断归零,规则集扩展经过独立审查且新增诊断为零 |
|
||||
| S4-L6 Coverage/并发/质量证据 | `PLANNED` | S3,S4-L1,S4-L2 | 高风险包纳入 coverage;raw concurrency 分类清零;Module Quality 有真实 evidence test |
|
||||
|
||||
### S5:Plugin、Agent、Domain、Startup 与最终收口
|
||||
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"application": {
|
||||
"covered_lines": 9978,
|
||||
"percent": 78.79,
|
||||
"statements": 12664
|
||||
"covered_lines": 10084,
|
||||
"percent": 78.89,
|
||||
"statements": 12782
|
||||
},
|
||||
"domain": {
|
||||
"covered_lines": 3392,
|
||||
|
||||
+50
-3
@@ -1441,8 +1441,8 @@
|
||||
"runtime_only": true
|
||||
}
|
||||
},
|
||||
"edge_count": 6902,
|
||||
"edge_sha256": "e2adb079d1df7415b81cbfa8358536e29e06276c2cb5af3a6f6c634e6f58fb20",
|
||||
"edge_count": 6944,
|
||||
"edge_sha256": "9f6be750c55150ef9e061f54ade997c328561b7950f6ec44bce6ada373d01f3e",
|
||||
"edges": [
|
||||
"app -> app.runtime",
|
||||
"app -> app.runtime.compat",
|
||||
@@ -4259,6 +4259,10 @@
|
||||
"app.application.plugin.transaction -> app.application.database",
|
||||
"app.application.plugin.transaction -> app.application.plugin",
|
||||
"app.application.plugin.transaction -> app.application.plugin.identity",
|
||||
"app.application.query -> app.application",
|
||||
"app.application.query -> app.application.database",
|
||||
"app.application.query -> app.schemas",
|
||||
"app.application.query -> app.schemas.query",
|
||||
"app.application.recognition -> app.application",
|
||||
"app.application.recognition -> app.application.configuration",
|
||||
"app.application.recognition -> app.schemas",
|
||||
@@ -5159,6 +5163,16 @@
|
||||
"app.db.adapters.plugininstallation -> app.db.models",
|
||||
"app.db.adapters.plugininstallation -> app.db.models.pluginidentity",
|
||||
"app.db.adapters.plugininstallation -> app.db.models.plugininstallation",
|
||||
"app.db.adapters.query -> app.application",
|
||||
"app.db.adapters.query -> app.application.query",
|
||||
"app.db.adapters.query -> app.db",
|
||||
"app.db.adapters.query -> app.db.oper",
|
||||
"app.db.adapters.query -> app.db.oper.downloadhistory",
|
||||
"app.db.adapters.query -> app.db.oper.subscribe",
|
||||
"app.db.adapters.query -> app.db.oper.subscribehistory",
|
||||
"app.db.adapters.query -> app.db.oper.transferhistory",
|
||||
"app.db.adapters.query -> app.schemas",
|
||||
"app.db.adapters.query -> app.schemas.query",
|
||||
"app.db.adapters.site -> app.db",
|
||||
"app.db.adapters.site -> app.db.oper",
|
||||
"app.db.adapters.site -> app.db.oper.site",
|
||||
@@ -5341,7 +5355,10 @@
|
||||
"app.db.oper.downloadhistory -> app.db.base",
|
||||
"app.db.oper.downloadhistory -> app.db.models",
|
||||
"app.db.oper.downloadhistory -> app.db.models.downloadhistory",
|
||||
"app.db.oper.downloadhistory -> app.db.oper",
|
||||
"app.db.oper.downloadhistory -> app.db.oper.query",
|
||||
"app.db.oper.downloadhistory -> app.schemas",
|
||||
"app.db.oper.downloadhistory -> app.schemas.query",
|
||||
"app.db.oper.downloadhistory -> app.schemas.types",
|
||||
"app.db.oper.mediaserver -> app.db",
|
||||
"app.db.oper.mediaserver -> app.db.base",
|
||||
@@ -5366,6 +5383,11 @@
|
||||
"app.db.oper.pluginidentity -> app.db.base",
|
||||
"app.db.oper.pluginidentity -> app.db.models",
|
||||
"app.db.oper.pluginidentity -> app.db.models.pluginidentity",
|
||||
"app.db.oper.query -> app.db",
|
||||
"app.db.oper.query -> app.db.base",
|
||||
"app.db.oper.query -> app.schemas",
|
||||
"app.db.oper.query -> app.schemas.query",
|
||||
"app.db.oper.query -> app.schemas.types",
|
||||
"app.db.oper.site -> app.db",
|
||||
"app.db.oper.site -> app.db.base",
|
||||
"app.db.oper.site -> app.db.models",
|
||||
@@ -5381,12 +5403,19 @@
|
||||
"app.db.oper.subscribe -> app.db.models",
|
||||
"app.db.oper.subscribe -> app.db.models.subscribe",
|
||||
"app.db.oper.subscribe -> app.db.models.subscribehistory",
|
||||
"app.db.oper.subscribe -> app.db.oper",
|
||||
"app.db.oper.subscribe -> app.db.oper.query",
|
||||
"app.db.oper.subscribe -> app.schemas",
|
||||
"app.db.oper.subscribe -> app.schemas.query",
|
||||
"app.db.oper.subscribe -> app.schemas.types",
|
||||
"app.db.oper.subscribehistory -> app.db",
|
||||
"app.db.oper.subscribehistory -> app.db.base",
|
||||
"app.db.oper.subscribehistory -> app.db.models",
|
||||
"app.db.oper.subscribehistory -> app.db.models.subscribehistory",
|
||||
"app.db.oper.subscribehistory -> app.db.oper",
|
||||
"app.db.oper.subscribehistory -> app.db.oper.query",
|
||||
"app.db.oper.subscribehistory -> app.schemas",
|
||||
"app.db.oper.subscribehistory -> app.schemas.query",
|
||||
"app.db.oper.systemconfig -> app.db",
|
||||
"app.db.oper.systemconfig -> app.db.base",
|
||||
"app.db.oper.systemconfig -> app.db.models",
|
||||
@@ -5403,7 +5432,10 @@
|
||||
"app.db.oper.transferhistory -> app.db.base",
|
||||
"app.db.oper.transferhistory -> app.db.models",
|
||||
"app.db.oper.transferhistory -> app.db.models.transferhistory",
|
||||
"app.db.oper.transferhistory -> app.db.oper",
|
||||
"app.db.oper.transferhistory -> app.db.oper.query",
|
||||
"app.db.oper.transferhistory -> app.schemas",
|
||||
"app.db.oper.transferhistory -> app.schemas.query",
|
||||
"app.db.oper.transferhistory -> app.schemas.types",
|
||||
"app.db.oper.transferpending -> app.db",
|
||||
"app.db.oper.transferpending -> app.db.base",
|
||||
@@ -7631,6 +7663,10 @@
|
||||
"app.schemas.openai -> app.schemas.common",
|
||||
"app.schemas.plugin -> app.schemas",
|
||||
"app.schemas.plugin -> app.schemas.common",
|
||||
"app.schemas.query -> app.schemas",
|
||||
"app.schemas.query -> app.schemas.common",
|
||||
"app.schemas.query -> app.schemas.media",
|
||||
"app.schemas.query -> app.schemas.types",
|
||||
"app.schemas.response -> app.runtime",
|
||||
"app.schemas.response -> app.runtime.localization",
|
||||
"app.schemas.search -> app.schemas",
|
||||
@@ -7755,6 +7791,10 @@
|
||||
"app.sdk.plugins -> app.runtime.extensions",
|
||||
"app.sdk.plugins -> app.runtime.extensions.module_manager",
|
||||
"app.sdk.plugins -> app.runtime.extensions.plugin_manager",
|
||||
"app.sdk.queries -> app.application",
|
||||
"app.sdk.queries -> app.application.query",
|
||||
"app.sdk.queries -> app.schemas",
|
||||
"app.sdk.queries -> app.schemas.query",
|
||||
"app.sdk.security -> app.adapters",
|
||||
"app.sdk.security -> app.adapters.web",
|
||||
"app.sdk.security -> app.adapters.web.security",
|
||||
@@ -7948,6 +7988,7 @@
|
||||
"app.startup.initializers.modules -> app.application.plugin",
|
||||
"app.startup.initializers.modules -> app.application.plugin.runtime",
|
||||
"app.startup.initializers.modules -> app.application.plugin.transaction",
|
||||
"app.startup.initializers.modules -> app.application.query",
|
||||
"app.startup.initializers.modules -> app.application.security",
|
||||
"app.startup.initializers.modules -> app.application.security.auth",
|
||||
"app.startup.initializers.modules -> app.application.security.passkey",
|
||||
@@ -7982,6 +8023,7 @@
|
||||
"app.startup.initializers.modules -> app.db.adapters.outbox",
|
||||
"app.startup.initializers.modules -> app.db.adapters.pluginidentity",
|
||||
"app.startup.initializers.modules -> app.db.adapters.plugininstallation",
|
||||
"app.startup.initializers.modules -> app.db.adapters.query",
|
||||
"app.startup.initializers.modules -> app.db.adapters.site",
|
||||
"app.startup.initializers.modules -> app.db.adapters.subscription",
|
||||
"app.startup.initializers.modules -> app.db.adapters.transaction",
|
||||
@@ -8347,7 +8389,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 845,
|
||||
"module_count": 850,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -8655,6 +8697,7 @@
|
||||
"app.application.plugin.runtime",
|
||||
"app.application.plugin.source",
|
||||
"app.application.plugin.transaction",
|
||||
"app.application.query",
|
||||
"app.application.recognition",
|
||||
"app.application.rss",
|
||||
"app.application.rules",
|
||||
@@ -8743,6 +8786,7 @@
|
||||
"app.db.adapters.outbox",
|
||||
"app.db.adapters.pluginidentity",
|
||||
"app.db.adapters.plugininstallation",
|
||||
"app.db.adapters.query",
|
||||
"app.db.adapters.site",
|
||||
"app.db.adapters.subscription",
|
||||
"app.db.adapters.transaction",
|
||||
@@ -8795,6 +8839,7 @@
|
||||
"app.db.oper.passkey",
|
||||
"app.db.oper.plugindata",
|
||||
"app.db.oper.pluginidentity",
|
||||
"app.db.oper.query",
|
||||
"app.db.oper.site",
|
||||
"app.db.oper.subscribe",
|
||||
"app.db.oper.subscribehistory",
|
||||
@@ -9115,6 +9160,7 @@
|
||||
"app.schemas.notification",
|
||||
"app.schemas.openai",
|
||||
"app.schemas.plugin",
|
||||
"app.schemas.query",
|
||||
"app.schemas.response",
|
||||
"app.schemas.rule",
|
||||
"app.schemas.search",
|
||||
@@ -9147,6 +9193,7 @@
|
||||
"app.sdk.media",
|
||||
"app.sdk.network",
|
||||
"app.sdk.plugins",
|
||||
"app.sdk.queries",
|
||||
"app.sdk.security",
|
||||
"app.sdk.services",
|
||||
"app.sdk.string",
|
||||
|
||||
@@ -458,18 +458,12 @@
|
||||
"app/db/models/workflow.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/db/oper/downloadhistory.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/db/oper/message.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/db/oper/site.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/db/oper/subscribe.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/db/oper/systemconfig.py": {
|
||||
"I001": 1
|
||||
},
|
||||
@@ -1113,9 +1107,6 @@
|
||||
"tests/test_agent_lazy_runtime_boundary.py": {
|
||||
"F401": 1
|
||||
},
|
||||
"tests/test_agent_lifecycle.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_agent_llm_capability.py": {
|
||||
"I001": 1
|
||||
},
|
||||
|
||||
@@ -10188,6 +10188,168 @@
|
||||
"target": "app.runtime.extensions.plugin_manager.PluginManager"
|
||||
}
|
||||
],
|
||||
"app.sdk.queries": [
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "DEFAULT_QUERY_PAGE_SIZE",
|
||||
"target": "app.schemas.query.DEFAULT_QUERY_PAGE_SIZE"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "DownloadHistoryFilter",
|
||||
"target": "app.schemas.query.DownloadHistoryFilter"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "DownloadHistorySnapshot",
|
||||
"target": "app.schemas.query.DownloadHistorySnapshot"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "MAX_QUERY_PAGE_SIZE",
|
||||
"target": "app.schemas.query.MAX_QUERY_PAGE_SIZE"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "MediaIdentityQuery",
|
||||
"target": "app.schemas.query.MediaIdentityQuery"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "QueryPage",
|
||||
"target": "app.schemas.query.QueryPage"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "QueryPageRequest",
|
||||
"target": "app.schemas.query.QueryPageRequest"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "QuerySort",
|
||||
"target": "app.schemas.query.QuerySort"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "QuerySortDirection",
|
||||
"target": "app.schemas.query.QuerySortDirection"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "QuerySortField",
|
||||
"target": "app.schemas.query.QuerySortField"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "SubscriptionFilter",
|
||||
"target": "app.schemas.query.SubscriptionFilter"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "SubscriptionHistoryFilter",
|
||||
"target": "app.schemas.query.SubscriptionHistoryFilter"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "SubscriptionHistorySnapshot",
|
||||
"target": "app.schemas.query.SubscriptionHistorySnapshot"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "SubscriptionSnapshot",
|
||||
"target": "app.schemas.query.SubscriptionSnapshot"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "TransferHistoryFilter",
|
||||
"target": "app.schemas.query.TransferHistoryFilter"
|
||||
},
|
||||
{
|
||||
"kind": "import",
|
||||
"name": "TransferHistorySnapshot",
|
||||
"target": "app.schemas.query.TransferHistorySnapshot"
|
||||
},
|
||||
{
|
||||
"kind": "AsyncFunctionDef",
|
||||
"name": "async_get_download_history",
|
||||
"target": ""
|
||||
},
|
||||
{
|
||||
"kind": "AsyncFunctionDef",
|
||||
"name": "async_get_subscription",
|
||||
"target": ""
|
||||
},
|
||||
{
|
||||
"kind": "AsyncFunctionDef",
|
||||
"name": "async_get_subscription_history",
|
||||
"target": ""
|
||||
},
|
||||
{
|
||||
"kind": "AsyncFunctionDef",
|
||||
"name": "async_get_transfer_history",
|
||||
"target": ""
|
||||
},
|
||||
{
|
||||
"kind": "AsyncFunctionDef",
|
||||
"name": "async_list_download_history",
|
||||
"target": ""
|
||||
},
|
||||
{
|
||||
"kind": "AsyncFunctionDef",
|
||||
"name": "async_list_subscription_history",
|
||||
"target": ""
|
||||
},
|
||||
{
|
||||
"kind": "AsyncFunctionDef",
|
||||
"name": "async_list_subscriptions",
|
||||
"target": ""
|
||||
},
|
||||
{
|
||||
"kind": "AsyncFunctionDef",
|
||||
"name": "async_list_transfer_history",
|
||||
"target": ""
|
||||
},
|
||||
{
|
||||
"kind": "FunctionDef",
|
||||
"name": "get_download_history",
|
||||
"target": ""
|
||||
},
|
||||
{
|
||||
"kind": "FunctionDef",
|
||||
"name": "get_subscription",
|
||||
"target": ""
|
||||
},
|
||||
{
|
||||
"kind": "FunctionDef",
|
||||
"name": "get_subscription_history",
|
||||
"target": ""
|
||||
},
|
||||
{
|
||||
"kind": "FunctionDef",
|
||||
"name": "get_transfer_history",
|
||||
"target": ""
|
||||
},
|
||||
{
|
||||
"kind": "FunctionDef",
|
||||
"name": "list_download_history",
|
||||
"target": ""
|
||||
},
|
||||
{
|
||||
"kind": "FunctionDef",
|
||||
"name": "list_subscription_history",
|
||||
"target": ""
|
||||
},
|
||||
{
|
||||
"kind": "FunctionDef",
|
||||
"name": "list_subscriptions",
|
||||
"target": ""
|
||||
},
|
||||
{
|
||||
"kind": "FunctionDef",
|
||||
"name": "list_transfer_history",
|
||||
"target": ""
|
||||
}
|
||||
],
|
||||
"app.sdk.security": [
|
||||
{
|
||||
"kind": "import",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"repeat": 3,
|
||||
"targets": {
|
||||
"app.startup.lifecycle": {
|
||||
"loaded_app_module_count": 388,
|
||||
"loaded_app_module_count": 392,
|
||||
"max_ms": 1035.341,
|
||||
"median_ms": 993.245,
|
||||
"min_ms": 991.998,
|
||||
@@ -17,7 +17,7 @@
|
||||
]
|
||||
},
|
||||
"app.factory": {
|
||||
"loaded_app_module_count": 400,
|
||||
"loaded_app_module_count": 404,
|
||||
"max_ms": 1006.172,
|
||||
"median_ms": 1005.88,
|
||||
"min_ms": 1003.841,
|
||||
@@ -28,7 +28,7 @@
|
||||
]
|
||||
},
|
||||
"app.main": {
|
||||
"loaded_app_module_count": 402,
|
||||
"loaded_app_module_count": 406,
|
||||
"max_ms": 1085.652,
|
||||
"median_ms": 1055.994,
|
||||
"min_ms": 1043.212,
|
||||
|
||||
@@ -4,18 +4,20 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
|
||||
import app.agent.orchestrator as agent_module
|
||||
from app.application.messaging.agent import (
|
||||
create_web_agent_background_task,
|
||||
shutdown_web_agent_background_tasks,
|
||||
)
|
||||
from app.agent.memory import MemoryManager
|
||||
from app.agent.orchestrator import (
|
||||
AGENT_SESSION_QUEUE_MAX_SIZE,
|
||||
AgentManager,
|
||||
AgentManagerQueueFullError,
|
||||
AgentManagerUnavailableError,
|
||||
)
|
||||
from app.agent.memory import MemoryManager
|
||||
from app.agent.tools.base import reopen_blocking_executors
|
||||
from app.application import query as query_application
|
||||
from app.application.messaging.agent import (
|
||||
create_web_agent_background_task,
|
||||
shutdown_web_agent_background_tasks,
|
||||
)
|
||||
from app.sdk import queries as query_sdk
|
||||
from app.startup.initializers import agent as agent_initializer
|
||||
from app.startup.initializers import modules as modules_initializer
|
||||
|
||||
@@ -196,12 +198,16 @@ async def test_agent_initialization_failure_does_not_stop_module_startup(
|
||||
check_auth = MagicMock()
|
||||
monkeypatch.setattr(modules_initializer, "start_frontend", start_frontend)
|
||||
monkeypatch.setattr(modules_initializer, "check_auth", check_auth)
|
||||
monkeypatch.setattr(query_application, "_configured_data_query_service", None)
|
||||
|
||||
try:
|
||||
runtime = await modules_initializer.init_modules()
|
||||
assert runtime.workflow.system_config() is (
|
||||
modules_initializer.get_configured_system_config()
|
||||
)
|
||||
query_page = await query_sdk.async_list_subscriptions({"ids": [-1]})
|
||||
assert query_page.items == []
|
||||
assert query_page.total == 0
|
||||
finally:
|
||||
await modules_initializer.stop_database_worker()
|
||||
|
||||
|
||||
@@ -396,13 +396,20 @@ def test_plugin_settlement_cannot_bypass_task_registry_shutdown_budget(
|
||||
lifecycle.init_extra.side_effect = settle_plugins
|
||||
|
||||
async def run_lifespan() -> None:
|
||||
"""确认 context 能由 TaskRegistry 的失败结果立即结束。"""
|
||||
async with lifecycle.lifespan(FastAPI()):
|
||||
"""仅对关闭阶段计时,避免覆盖率启动开销污染停机预算断言。"""
|
||||
lifespan_context = lifecycle.lifespan(FastAPI())
|
||||
await lifespan_context.__aenter__()
|
||||
try:
|
||||
await started.wait()
|
||||
release.set()
|
||||
await asyncio.sleep(0)
|
||||
await asyncio.wait_for(
|
||||
lifespan_context.__aexit__(None, None, None),
|
||||
timeout=0.5,
|
||||
)
|
||||
finally:
|
||||
release.set()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
asyncio.run(asyncio.wait_for(run_lifespan(), timeout=0.5))
|
||||
asyncio.run(run_lifespan())
|
||||
|
||||
shutdown.assert_awaited_once_with(timeout_seconds=30.0)
|
||||
for name, step in shutdown_steps.items():
|
||||
|
||||
@@ -0,0 +1,917 @@
|
||||
"""统一插件只读查询 SDK 的真实 SQLite 合同测试。
|
||||
|
||||
这些用例从真实 SQLAlchemy 表读取,再经查询服务和 ``app.sdk.queries`` 返回,
|
||||
因此同时约束筛选、分页、DTO 投影以及异步数据库执行边界。所有数据都由共享的
|
||||
隔离 SQLite harness 写入;测试不调用外部服务。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.models.downloadhistory import DownloadHistory as DownloadHistoryModel
|
||||
from app.db.models.subscribe import Subscribe as SubscribeModel
|
||||
from app.db.models.subscribehistory import SubscribeHistory as SubscribeHistoryModel
|
||||
from app.db.models.transferhistory import TransferHistory as TransferHistoryModel
|
||||
from app.schemas.query import (
|
||||
DownloadHistoryFilter,
|
||||
DownloadHistorySnapshot,
|
||||
QueryPage,
|
||||
QueryPageRequest,
|
||||
QuerySort,
|
||||
QuerySortDirection,
|
||||
QuerySortField,
|
||||
SubscriptionFilter,
|
||||
SubscriptionHistoryFilter,
|
||||
SubscriptionHistorySnapshot,
|
||||
SubscriptionSnapshot,
|
||||
TransferHistoryFilter,
|
||||
TransferHistorySnapshot,
|
||||
)
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
TMDB = MediaSource.TMDB.value
|
||||
|
||||
|
||||
class _RecordingExecutor:
|
||||
"""记录服务是否把异步查询提交到独立执行线程。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
self.worker_thread_ids: list[int] = []
|
||||
|
||||
async def run(self, operation: Callable[[], Any]) -> Any:
|
||||
"""在线程中执行同步查询,模拟宿主数据库 worker 的调用合同。"""
|
||||
self.calls += 1
|
||||
|
||||
def invoke() -> Any:
|
||||
self.worker_thread_ids.append(threading.get_ident())
|
||||
return operation()
|
||||
|
||||
return await asyncio.to_thread(invoke)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def query_sdk(db, monkeypatch):
|
||||
"""装配真实数据查询适配器,并把 SDK 绑定到本用例的 SQLite 服务。"""
|
||||
from app.application import query as data_query_module
|
||||
from app.application.query import DataQueryService
|
||||
from app.db.adapters.query import SqlAlchemyDataQueryAdapter
|
||||
from app.db.session import SessionFactory
|
||||
from app.sdk import queries as sdk
|
||||
|
||||
adapter = SqlAlchemyDataQueryAdapter(SessionFactory)
|
||||
executor = _RecordingExecutor()
|
||||
service = DataQueryService(
|
||||
subscriptions=adapter,
|
||||
histories=adapter,
|
||||
async_executor=executor,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
data_query_module,
|
||||
"_configured_data_query_service",
|
||||
service,
|
||||
)
|
||||
return sdk, executor
|
||||
|
||||
|
||||
def _subscribe(
|
||||
name: str,
|
||||
*,
|
||||
media_id: str,
|
||||
state: str = "N",
|
||||
username: str = "alice",
|
||||
mtype: str = MediaType.TV.value,
|
||||
season: int | None = 1,
|
||||
episode_group: str | None = None,
|
||||
date: str = "2026-08-27 10:00:00",
|
||||
music_type: str | None = None,
|
||||
manual_total_episode: int | None = 0,
|
||||
) -> SubscribeModel:
|
||||
"""构造隔离用例使用的订阅行。"""
|
||||
return SubscribeModel(
|
||||
name=name,
|
||||
type=mtype,
|
||||
state=state,
|
||||
media_source=TMDB,
|
||||
media_id=media_id,
|
||||
season=season,
|
||||
episode_group=episode_group,
|
||||
username=username,
|
||||
date=date,
|
||||
music_type=music_type,
|
||||
manual_total_episode=manual_total_episode,
|
||||
)
|
||||
|
||||
|
||||
def _subscribe_history(
|
||||
name: str,
|
||||
*,
|
||||
media_id: str,
|
||||
username: str = "alice",
|
||||
mtype: str = MediaType.TV.value,
|
||||
season: int | None = 1,
|
||||
episode_group: str | None = None,
|
||||
date: str = "2026-08-27 10:00:00",
|
||||
music_type: str | None = None,
|
||||
) -> SubscribeHistoryModel:
|
||||
"""构造隔离用例使用的订阅完成历史行。"""
|
||||
return SubscribeHistoryModel(
|
||||
name=name,
|
||||
type=mtype,
|
||||
media_source=TMDB,
|
||||
media_id=media_id,
|
||||
season=season,
|
||||
episode_group=episode_group,
|
||||
username=username,
|
||||
date=date,
|
||||
music_type=music_type,
|
||||
)
|
||||
|
||||
|
||||
def _download_history(
|
||||
title: str,
|
||||
*,
|
||||
media_id: str,
|
||||
path: str,
|
||||
year: str = "2026",
|
||||
seasons: str | None = "S01",
|
||||
episodes: str | None = "E01",
|
||||
username: str = "alice",
|
||||
download_hash: str | None = "hash-1",
|
||||
episode_group: str | None = None,
|
||||
date: str = "2026-08-27 10:00:00",
|
||||
mtype: str = MediaType.TV.value,
|
||||
music_type: str | None = None,
|
||||
) -> DownloadHistoryModel:
|
||||
"""构造隔离用例使用的下载历史行。"""
|
||||
return DownloadHistoryModel(
|
||||
path=path,
|
||||
type=mtype,
|
||||
title=title,
|
||||
year=year,
|
||||
media_source=TMDB,
|
||||
media_id=media_id,
|
||||
music_type=music_type,
|
||||
seasons=seasons,
|
||||
episodes=episodes,
|
||||
username=username,
|
||||
download_hash=download_hash,
|
||||
episode_group=episode_group,
|
||||
date=date,
|
||||
)
|
||||
|
||||
|
||||
def _transfer_history(
|
||||
title: str,
|
||||
*,
|
||||
media_id: str | None,
|
||||
src: str,
|
||||
dest: str,
|
||||
year: str = "2026",
|
||||
seasons: str | None = "S01",
|
||||
episodes: str | None = "E01",
|
||||
download_hash: str | None = "hash-1",
|
||||
episode_group: str | None = None,
|
||||
status: bool | None = True,
|
||||
date: str = "2026-08-27 10:00:00",
|
||||
mtype: str = MediaType.TV.value,
|
||||
) -> TransferHistoryModel:
|
||||
"""构造隔离用例使用的整理历史行。"""
|
||||
return TransferHistoryModel(
|
||||
src=src,
|
||||
src_storage="local",
|
||||
dest=dest,
|
||||
type=mtype,
|
||||
title=title,
|
||||
year=year,
|
||||
media_source=TMDB if media_id is not None else None,
|
||||
media_id=media_id,
|
||||
seasons=seasons,
|
||||
episodes=episodes,
|
||||
download_hash=download_hash,
|
||||
episode_group=episode_group,
|
||||
status=status,
|
||||
date=date,
|
||||
)
|
||||
|
||||
|
||||
def _assert_projected_page(page: QueryPage, dto_type: type[Any], model_type: type[Any], row_id: int) -> None:
|
||||
"""断言分页结果是稳定 DTO,而不是 ORM 行或延迟加载对象。"""
|
||||
assert isinstance(page, QueryPage)
|
||||
assert page.total == 1
|
||||
assert len(page.items) == 1
|
||||
item = page.items[0]
|
||||
assert isinstance(item, dto_type)
|
||||
assert not isinstance(item, model_type)
|
||||
assert item.id == row_id
|
||||
assert item.media_source == MediaSource.TMDB
|
||||
|
||||
|
||||
def test_sdk_projects_subscription_and_three_history_domains_to_dtos(db, query_sdk):
|
||||
"""订阅、订阅完成历史、下载历史、整理历史均只返回 Pydantic 投影。"""
|
||||
sdk, _executor = query_sdk
|
||||
subscribe = db.add(
|
||||
_subscribe(
|
||||
"订阅 DTO",
|
||||
media_id="dto-sub",
|
||||
manual_total_episode=1,
|
||||
)
|
||||
)
|
||||
subscribe_history = db.add(_subscribe_history("订阅历史 DTO", media_id="dto-sub-history"))
|
||||
download_history = db.add(_download_history("下载历史 DTO", media_id="dto-download", path="/dto/download"))
|
||||
transfer_history = db.add(
|
||||
_transfer_history(
|
||||
"整理历史 DTO",
|
||||
media_id="dto-transfer",
|
||||
src="/dto/src",
|
||||
dest="/dto/dest",
|
||||
)
|
||||
)
|
||||
|
||||
_assert_projected_page(
|
||||
subscription_page := sdk.list_subscriptions(SubscriptionFilter(media_source=TMDB, media_id="dto-sub")),
|
||||
SubscriptionSnapshot,
|
||||
SubscribeModel,
|
||||
subscribe.id,
|
||||
)
|
||||
assert subscription_page.items[0].manual_total_episode == 1
|
||||
_assert_projected_page(
|
||||
sdk.list_subscription_history(
|
||||
SubscriptionHistoryFilter(
|
||||
media_source=TMDB,
|
||||
media_id="dto-sub-history",
|
||||
)
|
||||
),
|
||||
SubscriptionHistorySnapshot,
|
||||
SubscribeHistoryModel,
|
||||
subscribe_history.id,
|
||||
)
|
||||
_assert_projected_page(
|
||||
sdk.list_download_history(DownloadHistoryFilter(media_source=TMDB, media_id="dto-download")),
|
||||
DownloadHistorySnapshot,
|
||||
DownloadHistoryModel,
|
||||
download_history.id,
|
||||
)
|
||||
_assert_projected_page(
|
||||
sdk.list_transfer_history(TransferHistoryFilter(media_source=TMDB, media_id="dto-transfer")),
|
||||
TransferHistorySnapshot,
|
||||
TransferHistoryModel,
|
||||
transfer_history.id,
|
||||
)
|
||||
|
||||
|
||||
def test_sdk_applies_combined_filters_in_each_query_domain(db, query_sdk):
|
||||
"""多个筛选条件必须按 AND 组合,不能只应用媒体身份或单个主条件。"""
|
||||
sdk, _executor = query_sdk
|
||||
|
||||
db.add(
|
||||
_subscribe(
|
||||
"订阅命中",
|
||||
media_id="combo-sub",
|
||||
state="N",
|
||||
username="alice",
|
||||
season=2,
|
||||
episode_group="eg-a",
|
||||
),
|
||||
_subscribe(
|
||||
"状态不符",
|
||||
media_id="combo-sub",
|
||||
state="R",
|
||||
username="alice",
|
||||
season=2,
|
||||
episode_group="eg-a",
|
||||
),
|
||||
_subscribe(
|
||||
"用户不符",
|
||||
media_id="combo-sub",
|
||||
state="N",
|
||||
username="bob",
|
||||
season=2,
|
||||
episode_group="eg-a",
|
||||
),
|
||||
_subscribe(
|
||||
"季不符",
|
||||
media_id="combo-sub",
|
||||
state="N",
|
||||
username="alice",
|
||||
season=1,
|
||||
episode_group="eg-a",
|
||||
),
|
||||
_subscribe(
|
||||
"剧集组不符",
|
||||
media_id="combo-sub",
|
||||
state="N",
|
||||
username="alice",
|
||||
season=2,
|
||||
episode_group="eg-b",
|
||||
),
|
||||
_subscribe(
|
||||
"类型不符",
|
||||
media_id="combo-sub",
|
||||
state="N",
|
||||
username="alice",
|
||||
mtype=MediaType.MOVIE.value,
|
||||
season=2,
|
||||
episode_group="eg-a",
|
||||
),
|
||||
)
|
||||
subscribe_page = sdk.list_subscriptions(
|
||||
SubscriptionFilter(
|
||||
media_source=TMDB,
|
||||
media_id="combo-sub",
|
||||
states=("N",),
|
||||
usernames=("alice",),
|
||||
media_types=(MediaType.TV,),
|
||||
season=2,
|
||||
episode_group="eg-a",
|
||||
)
|
||||
)
|
||||
assert [item.name for item in subscribe_page.items] == ["订阅命中"]
|
||||
|
||||
db.add(
|
||||
_subscribe_history(
|
||||
"历史命中",
|
||||
media_id="combo-sub-history",
|
||||
username="alice",
|
||||
season=2,
|
||||
episode_group="eg-a",
|
||||
),
|
||||
_subscribe_history(
|
||||
"历史用户不符",
|
||||
media_id="combo-sub-history",
|
||||
username="bob",
|
||||
season=2,
|
||||
episode_group="eg-a",
|
||||
),
|
||||
_subscribe_history(
|
||||
"历史季不符",
|
||||
media_id="combo-sub-history",
|
||||
username="alice",
|
||||
season=1,
|
||||
episode_group="eg-a",
|
||||
),
|
||||
_subscribe_history(
|
||||
"历史类型不符",
|
||||
media_id="combo-sub-history",
|
||||
username="alice",
|
||||
mtype=MediaType.MOVIE.value,
|
||||
season=2,
|
||||
episode_group="eg-a",
|
||||
),
|
||||
)
|
||||
history_page = sdk.list_subscription_history(
|
||||
SubscriptionHistoryFilter(
|
||||
media_source=TMDB,
|
||||
media_id="combo-sub-history",
|
||||
usernames=("alice",),
|
||||
media_types=(MediaType.TV,),
|
||||
season=2,
|
||||
episode_group="eg-a",
|
||||
)
|
||||
)
|
||||
assert [item.name for item in history_page.items] == ["历史命中"]
|
||||
|
||||
db.add(
|
||||
_download_history(
|
||||
"Combo Film",
|
||||
media_id="combo-download",
|
||||
path="/combo/match.mkv",
|
||||
year="2026",
|
||||
seasons="S02",
|
||||
episodes="E03",
|
||||
username="alice",
|
||||
download_hash="combo-hash",
|
||||
episode_group="eg-a",
|
||||
),
|
||||
_download_history(
|
||||
"Combo Film",
|
||||
media_id="combo-download",
|
||||
path="/combo/wrong-user.mkv",
|
||||
year="2026",
|
||||
seasons="S02",
|
||||
episodes="E03",
|
||||
username="bob",
|
||||
download_hash="combo-hash",
|
||||
episode_group="eg-a",
|
||||
),
|
||||
_download_history(
|
||||
"Combo Film",
|
||||
media_id="combo-download",
|
||||
path="/combo/wrong-episode.mkv",
|
||||
year="2026",
|
||||
seasons="S02",
|
||||
episodes="E04",
|
||||
username="alice",
|
||||
download_hash="combo-hash",
|
||||
episode_group="eg-a",
|
||||
),
|
||||
)
|
||||
download_page = sdk.list_download_history(
|
||||
DownloadHistoryFilter(
|
||||
media_source=TMDB,
|
||||
media_id="combo-download",
|
||||
media_types=(MediaType.TV,),
|
||||
title="Combo Film",
|
||||
text="match",
|
||||
year="2026",
|
||||
seasons="S02",
|
||||
episodes="E03",
|
||||
path="/combo/match.mkv",
|
||||
download_hash="combo-hash",
|
||||
username="alice",
|
||||
episode_group="eg-a",
|
||||
)
|
||||
)
|
||||
assert [item.path for item in download_page.items] == ["/combo/match.mkv"]
|
||||
|
||||
db.add(
|
||||
_transfer_history(
|
||||
"Transfer Combo",
|
||||
media_id="combo-transfer",
|
||||
src="/combo/src-match.mkv",
|
||||
dest="/combo/dest-match.mkv",
|
||||
year="2026",
|
||||
seasons="S02",
|
||||
episodes="E03",
|
||||
download_hash="transfer-hash",
|
||||
episode_group="eg-a",
|
||||
),
|
||||
_transfer_history(
|
||||
"Transfer Combo",
|
||||
media_id="combo-transfer",
|
||||
src="/combo/src-failed.mkv",
|
||||
dest="/combo/dest-failed.mkv",
|
||||
year="2026",
|
||||
seasons="S02",
|
||||
episodes="E03",
|
||||
download_hash="transfer-hash",
|
||||
episode_group="eg-a",
|
||||
status=False,
|
||||
),
|
||||
_transfer_history(
|
||||
"Transfer No Identity",
|
||||
media_id=None,
|
||||
src="/combo/src-no-id.mkv",
|
||||
dest="/combo/dest-no-id.mkv",
|
||||
year="2026",
|
||||
seasons="S02",
|
||||
episodes="E03",
|
||||
download_hash="transfer-hash",
|
||||
episode_group="eg-a",
|
||||
),
|
||||
)
|
||||
transfer_page = sdk.list_transfer_history(
|
||||
TransferHistoryFilter(
|
||||
media_types=(MediaType.TV,),
|
||||
media_sources=(MediaSource.TMDB,),
|
||||
require_media_identity=True,
|
||||
title="Transfer Combo",
|
||||
text="dest-match",
|
||||
year="2026",
|
||||
seasons="S02",
|
||||
episodes="E03",
|
||||
src="/combo/src-match.mkv",
|
||||
dest="/combo/dest-match.mkv",
|
||||
status=True,
|
||||
download_hash="transfer-hash",
|
||||
episode_group="eg-a",
|
||||
)
|
||||
)
|
||||
assert [item.src for item in transfer_page.items] == ["/combo/src-match.mkv"]
|
||||
|
||||
|
||||
def test_sdk_pagination_reports_total_and_stable_date_id_order(db, query_sdk):
|
||||
"""同日期记录按 ID 打破平局,跨页总数和结果顺序保持稳定。"""
|
||||
sdk, _executor = query_sdk
|
||||
rows = db.add(
|
||||
_download_history(
|
||||
"排序一",
|
||||
media_id="sort-download",
|
||||
path="/sort/one",
|
||||
date="2026-08-27 10:00:00",
|
||||
download_hash="sort-1",
|
||||
),
|
||||
_download_history(
|
||||
"排序二",
|
||||
media_id="sort-download",
|
||||
path="/sort/two",
|
||||
date="2026-08-27 10:00:00",
|
||||
download_hash="sort-2",
|
||||
),
|
||||
_download_history(
|
||||
"排序三",
|
||||
media_id="sort-download",
|
||||
path="/sort/three",
|
||||
date="2026-08-26 10:00:00",
|
||||
download_hash="sort-3",
|
||||
),
|
||||
)
|
||||
rows = list(rows)
|
||||
expected_desc = sorted(rows, key=lambda row: (row.date, row.id), reverse=True)
|
||||
filters = DownloadHistoryFilter(media_source=TMDB, media_id="sort-download")
|
||||
|
||||
page_one = sdk.list_download_history(
|
||||
filters,
|
||||
QueryPageRequest(page=1, count=2),
|
||||
)
|
||||
page_two = sdk.list_download_history(
|
||||
filters,
|
||||
QueryPageRequest(page=2, count=2),
|
||||
)
|
||||
|
||||
assert page_one.total == 3
|
||||
assert page_one.page == 1
|
||||
assert page_one.count == 2
|
||||
assert page_one.has_next is True
|
||||
assert [item.id for item in page_one.items] == [row.id for row in expected_desc[:2]]
|
||||
assert page_two.total == 3
|
||||
assert page_two.has_next is False
|
||||
assert [item.id for item in page_two.items] == [expected_desc[2].id]
|
||||
|
||||
asc_page = sdk.list_download_history(
|
||||
filters,
|
||||
QueryPageRequest(
|
||||
page=1,
|
||||
count=3,
|
||||
sort=QuerySort(
|
||||
field=QuerySortField.DATE,
|
||||
direction=QuerySortDirection.ASC,
|
||||
),
|
||||
),
|
||||
)
|
||||
expected_asc = sorted(rows, key=lambda row: (row.date, row.id))
|
||||
assert [item.id for item in asc_page.items] == [row.id for row in expected_asc]
|
||||
|
||||
|
||||
def test_structured_text_fields_are_exact_and_text_search_is_explicit(db, query_sdk):
|
||||
"""结构化字段保持精确匹配,只有 text 承担转义后的模糊搜索。"""
|
||||
sdk, _executor = query_sdk
|
||||
db.add(
|
||||
_download_history(
|
||||
"Exact Film Extended",
|
||||
media_id="exact-download",
|
||||
path="/exact/extended.mkv",
|
||||
),
|
||||
_download_history(
|
||||
"Exact Film",
|
||||
media_id="exact-download",
|
||||
path="/exact/base.mkv",
|
||||
),
|
||||
_transfer_history(
|
||||
"Exact Transfer Extended",
|
||||
media_id="exact-transfer",
|
||||
src="/exact/extended-src.mkv",
|
||||
dest="/exact/extended-dest.mkv",
|
||||
),
|
||||
_transfer_history(
|
||||
"Exact Transfer",
|
||||
media_id="exact-transfer",
|
||||
src="/exact/base-src.mkv",
|
||||
dest="/exact/base-dest.mkv",
|
||||
),
|
||||
)
|
||||
|
||||
exact_downloads = sdk.list_download_history(
|
||||
DownloadHistoryFilter(
|
||||
media_source=TMDB,
|
||||
media_id="exact-download",
|
||||
title="Exact Film",
|
||||
)
|
||||
)
|
||||
assert [item.path for item in exact_downloads.items] == ["/exact/base.mkv"]
|
||||
fuzzy_downloads = sdk.list_download_history(
|
||||
DownloadHistoryFilter(
|
||||
media_source=TMDB,
|
||||
media_id="exact-download",
|
||||
text="extended",
|
||||
)
|
||||
)
|
||||
assert [item.path for item in fuzzy_downloads.items] == ["/exact/extended.mkv"]
|
||||
|
||||
exact_transfers = sdk.list_transfer_history(
|
||||
TransferHistoryFilter(
|
||||
media_source=TMDB,
|
||||
media_id="exact-transfer",
|
||||
title="Exact Transfer",
|
||||
)
|
||||
)
|
||||
assert [item.src for item in exact_transfers.items] == ["/exact/base-src.mkv"]
|
||||
fuzzy_transfers = sdk.list_transfer_history(
|
||||
TransferHistoryFilter(
|
||||
media_source=TMDB,
|
||||
media_id="exact-transfer",
|
||||
text="extended-dest",
|
||||
)
|
||||
)
|
||||
assert [item.src for item in fuzzy_transfers.items] == ["/exact/extended-src.mkv"]
|
||||
|
||||
|
||||
def test_snapshots_normalize_legacy_identity_and_transfer_status():
|
||||
"""旧半对身份和 NULL 整理状态不得使分页投影失败或产生假成功。"""
|
||||
dirty_transfer = SimpleNamespace(
|
||||
id=1,
|
||||
media_source=TMDB,
|
||||
media_id=" ",
|
||||
status=None,
|
||||
)
|
||||
|
||||
snapshot = TransferHistorySnapshot.model_validate(dirty_transfer)
|
||||
|
||||
assert snapshot.media_source is None
|
||||
assert snapshot.media_id is None
|
||||
assert snapshot.status is False
|
||||
|
||||
|
||||
def test_transfer_failure_filter_includes_legacy_null_status(db, query_sdk):
|
||||
"""失败筛选与快照语义一致,包含状态尚未回填的旧整理记录。"""
|
||||
sdk, _executor = query_sdk
|
||||
legacy_row = db.add(
|
||||
_transfer_history(
|
||||
"Legacy null status",
|
||||
media_id="legacy-status",
|
||||
src="/legacy/status-src.mkv",
|
||||
dest="/legacy/status-dest.mkv",
|
||||
status=None,
|
||||
)
|
||||
)
|
||||
legacy_row.status = None
|
||||
db.session.commit()
|
||||
|
||||
page = sdk.list_transfer_history(
|
||||
TransferHistoryFilter(
|
||||
media_source=TMDB,
|
||||
media_id="legacy-status",
|
||||
status=False,
|
||||
)
|
||||
)
|
||||
|
||||
assert [item.id for item in page.items] == [legacy_row.id]
|
||||
assert page.items[0].status is False
|
||||
|
||||
|
||||
def test_transfer_required_identity_excludes_unparseable_legacy_source(db, query_sdk):
|
||||
"""有效身份筛选不得返回随后会被快照降级为空身份的脏来源。"""
|
||||
sdk, _executor = query_sdk
|
||||
valid_row = db.add(
|
||||
_transfer_history(
|
||||
"Valid dynamic source",
|
||||
media_id="valid-dynamic",
|
||||
src="/valid/source.mkv",
|
||||
dest="/valid/dest.mkv",
|
||||
)
|
||||
)
|
||||
invalid_row = db.add(
|
||||
_transfer_history(
|
||||
"Invalid legacy source",
|
||||
media_id="invalid-source",
|
||||
src="/invalid/source.mkv",
|
||||
dest="/invalid/dest.mkv",
|
||||
)
|
||||
)
|
||||
valid_row.media_source = "plugin-source"
|
||||
invalid_row.media_source = "invalid!"
|
||||
db.session.commit()
|
||||
|
||||
page = sdk.list_transfer_history(
|
||||
TransferHistoryFilter(
|
||||
ids=(valid_row.id, invalid_row.id),
|
||||
require_media_identity=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert [item.id for item in page.items] == [valid_row.id]
|
||||
assert page.items[0].media_source == MediaSource("plugin-source")
|
||||
|
||||
|
||||
def test_query_snapshots_are_owned_by_the_sdk_contract_module():
|
||||
"""公开查询返回值由独立快照定义,不复用宿主写入或 API 响应模型。"""
|
||||
snapshots = (
|
||||
SubscriptionSnapshot,
|
||||
SubscriptionHistorySnapshot,
|
||||
DownloadHistorySnapshot,
|
||||
TransferHistorySnapshot,
|
||||
)
|
||||
|
||||
assert all(snapshot.__module__ == "app.schemas.query" for snapshot in snapshots)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{"media_source": TMDB},
|
||||
{"media_id": "half-id"},
|
||||
{"media_source": TMDB, "media_id": ""},
|
||||
{"media_source": TMDB, "media_id": " "},
|
||||
{"media_source": TMDB, "media_id": "0"},
|
||||
{"media_source": "not valid!", "media_id": "id"},
|
||||
],
|
||||
)
|
||||
def test_sdk_rejects_invalid_or_half_media_identity_fail_closed(query_sdk, payload):
|
||||
"""媒体身份不完整、空白、零值或未知来源不得退化成全表查询。"""
|
||||
sdk, _executor = query_sdk
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
sdk.list_download_history(payload)
|
||||
|
||||
|
||||
def test_sdk_get_returns_none_for_missing_records(query_sdk):
|
||||
"""四个查询领域的按 ID 读取在未命中时统一返回 None。"""
|
||||
sdk, _executor = query_sdk
|
||||
missing_id = 2_147_483_647
|
||||
|
||||
assert sdk.get_subscription(missing_id) is None
|
||||
assert sdk.get_subscription_history(missing_id) is None
|
||||
assert sdk.get_download_history(missing_id) is None
|
||||
assert sdk.get_transfer_history(missing_id) is None
|
||||
|
||||
|
||||
def test_sdk_get_projects_records_and_async_uses_executor(db, query_sdk):
|
||||
"""四类 getter 均返回 DTO,异步入口与同步一致且经过数据库 executor。"""
|
||||
sdk, executor = query_sdk
|
||||
subscribe = db.add(_subscribe("Getter subscribe", media_id="getter-subscription"))
|
||||
subscribe_history = db.add(
|
||||
_subscribe_history(
|
||||
"Getter subscription history",
|
||||
media_id="getter-subscription-history",
|
||||
)
|
||||
)
|
||||
download = db.add(
|
||||
_download_history(
|
||||
"Getter download history",
|
||||
media_id="getter-download",
|
||||
path="/getter/download",
|
||||
)
|
||||
)
|
||||
transfer = db.add(
|
||||
_transfer_history(
|
||||
"Getter transfer history",
|
||||
media_id="getter-transfer",
|
||||
src="/getter/src",
|
||||
dest="/getter/dest",
|
||||
)
|
||||
)
|
||||
caller_thread_id = threading.get_ident()
|
||||
cases = (
|
||||
(
|
||||
sdk.get_subscription,
|
||||
sdk.async_get_subscription,
|
||||
subscribe,
|
||||
SubscriptionSnapshot,
|
||||
),
|
||||
(
|
||||
sdk.get_subscription_history,
|
||||
sdk.async_get_subscription_history,
|
||||
subscribe_history,
|
||||
SubscriptionHistorySnapshot,
|
||||
),
|
||||
(
|
||||
sdk.get_download_history,
|
||||
sdk.async_get_download_history,
|
||||
download,
|
||||
DownloadHistorySnapshot,
|
||||
),
|
||||
(
|
||||
sdk.get_transfer_history,
|
||||
sdk.async_get_transfer_history,
|
||||
transfer,
|
||||
TransferHistorySnapshot,
|
||||
),
|
||||
)
|
||||
|
||||
for sync_call, async_call, model, snapshot_type in cases:
|
||||
sync_item = sync_call(model.id)
|
||||
calls_before = executor.calls
|
||||
async_item = asyncio.run(async_call(model.id))
|
||||
|
||||
assert async_item == sync_item
|
||||
assert isinstance(sync_item, snapshot_type)
|
||||
assert not isinstance(sync_item, type(model))
|
||||
assert sync_item.id == model.id
|
||||
assert executor.calls == calls_before + 1
|
||||
|
||||
assert executor.worker_thread_ids
|
||||
assert all(thread_id != caller_thread_id for thread_id in executor.worker_thread_ids)
|
||||
|
||||
|
||||
def test_sdk_sync_async_semantics_match_and_async_uses_executor(db, query_sdk):
|
||||
"""四个查询门面的异步结果与同步一致,并交给 executor 线程。"""
|
||||
sdk, executor = query_sdk
|
||||
subscribe = db.add(_subscribe("订阅同步异步", media_id="async-subscription"))
|
||||
subscribe_history = db.add(
|
||||
_subscribe_history(
|
||||
"订阅历史同步异步",
|
||||
media_id="async-subscription-history",
|
||||
)
|
||||
)
|
||||
download = db.add(
|
||||
_download_history(
|
||||
"同步异步一致",
|
||||
media_id="async-download",
|
||||
path="/async/download",
|
||||
)
|
||||
)
|
||||
transfer = db.add(
|
||||
_transfer_history(
|
||||
"整理历史同步异步",
|
||||
media_id="async-transfer",
|
||||
src="/async/src",
|
||||
dest="/async/dest",
|
||||
)
|
||||
)
|
||||
request = QueryPageRequest(page=1, count=10)
|
||||
caller_thread_id = threading.get_ident()
|
||||
|
||||
cases = (
|
||||
(
|
||||
sdk.list_subscriptions,
|
||||
sdk.async_list_subscriptions,
|
||||
SubscriptionFilter(
|
||||
media_source=TMDB,
|
||||
media_id="async-subscription",
|
||||
),
|
||||
subscribe.id,
|
||||
),
|
||||
(
|
||||
sdk.list_subscription_history,
|
||||
sdk.async_list_subscription_history,
|
||||
SubscriptionHistoryFilter(
|
||||
media_source=TMDB,
|
||||
media_id="async-subscription-history",
|
||||
),
|
||||
subscribe_history.id,
|
||||
),
|
||||
(
|
||||
sdk.list_download_history,
|
||||
sdk.async_list_download_history,
|
||||
DownloadHistoryFilter(
|
||||
media_source=TMDB,
|
||||
media_id="async-download",
|
||||
),
|
||||
download.id,
|
||||
),
|
||||
(
|
||||
sdk.list_transfer_history,
|
||||
sdk.async_list_transfer_history,
|
||||
TransferHistoryFilter(
|
||||
media_source=TMDB,
|
||||
media_id="async-transfer",
|
||||
),
|
||||
transfer.id,
|
||||
),
|
||||
)
|
||||
for sync_call, async_call, filters, expected_id in cases:
|
||||
sync_page = sync_call(filters, request)
|
||||
calls_before = executor.calls
|
||||
async_page = asyncio.run(async_call(filters, request))
|
||||
|
||||
assert async_page == sync_page
|
||||
assert [item.id for item in async_page.items] == [expected_id]
|
||||
assert executor.calls == calls_before + 1
|
||||
|
||||
assert executor.worker_thread_ids
|
||||
assert all(thread_id != caller_thread_id for thread_id in executor.worker_thread_ids)
|
||||
|
||||
|
||||
def test_sdk_public_exports_do_not_leak_persistence_implementation(query_sdk):
|
||||
"""SDK 的机器可读公开合同只允许 DTO、筛选模型和查询函数。"""
|
||||
sdk, _executor = query_sdk
|
||||
forbidden_tokens = ("session", "oper", "provider", "configure")
|
||||
forbidden_modules = (
|
||||
"app.db.models",
|
||||
"app.db.oper",
|
||||
"app.db.session",
|
||||
"app.application",
|
||||
)
|
||||
|
||||
assert sdk.__all__
|
||||
for name in sdk.__all__:
|
||||
lowered = name.casefold()
|
||||
assert not any(token in lowered for token in forbidden_tokens), name
|
||||
exported = getattr(sdk, name)
|
||||
module_name = getattr(exported, "__module__", "")
|
||||
assert not module_name.startswith(forbidden_modules), (
|
||||
name,
|
||||
module_name,
|
||||
)
|
||||
if inspect.isfunction(exported):
|
||||
assert module_name == "app.sdk.queries"
|
||||
|
||||
assert not hasattr(sdk, "TransactionalDataQueryRepository")
|
||||
assert not hasattr(sdk, "DataQueryService")
|
||||
assert not hasattr(sdk, "get_configured_data_query_service")
|
||||
|
||||
|
||||
def test_query_tests_use_local_sqlite_backend(query_sdk):
|
||||
"""查询 focused tests 固定在隔离 SQLite 上,不依赖外部数据库或网络。"""
|
||||
from app.db.engine import get_engine
|
||||
|
||||
engine = get_engine()
|
||||
assert engine.url.get_backend_name() == "sqlite"
|
||||
Reference in New Issue
Block a user