refactor(chain): type download and media server ports

This commit is contained in:
jxxghp
2026-08-28 07:47:37 +08:00
parent c3f115d4fb
commit 5fb62108ab
25 changed files with 1262 additions and 331 deletions
+12 -8
View File
@@ -10,10 +10,14 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, Optional
from app.application.download.failures import DownloadFailureRepository
from app.application.mediaserver import MediaServerRepository
from app.application.transfer.execution import TransferExecutionRepository
from app.application.transfer.workflow import TransferAdmissionRepository
OperFactory = Callable[[], Any]
DownloadFailureRepositoryFactory = Callable[[], DownloadFailureRepository]
MediaServerRepositoryFactory = Callable[[], MediaServerRepository]
TransferAdmissionRepositoryFactory = Callable[[], TransferAdmissionRepository]
TransferExecutionRepositoryFactory = Callable[[], TransferExecutionRepository]
@@ -28,8 +32,8 @@ class ChainDataPorts:
transfer_history: OperFactory
transfer_pending: TransferAdmissionRepositoryFactory
transfer_execution: TransferExecutionRepositoryFactory
media_server: OperFactory
download_failure: OperFactory
media_server: MediaServerRepositoryFactory
download_failure: DownloadFailureRepositoryFactory
user: OperFactory
@@ -44,8 +48,8 @@ def configure_chain_data_ports(
transfer_history: OperFactory,
transfer_pending: TransferAdmissionRepositoryFactory,
transfer_execution: TransferExecutionRepositoryFactory,
media_server: OperFactory,
download_failure: OperFactory,
media_server: MediaServerRepositoryFactory,
download_failure: DownloadFailureRepositoryFactory,
user: OperFactory,
) -> None:
"""由启动组合根登记显式命名的 Chain 数据端口实现。"""
@@ -100,13 +104,13 @@ def get_chain_transfer_execution_port() -> TransferExecutionRepository:
return get_chain_data_ports().transfer_execution()
def get_chain_media_server_port() -> Any:
"""创建媒体服务器数据端口实例。"""
def get_chain_media_server_port() -> MediaServerRepository:
"""创建类型化的媒体服务器本地缓存端口实例。"""
return get_chain_data_ports().media_server()
def get_chain_download_failure_port() -> Any:
"""创建下载失败数据端口实例。"""
def get_chain_download_failure_port() -> DownloadFailureRepository:
"""创建类型化的下载失败冷却持久化端口实例。"""
return get_chain_data_ports().download_failure()
+57
View File
@@ -0,0 +1,57 @@
"""下载失败冷却记录的类型化应用契约。"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional, Protocol, Union
from app.schemas.types import MediaSource
@dataclass(frozen=True, slots=True)
class DownloadFailureWrite:
"""一次下载失败冷却写入所需的完整稳定数据。"""
fingerprint: str
failed_at: str
next_retry_at: str
media_type: Optional[str] = None
title: Optional[str] = None
year: Optional[str] = None
media_source: Optional[MediaSource] = None
media_id: Optional[str] = None
seasons: Optional[str] = None
episodes: Optional[str] = None
site: Optional[int] = None
site_name: Optional[str] = None
torrent_id: Optional[str] = None
torrent_name: Optional[str] = None
torrent_size: Optional[Union[float, int]] = None
downloader: Optional[str] = None
source: Optional[str] = None
error_message: Optional[str] = None
@dataclass(frozen=True, slots=True)
class DownloadFailureSnapshot:
"""脱离数据库会话后供资源冷却判断使用的只读快照。"""
fingerprint: str
error_message: Optional[str]
next_retry_at: Optional[str]
class DownloadFailureRepository(Protocol):
"""下载链读写失败冷却状态所需的最小持久化端口。"""
def get_active_by_fingerprints(
self,
fingerprints: list[str],
now_time: str,
) -> dict[str, DownloadFailureSnapshot]:
"""返回仍处于冷却期的不可变失败快照。"""
...
def record_failure(self, failure: DownloadFailureWrite) -> None:
"""持久化一次失败事实,完成提交后不暴露数据库记录。"""
...
+102 -12
View File
@@ -1,16 +1,17 @@
import json
import re
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Optional, Protocol
from typing import Any, Optional, Protocol, Union
from app.schemas.mediaserver import MediaServerItem as _SchemaMediaServerItem
from app.schemas.mediaserver import MediaServerItemUserState as _SchemaMediaServerItemUserState
from app.application.service import ServiceBaseHelper
from app.domain.context import MusicInfo
from app.runtime.log import logger
from app.schemas.media import normalize_media_source, resolve_media_identity
from app.application.service import ServiceBaseHelper
from app.schemas.system import MediaServerConf
from app.schemas.system import ServiceInfo
from app.schemas.mediaserver import MediaServerItem as _SchemaMediaServerItem
from app.schemas.mediaserver import MediaServerItemUserState as _SchemaMediaServerItemUserState
from app.schemas.system import MediaServerConf, ServiceInfo
from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
MediaSource,
@@ -19,16 +20,106 @@ from app.schemas.types import (
)
@dataclass(frozen=True, slots=True)
class MediaServerSyncItem:
"""媒体库同步写端使用的脱离远端响应与数据库会话的冻结条目。"""
server: str
library: Optional[str]
item_id: str
item_type: Optional[str]
title: Optional[str]
original_title: Optional[str]
year: Optional[Union[str, int]]
media_source: Optional[MediaSource]
media_id: Optional[str]
path: Optional[str]
seasoninfo: tuple[tuple[int, tuple[int, ...]], ...]
note_json: Optional[str]
lst_mod_date: str
@classmethod
def from_item(
cls,
item: _SchemaMediaServerItem,
*,
item_type: Optional[str],
seasoninfo: Mapping[int, Optional[list[int]]],
sync_time: str,
) -> "MediaServerSyncItem":
"""冻结远端媒体条目和本轮剧集快照,供短事务持久化。"""
return cls(
server=str(item.server or ""),
library=str(item.library) if item.library is not None else None,
item_id=str(item.item_id or ""),
item_type=item_type,
title=item.title,
original_title=item.original_title,
year=item.year,
media_source=item.media_source,
media_id=item.media_id,
path=item.path,
seasoninfo=tuple(
(season, tuple(episodes or ()))
for season, episodes in seasoninfo.items()
),
note_json=(
json.dumps(item.note, ensure_ascii=False)
if item.note is not None
else None
),
lst_mod_date=sync_time,
)
class MediaServerRepository(Protocol):
"""Chain 查询与同步媒体服务器本地缓存所需的最小持久化端口。"""
def get_item_id(
self,
*,
title: Optional[str] = None,
year: Optional[Union[str, int]] = None,
mtype: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
season: Optional[int] = None,
) -> Optional[str]:
"""返回匹配条目的服务器 item_id,未命中时返回 None。"""
...
def upsert(self, item: MediaServerSyncItem) -> bool:
"""在短事务中新增或更新一个冻结媒体条目。"""
...
def delete_stale(self, *, server: str, sync_time: str) -> int:
"""删除指定服务器本轮同步未更新的本地条目。"""
...
def delete_excluded_servers(self, servers: list[str]) -> int:
"""删除已停用或已移除服务器的本地条目。"""
...
class AsyncMediaServerQueryRepository(Protocol):
"""媒体服务器本地条目查询所需的异步持久化端口。"""
async def async_exists(self, **kwargs: Any) -> Any | None:
"""按标题或统一媒体身份查找已同步条目。"""
async def async_get_item_id(
self,
*,
title: Optional[str] = None,
year: Optional[Union[str, int]] = None,
mtype: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
season: Optional[int] = None,
) -> Optional[str]:
"""按标题或统一媒体身份返回已同步条目的标量 ID。"""
...
class MediaServerQueryService:
"""封装媒体服务器本地存在性查询与 ORM 投影"""
"""封装媒体服务器本地存在性标量查询。"""
def __init__(self, repository: AsyncMediaServerQueryRepository):
"""使用显式媒体服务器查询端口初始化服务。"""
@@ -38,14 +129,14 @@ class MediaServerQueryService:
self,
*,
title: Optional[str] = None,
year: Optional[str] = None,
year: Optional[Union[str, int]] = None,
mtype: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
season: Optional[int] = None,
) -> Optional[str]:
"""返回匹配条目的服务器 item_id,未命中时返回 None。"""
item = await self._repository.async_exists(
return await self._repository.async_get_item_id(
title=title,
year=year,
mtype=mtype,
@@ -53,7 +144,6 @@ class MediaServerQueryService:
media_id=media_id,
season=season,
)
return item.item_id if item else None
class MediaServerIdentityHelper:
+54 -35
View File
@@ -6,7 +6,7 @@ import re
import shutil
import time
from pathlib import Path
from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple, Union
from typing import Dict, List, Optional, Set, Tuple, Union
from urllib.parse import parse_qs, urlencode, urljoin, urlparse
from app.adapters.network.http import RequestUtils
@@ -19,6 +19,11 @@ from app.application.chain.data import (
from app.application.configuration import get_chain_runtime_config_snapshot
from app.application.directory import DirectoryHelper, validate_download_save_path
from app.application.download import selection as _selection
from app.application.download.failures import (
DownloadFailureRepository,
DownloadFailureSnapshot,
DownloadFailureWrite,
)
from app.application.download.tasks import DownloadTaskService
from app.application.torrent import TorrentHelper
from app.chain import ChainBase
@@ -63,12 +68,6 @@ from app.schemas.types import (
)
from app.schemas.workflow import FileItem as _SchemaFileItem
if TYPE_CHECKING:
from typing import Any
DownloadFailure = Any
DOWNLOAD_FAILURE_RESOURCE_TTL_SECONDS = 24 * 60 * 60
DOWNLOAD_FAILURE_TRANSIENT_TTL_SECONDS = 60 * 60
DOWNLOAD_FAILURE_RESOURCE_ERROR_KEYWORDS = (
@@ -880,25 +879,40 @@ class DownloadChain(ChainBase):
torrent = context.torrent_info
site = getattr(torrent, "site", None)
try:
get_chain_download_failure_port().record_failure(
fingerprint=fingerprint,
now_time=now_time,
next_retry_at=next_retry_at,
type=getattr(getattr(media, "type", None), "value", getattr(media, "type", None)),
title=getattr(media, "title", None),
year=getattr(media, "year", None),
media_source=media_source,
media_id=media_id,
seasons=getattr(meta, "season", None),
episodes=episode_rules.format_ranges(list(episodes)) if episodes else self._format_failure_episodes(meta),
site=site if isinstance(site, int) else None,
site_name=getattr(torrent, "site_name", None),
torrent_id=self._torrent_resource_key(torrent),
torrent_name=getattr(torrent, "title", None),
torrent_size=getattr(torrent, "size", None),
downloader=downloader,
source=str(source)[:1000] if source else None,
error_message=str(error_msg or "")[:1000],
repository: DownloadFailureRepository = get_chain_download_failure_port()
repository.record_failure(
DownloadFailureWrite(
fingerprint=fingerprint,
failed_at=now_time,
next_retry_at=next_retry_at,
media_type=getattr(
getattr(media, "type", None),
"value",
getattr(media, "type", None),
),
title=getattr(media, "title", None),
year=getattr(media, "year", None),
media_source=media_source,
media_id=media_id,
seasons=(
str(getattr(meta, "season", None))
if getattr(meta, "season", None) is not None
else None
),
episodes=(
episode_rules.format_ranges(list(episodes))
if episodes
else self._format_failure_episodes(meta)
),
site=site if isinstance(site, int) else None,
site_name=getattr(torrent, "site_name", None),
torrent_id=self._torrent_resource_key(torrent),
torrent_name=getattr(torrent, "title", None),
torrent_size=getattr(torrent, "size", None),
downloader=downloader,
source=str(source)[:1000] if source else None,
error_message=str(error_msg or "")[:1000],
)
)
except Exception as err:
logger.error(f"记录下载失败冷却失败:{str(err)}")
@@ -908,7 +922,7 @@ class DownloadChain(ChainBase):
self,
contexts: List[Context],
source: Optional[str],
) -> Dict[str, "DownloadFailure"]:
) -> Dict[str, DownloadFailureSnapshot]:
"""
查询当前订阅候选中仍处于冷却期的失败记录,返回指纹到失败记录的映射。
"""
@@ -926,7 +940,8 @@ class DownloadChain(ChainBase):
return {}
now_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
try:
return get_chain_download_failure_port().get_active_by_fingerprints(
repository: DownloadFailureRepository = get_chain_download_failure_port()
return repository.get_active_by_fingerprints(
fingerprints=fingerprints, now_time=now_time,
)
except Exception as err:
@@ -936,7 +951,7 @@ class DownloadChain(ChainBase):
@staticmethod
def _log_download_failure_cooldown(
context: Context,
failure: Optional["DownloadFailure"],
failure: Optional[DownloadFailureSnapshot],
) -> None:
"""记录候选资源处于失败冷却期时的跳过原因和下次重试时间。"""
reason = getattr(failure, "error_message", None) or "未知原因"
@@ -957,7 +972,7 @@ class DownloadChain(ChainBase):
contexts: List[Context],
downloader: Optional[str],
source: Optional[str],
) -> Tuple[List[Context], Dict[str, "DownloadFailure"]]:
) -> Tuple[List[Context], Dict[str, Optional[DownloadFailureSnapshot]]]:
"""
执行批量下载前的资源选择、排序和失败冷却准备。
@@ -979,16 +994,20 @@ class DownloadChain(ChainBase):
)
contexts = event_data.updated_contexts
contexts = TorrentHelper().sort_torrents(contexts)
return contexts, self._active_download_failure_fingerprints(
contexts=contexts,
source=source,
)
active_failures: Dict[str, Optional[DownloadFailureSnapshot]] = {
fingerprint: failure
for fingerprint, failure in self._active_download_failure_fingerprints(
contexts=contexts,
source=source,
).items()
}
return contexts, active_failures
def _download_movie_music_candidates(
self,
contexts: List[Context],
downloaded_list: List[Context],
active_failure_records: Dict[str, "DownloadFailure"],
active_failure_records: Dict[str, Optional[DownloadFailureSnapshot]],
save_path: Optional[str],
channel: Optional[NotificationChannel],
source: Optional[str],
+26 -16
View File
@@ -3,7 +3,11 @@ from datetime import datetime
from typing import Any, Callable, Dict, Generator, List, Optional, Tuple, Union
from app.application.chain.data import get_chain_media_server_port
from app.application.mediaserver import get_mediaserver_configs
from app.application.mediaserver import (
MediaServerRepository,
MediaServerSyncItem,
get_mediaserver_configs,
)
from app.application.security.url import SecurityUtils
from app.chain import ChainBase
from app.runtime.log import logger
@@ -271,6 +275,7 @@ class MediaServerChain(ChainBase):
self,
mediaservers: List[Any],
server: Optional[str],
repository: MediaServerRepository,
) -> Tuple[List[Any], int, Dict[str, Any], Optional[int]]:
"""
准备启用媒体服务器的同步库和进度总量。
@@ -281,8 +286,7 @@ class MediaServerChain(ChainBase):
item.name for item in mediaservers
if item and item.enabled and item.name
]
dboper = get_chain_media_server_port()
dboper.delete_excluded_servers(enabled_servers)
repository.delete_excluded_servers(enabled_servers)
selected_servers = [
item for item in mediaservers
if item and item.enabled and (not server or item.name == server)
@@ -332,7 +336,7 @@ class MediaServerChain(ChainBase):
server_name: str,
selected_libraries: List[Any],
library_media_counts: Dict[str, Optional[int]],
dboper: Any,
repository: MediaServerRepository,
sync_time: str,
progress_callback: Optional[Callable[..., None]],
server_index: int,
@@ -363,14 +367,17 @@ class MediaServerChain(ChainBase):
item_type = self._normalize_item_type(item.item_type)
if item_type == MediaType.TV.value:
for episode in self.episodes(server_name, item.item_id) or []:
seasoninfo[episode.season] = episode.episodes
item_dict = item.model_dump()
item_dict.update({
"seasoninfo": seasoninfo,
"item_type": item_type,
"lst_mod_date": sync_time,
})
dboper.upsert(**item_dict)
if episode.season is None:
continue
seasoninfo[episode.season] = episode.episodes or []
repository.upsert(
MediaServerSyncItem.from_item(
item,
item_type=item_type,
seasoninfo=seasoninfo,
sync_time=sync_time,
)
)
if progress_callback:
if global_media_total:
progress_value = min(global_media_finished / global_media_total, 1) * 100
@@ -423,7 +430,10 @@ class MediaServerChain(ChainBase):
"media_finished": global_media_finished,
},
)
stale_count = dboper.delete_stale(server=server_name, sync_time=sync_time)
stale_count = repository.delete_stale(
server=server_name,
sync_time=sync_time,
)
logger.info(f"媒体服务器 {server_name} 清理陈旧数据完成,删除数量:{stale_count}")
return total_count, global_media_finished
@@ -463,9 +473,9 @@ class MediaServerChain(ChainBase):
with lock:
# 汇总统计
total_count = 0
dboper = get_chain_media_server_port()
repository = get_chain_media_server_port()
mediaservers, total_servers, server_sync_contexts, global_media_total = (
self._prepare_sync_contexts(mediaservers, server)
self._prepare_sync_contexts(mediaservers, server, repository)
)
if progress_callback:
progress_callback(
@@ -543,7 +553,7 @@ class MediaServerChain(ChainBase):
server_name=server_name,
selected_libraries=selected_libraries,
library_media_counts=library_media_counts,
dboper=dboper,
repository=repository,
sync_time=sync_time,
progress_callback=progress_callback,
server_index=server_index,
+39 -22
View File
@@ -3,8 +3,13 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any, cast
from sqlalchemy.orm import Session
from app.application.download.failures import (
DownloadFailureSnapshot,
DownloadFailureWrite,
)
from app.db.oper.downloadfailure import DownloadFailureOper
from app.db.uow import SqlAlchemyUnitOfWork
@@ -12,7 +17,7 @@ from app.db.uow import SqlAlchemyUnitOfWork
class TransactionalDownloadFailureRepository:
"""为 Chain 下载失败读写创建短生命周期会话并显式收口事务。"""
def __init__(self, session_factory: Callable[[], Any]) -> None:
def __init__(self, session_factory: Callable[[], Session]) -> None:
"""保存由启动组合根提供的同步会话工厂。"""
self._session_factory = session_factory
@@ -20,36 +25,48 @@ class TransactionalDownloadFailureRepository:
self,
fingerprints: list[str],
now_time: str,
) -> dict[str, Any]:
) -> dict[str, DownloadFailureSnapshot]:
"""在独立只读会话中查询仍处于冷却期的失败记录。"""
with self._session_factory() as session:
return cast(
dict[str, Any],
DownloadFailureOper(db=session).get_active_by_fingerprints(
fingerprints=fingerprints,
now_time=now_time,
),
records = DownloadFailureOper(db=session).get_active_by_fingerprints(
fingerprints=fingerprints,
now_time=now_time,
)
return {
fingerprint: DownloadFailureSnapshot(
fingerprint=record.fingerprint,
error_message=record.error_message,
next_retry_at=record.next_retry_at,
)
for fingerprint, record in records.items()
}
def record_failure(
self,
fingerprint: str,
now_time: str,
next_retry_at: str,
**kwargs: object,
) -> Any:
def record_failure(self, failure: DownloadFailureWrite) -> None:
"""在一个显式 UoW 中新增或更新下载失败记录。"""
with self._session_factory() as session:
transaction = SqlAlchemyUnitOfWork(session)
try:
failure = DownloadFailureOper(db=session).record_failure(
fingerprint=fingerprint,
now_time=now_time,
next_retry_at=next_retry_at,
**kwargs,
DownloadFailureOper(db=session).record_failure(
fingerprint=failure.fingerprint,
now_time=failure.failed_at,
next_retry_at=failure.next_retry_at,
type=failure.media_type,
title=failure.title,
year=failure.year,
media_source=failure.media_source,
media_id=failure.media_id,
seasons=failure.seasons,
episodes=failure.episodes,
site=failure.site,
site_name=failure.site_name,
torrent_id=failure.torrent_id,
torrent_name=failure.torrent_name,
torrent_size=failure.torrent_size,
downloader=failure.downloader,
source=failure.source,
error_message=failure.error_message,
)
transaction.commit()
return failure
except Exception:
transaction.rollback()
raise
+110
View File
@@ -0,0 +1,110 @@
"""媒体服务器本地缓存的显式短会话与事务适配器。"""
from __future__ import annotations
import json
from collections.abc import Callable
from typing import Optional, TypeVar, Union
from sqlalchemy.orm import Session
from app.application.mediaserver import MediaServerSyncItem
from app.db.oper.mediaserver import MediaServerOper
from app.db.uow import SqlAlchemyUnitOfWork
from app.schemas.types import MediaSource
T = TypeVar("T")
class TransactionalMediaServerRepository:
"""让每次媒体库缓存查询或变更各自拥有一个短生命周期 Session。"""
def __init__(self, session_factory: Callable[[], Session]) -> None:
"""保存由启动组合根提供的同步会话工厂。"""
self._session_factory = session_factory
def _read(self, operation: Callable[[MediaServerOper], T]) -> T:
"""在独立只读会话中执行媒体库缓存查询。"""
with self._session_factory() as session:
return operation(MediaServerOper(db=session))
def _write(self, operation: Callable[[MediaServerOper], T]) -> T:
"""在独立 UoW 中执行并提交一项媒体库缓存变更。"""
with self._session_factory() as session:
unit_of_work = SqlAlchemyUnitOfWork(session)
try:
result = operation(MediaServerOper(db=session))
unit_of_work.commit()
return result
except Exception:
unit_of_work.rollback()
raise
def get_item_id(
self,
*,
title: Optional[str] = None,
year: Optional[Union[str, int]] = None,
mtype: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
season: Optional[int] = None,
) -> Optional[str]:
"""在短会话中返回匹配条目的服务器 item_id。"""
item_id: Optional[str] = self._read(
lambda repository: repository.get_item_id(
title=title,
year=year,
mtype=mtype,
media_source=media_source,
media_id=media_id,
season=season,
)
)
return item_id
def upsert(self, item: MediaServerSyncItem) -> bool:
"""在单个短事务中新增或更新一个媒体库同步条目。"""
payload = {
"server": item.server,
"library": item.library,
"item_id": item.item_id,
"item_type": item.item_type,
"title": item.title,
"original_title": item.original_title,
"year": item.year,
"media_source": item.media_source,
"media_id": item.media_id,
"path": item.path,
"seasoninfo": {
season: list(episodes)
for season, episodes in item.seasoninfo
},
"note": (
json.loads(item.note_json)
if item.note_json is not None
else None
),
"lst_mod_date": item.lst_mod_date,
}
created: bool = self._write(
lambda repository: repository.upsert(**payload)
)
return created
def delete_stale(self, *, server: str, sync_time: str) -> int:
"""在短事务中删除指定服务器本轮未更新的条目。"""
deleted: int = self._write(
lambda repository: repository.delete_stale(
server=server,
sync_time=sync_time,
),
)
return deleted
def delete_excluded_servers(self, servers: list[str]) -> int:
"""在短事务中删除已停用或已移除服务器的条目。"""
deleted: int = self._write(
lambda repository: repository.delete_excluded_servers(servers)
)
return deleted
+8 -8
View File
@@ -29,6 +29,12 @@ class MediaServerOper(DbOper):
if hasattr(MediaServerItem, k) and k != "id"
}
@staticmethod
def _contains_season(item: MediaServerItem, season: int) -> bool:
"""兼容 JSON 往返后变为字符串的 seasoninfo 键。"""
seasoninfo = item.seasoninfo or {}
return season in seasoninfo or str(season) in seasoninfo
def add(self, **kwargs) -> bool:
"""
新增媒体服务器数据
@@ -132,10 +138,7 @@ class MediaServerOper(DbOper):
if kwargs.get("season") is not None:
# 判断季是否存在
if not item.seasoninfo:
return None
seasoninfo = item.seasoninfo or {}
if kwargs.get("season") not in seasoninfo.keys():
if not self._contains_season(item, kwargs["season"]):
return None
return item
@@ -169,10 +172,7 @@ class MediaServerOper(DbOper):
if kwargs.get("season") is not None:
# 判断季是否存在
if not item.seasoninfo:
return None
seasoninfo = item.seasoninfo or {}
if kwargs.get("season") not in seasoninfo.keys():
if not self._contains_season(item, kwargs["season"]):
return None
return item
+2 -1
View File
@@ -114,6 +114,7 @@ from app.application.workflow import (
from app.command import CommandChain
from app.db.adapters.chain import TransactionalChainDurableEventWriter
from app.db.adapters.download import TransactionalDownloadFailureRepository
from app.db.adapters.mediaserver import TransactionalMediaServerRepository
from app.db.adapters.outbox import SqlAlchemyAsyncOutboxStager, SqlAlchemyOutboxRepository
from app.db.adapters.query import SqlAlchemyDataQueryAdapter
from app.db.adapters.site import TransactionalSiteRepository
@@ -892,7 +893,7 @@ async def init_modules() -> HostRuntime:
transfer_execution=lambda: TransactionalTransferExecutionRepository(
SessionFactory
),
media_server=lambda: MediaServerOper(),
media_server=lambda: TransactionalMediaServerRepository(SessionFactory),
download_failure=lambda: TransactionalDownloadFailureRepository(
SessionFactory
),
+6 -4
View File
@@ -69,7 +69,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
| 指标 | 当前值 | 解释 |
|---|---:|---|
| 宿主 Python 模块 / 内部依赖边 | 850 / 6,944 | `dependency-baseline.json` 当前快照 |
| 宿主 Python 模块 / 内部依赖边 | 852 / 6,962 | `dependency-baseline.json` 当前快照 |
| 非平凡 SCC | 2 | 新增 Chain 包根环;另一个是隔离的 29 模块 TMDB 移植包环 |
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
@@ -77,9 +77,9 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
| Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement |
| Python 源码量 | 约 271,400 行 | 60 个文件超过 1,000 行,14 个超过 2,000 行 |
| 长方法 | 281 个超过 80 行 | 67 个超过 150 行,23 个超过 250 行;大量是私有方法 |
| 全量 mypy 历史债务 | 11,809 / 596 文件 | strict frontier 当前覆盖 41 个文件,本批迁移路径的类型债务已清零 |
| Ruff 历史诊断 | 875 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| 覆盖率低水位 | Application 78.89%Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
| 全量 mypy 历史债务 | 11,808 / 596 文件 | strict frontier 当前覆盖 41 个文件,本批迁移路径的类型债务已清零 |
| Ruff 历史诊断 | 872 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| 覆盖率低水位 | Application 78.95%Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
### 3.3 热点文件
@@ -291,6 +291,8 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
`WorkflowOper` 只保留显式 Session query/stage,旧无 Session 五方法只存在于 SDK Legacy/Compat。
- [x] 删除 Chain registry 中零消费者 `*PortProxy`/动态转发和 `ChainRuntimeContext.data_ports`
伪注入;Workflow 执行服务只在 Application owner 配置一次,不再重复注册到 `ChainDataPorts`
- [x] DownloadFailure/MediaServer 两个 registry 字段改用冻结 DTO 与 typed Repository factory
ORM 不越过短 Session,媒体库远端枚举期间不持有事务,旧 Oper/Compat 与公开 Chain ABI 保持不变。
- [ ] `ChainDataPorts`/`AgentDataPorts` 可暂时保留为兼容聚合器,但字段必须显式、可类型检查。
- [ ] 以一个业务纵切面迁移并验证后,再迁移下一组,禁止一次替换所有 Oper。
- [ ] 增加 AST 门禁,禁止向 `ChainDataPorts``AgentDataPorts` 和新的 canonical use-case service
+3 -3
View File
@@ -697,15 +697,15 @@ flowchart LR
SDK 导出(若公开)、`docs/rules/05-architecture.md` 与上述架构测试。
- 延迟导入不被接受为隐藏循环依赖的手段。
### 10.1 2026-08-27 当前收口状态与后续边界
### 10.1 2026-08-28 当前收口状态与后续边界
当前宿主架构基线(排除 `app/plugins/**`)如下;数字来自
`tests/fixtures/architecture/`,更新基线前必须先审查语义变化:
| 指标 | 当前值 |
|---|---:|
| Python 模块 | 850 |
| 内部导入边 | 6,944 |
| Python 模块 | 852 |
| 内部导入边 | 6,962 |
| 非平凡 SCC | 2`ARCH-107` 临时 Chain 包根环;精确 containment 的 TMDB 移植包环) |
| Direct egress | 6612 条待迁移债务,54 条精确 containment |
| Module Contract V2 spec | 217(其中 215 个进入 `run_module` 观察面) |
+2 -2
View File
@@ -102,7 +102,7 @@ canonical 主程序;兼容只经统一 Compat/SDK 门面提供。
| S1-L3.1 Workflow typed execution | `DELIVERED` | S1-L2 | `17d8be2af``b33b29876`:Chain 直连类型化事务服务且单次执行只取一个 port;canonical Oper 删除旧 writer/无 Session 写方法,旧 ABI 只在 `_legacy/workflow.py` 与 Compat overlayUnit Tests `33103913838`、Pylint `33103913935` 全绿,Application 覆盖率低水位提升至 `78.79%` |
| S1-L3.2 Chain registry/DI | `ACTIVE` | S1-L3.1 | 显式类型化 factory,删除 PortProxy 与失效的双重注入,构造器注入真实控制调用 |
| S1-L3.2.1 Registry hygiene | `DELIVERED` | S1-L3.1 | `ac7a20132`:删除零消费者 PortProxy/动态转发和 `ChainRuntimeContext.data_ports` 伪注入;Workflow 退出 Chain registry,只保留 Application owner 单一配置入口;Unit Tests `33120205586`、Pylint `33120205581` 全绿 |
| S1-L3.3 DownloadFailure/MediaServer | `PLANNED` | S1-L3.2 | 两组窄 DTO/Port/adapter 清零 raw Oper,不跨远端 I/O 持有 Session |
| S1-L3.3 DownloadFailure/MediaServer | `VERIFIED` | S1-L3.2 | 两组 raw factory 已替换为冻结 DTO/typed Port;失败冷却在 Session 内投影,媒体库查询只返回标量且每个 upsert/cleanup 独立短事务,远端枚举不持有 Session;旧 Oper 与插件可见 Chain ABI 保持不变 |
| S1-L3.4 User | `PLANNED` | S1-L3.3 | 认证、偏好与渠道绑定投影冻结快照,User Chain/Agent 不接收 ORM |
| S1-L3.5 History | `PLANNED` | S1-L3.4 | Download/Transfer history 统一 typed query/mutation,删除下载历史双事务 fail-open |
| S1-L3.6 Site | `PLANNED` | S1-L3.5 | 复用 Site query/health,补齐同步 typed commandSession 内完成 DTO 投影 |
@@ -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 | 当前受控 875 条诊断归零,规则集扩展经过独立审查且新增诊断为零 |
| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 872 条诊断归零,规则集扩展经过独立审查且新增诊断为零 |
| S4-L6 Coverage/并发/质量证据 | `PLANNED` | S3,S4-L1,S4-L2 | 高风险包纳入 coverageraw concurrency 分类清零;Module Quality 有真实 evidence test |
### S5Plugin、Agent、Domain、Startup 与最终收口
+7 -1
View File
@@ -63,7 +63,7 @@ to make the directory tree look symmetrical.
| `app/application/*.py` | Established single-module application services and compatibility facades |
| `app/application/subscription/` | Subscription use cases: `write.py` owns media-to-row translation and the write port; `contract.py` owns shared metadata/media-key projection; query, mutation, deletion, identity and search stay in their single-word modules |
| `app/application/search/` | Search state and later search-plan use cases |
| `app/application/download/` | Download task querying/control and later submission use cases |
| `app/application/download/` | Download task querying/control and selection use cases; `failures.py` owns the frozen failure-cooldown write/query DTOs and persistence Port |
| `app/application/music/` | Multi-source music catalog orchestration |
| `app/application/chain/` | Injectable Chain runtime capabilities: `context.py` owns the runtime dependency aggregate, `data.py` owns named persistence ports, and `events.py` owns durable event write contracts plus replayable payload conversion |
| `app/application/agentdata.py` | Named Agent data ports; canonical Agent consumers use `get_agent_*_port()` and do not alias legacy proxies to Oper classes |
@@ -143,6 +143,9 @@ Chain consumers temporarily use the named `get_chain_*_port()` functions from
The retired migration-time `*PortProxy` classes and dynamic `__getattr__` forwarding must not
be recreated; they had no host, SDK or plugin consumers. Workflow execution uses its owning
`app.application.workflow` service directly and must not be registered again in `ChainDataPorts`.
Download-failure cooldown and media-server cache consumers use their typed Application DTO/Port
contracts. Their DB adapters project ORM values before Session close and commit each local write
in a separate short UoW, so remote media enumeration never holds a database transaction.
Agent orchestration, memory and tool implementations follow the same rule via
the named `get_agent_*_port()` functions from `app/application/agentdata.py`.
The legacy Agent `*Port` proxy classes remain import-compatible boundaries and
@@ -671,6 +674,9 @@ driven workflow registration.
| `app/application/agent.py` | Agent orchestration facade (`get_agent_manager` / `get_prompt_manager` / capability queries / prompt builders); lightweight providers register through `app/startup/initializers/agent.py`, with no static `application -> agent` edge |
| `app/agent/runtime_loader.py` | Agent-specific capability discovery and canonical entrypoint/service materialization; reuses the generic Capability Runtime while keeping Agent ownership under `app/agent/` |
| `app/application/subscription/write.py` | Subscription media translation and sync/async write-port orchestration |
| `app/application/download/failures.py` | Frozen download-failure cooldown write/query DTOs and Chain persistence Port |
| `app/db/adapters/download.py` | Short-session download-failure snapshot and mutation adapter |
| `app/db/adapters/mediaserver.py` | Per-operation media-server cache query/upsert/cleanup transaction adapter |
| `app/application/outbox.py` | Durable intent, topic handler and Outbox repository contracts |
| `app/db/adapters/outbox.py` | SQLAlchemy Outbox persistence, claim/lease and retry state adapter |
| `app/application/chain/events.py` | Chain durable-event write port, settlement projection and replayable payload conversion |
+2 -1
View File
@@ -223,6 +223,7 @@ def configure_plugin_system_services():
configure_agent_task_execution,
)
from app.db.adapters.download import TransactionalDownloadFailureRepository
from app.db.adapters.mediaserver import TransactionalMediaServerRepository
from app.db.adapters.site import TransactionalSiteRepository
from app.db.adapters.subscription import TransactionalSubscribeWriter
from app.db.adapters.transaction import TransactionalWriteRunner
@@ -312,7 +313,7 @@ def configure_plugin_system_services():
transfer_execution=lambda: TransactionalTransferExecutionRepository(
SessionFactory
),
media_server=lambda: MediaServerOper(),
media_server=lambda: TransactionalMediaServerRepository(SessionFactory),
download_failure=lambda: TransactionalDownloadFailureRepository(
SessionFactory
),
+3 -3
View File
@@ -1,8 +1,8 @@
{
"application": {
"covered_lines": 10084,
"percent": 78.89,
"statements": 12782
"covered_lines": 10133,
"percent": 78.95,
"statements": 12834
},
"domain": {
"covered_lines": 3392,
+23 -3
View File
@@ -1441,8 +1441,8 @@
"runtime_only": true
}
},
"edge_count": 6944,
"edge_sha256": "9f6be750c55150ef9e061f54ade997c328561b7950f6ec44bce6ada373d01f3e",
"edge_count": 6962,
"edge_sha256": "d78cc1aa6f3837310c7460d2ea17873222b4b50708012ae2f3db249d8197f77e",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -3984,6 +3984,9 @@
"app.application.chain.context -> app.runtime",
"app.application.chain.context -> app.runtime.stop",
"app.application.chain.data -> app.application",
"app.application.chain.data -> app.application.download",
"app.application.chain.data -> app.application.download.failures",
"app.application.chain.data -> app.application.mediaserver",
"app.application.chain.data -> app.application.transfer",
"app.application.chain.data -> app.application.transfer.execution",
"app.application.chain.data -> app.application.transfer.workflow",
@@ -4020,6 +4023,8 @@
"app.application.directory -> app.schemas.file",
"app.application.directory -> app.schemas.system",
"app.application.directory -> app.schemas.types",
"app.application.download.failures -> app.schemas",
"app.application.download.failures -> app.schemas.types",
"app.application.download.selection -> app.domain",
"app.application.download.selection -> app.domain.context",
"app.application.download.selection -> app.schemas",
@@ -4640,6 +4645,7 @@
"app.chain.download -> app.application.configuration",
"app.chain.download -> app.application.directory",
"app.chain.download -> app.application.download",
"app.chain.download -> app.application.download.failures",
"app.chain.download -> app.application.download.selection",
"app.chain.download -> app.application.download.tasks",
"app.chain.download -> app.application.torrent",
@@ -5134,10 +5140,21 @@
"app.db.adapters.chain -> app.db.oper.transferpending",
"app.db.adapters.chain -> app.db.oper.transfersettlementreceipt",
"app.db.adapters.chain -> app.db.uow",
"app.db.adapters.download -> app.application",
"app.db.adapters.download -> app.application.download",
"app.db.adapters.download -> app.application.download.failures",
"app.db.adapters.download -> app.db",
"app.db.adapters.download -> app.db.oper",
"app.db.adapters.download -> app.db.oper.downloadfailure",
"app.db.adapters.download -> app.db.uow",
"app.db.adapters.mediaserver -> app.application",
"app.db.adapters.mediaserver -> app.application.mediaserver",
"app.db.adapters.mediaserver -> app.db",
"app.db.adapters.mediaserver -> app.db.oper",
"app.db.adapters.mediaserver -> app.db.oper.mediaserver",
"app.db.adapters.mediaserver -> app.db.uow",
"app.db.adapters.mediaserver -> app.schemas",
"app.db.adapters.mediaserver -> app.schemas.types",
"app.db.adapters.outbox -> app.application",
"app.db.adapters.outbox -> app.application.outbox",
"app.db.adapters.outbox -> app.db",
@@ -8020,6 +8037,7 @@
"app.startup.initializers.modules -> app.db.adapters",
"app.startup.initializers.modules -> app.db.adapters.chain",
"app.startup.initializers.modules -> app.db.adapters.download",
"app.startup.initializers.modules -> app.db.adapters.mediaserver",
"app.startup.initializers.modules -> app.db.adapters.outbox",
"app.startup.initializers.modules -> app.db.adapters.pluginidentity",
"app.startup.initializers.modules -> app.db.adapters.plugininstallation",
@@ -8389,7 +8407,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 850,
"module_count": 852,
"modules": [
"app",
"app.adapters",
@@ -8652,6 +8670,7 @@
"app.application.database",
"app.application.directory",
"app.application.download",
"app.application.download.failures",
"app.application.download.selection",
"app.application.download.tasks",
"app.application.downloader",
@@ -8783,6 +8802,7 @@
"app.db.adapters",
"app.db.adapters.chain",
"app.db.adapters.download",
"app.db.adapters.mediaserver",
"app.db.adapters.outbox",
"app.db.adapters.pluginidentity",
"app.db.adapters.plugininstallation",
+1 -1
View File
@@ -1422,7 +1422,7 @@
"call-overload": 1,
"comparison-overlap": 1,
"misc": 1,
"no-any-return": 4,
"no-any-return": 3,
"no-redef": 1,
"no-untyped-call": 7,
"no-untyped-def": 6,
-9
View File
@@ -312,9 +312,6 @@
"app/application/maintenance.py": {
"I001": 1
},
"app/application/mediaserver.py": {
"I001": 1
},
"app/application/messaging/agent.py": {
"I001": 1
},
@@ -1276,9 +1273,6 @@
"tests/test_douban_recognition.py": {
"I001": 1
},
"tests/test_download_chain.py": {
"I001": 1
},
"tests/test_download_paths_endpoint.py": {
"I001": 1
},
@@ -1410,9 +1404,6 @@
"tests/test_mediaserver_image_signing.py": {
"I001": 1
},
"tests/test_mediaserver_sync_incremental.py": {
"I001": 1
},
"tests/test_mediaserver_sync_scheduler.py": {
"I001": 1
},
+140 -140
View File
@@ -1,41 +1,41 @@
{
"schema_version": 2,
"generated_at": "2026-08-27T13:33:52.081319+00:00",
"generated_at": "2026-08-27T23:28:48.301843+00:00",
"platform": "macOS-26.5.2-arm64-arm-64bit-Mach-O",
"python": "3.14.3",
"repeat": 3,
"targets": {
"app.startup.lifecycle": {
"loaded_app_module_count": 392,
"max_ms": 1035.341,
"median_ms": 993.245,
"min_ms": 991.998,
"loaded_app_module_count": 394,
"max_ms": 1298.973,
"median_ms": 999.845,
"min_ms": 994.938,
"samples_ms": [
1035.341,
991.998,
993.245
1298.973,
999.845,
994.938
]
},
"app.factory": {
"loaded_app_module_count": 404,
"max_ms": 1006.172,
"median_ms": 1005.88,
"min_ms": 1003.841,
"loaded_app_module_count": 406,
"max_ms": 1079.626,
"median_ms": 1019.979,
"min_ms": 1014.804,
"samples_ms": [
1005.88,
1006.172,
1003.841
1019.979,
1014.804,
1079.626
]
},
"app.main": {
"loaded_app_module_count": 406,
"max_ms": 1085.652,
"median_ms": 1055.994,
"min_ms": 1043.212,
"loaded_app_module_count": 408,
"max_ms": 1075.788,
"median_ms": 1060.558,
"min_ms": 1057.105,
"samples_ms": [
1055.994,
1043.212,
1085.652
1075.788,
1060.558,
1057.105
]
}
},
@@ -47,56 +47,25 @@
{
"mode": "normal",
"enabled_component_count": 25,
"startup_ms": 0.702,
"full_lifespan_ms": 1.54,
"startup_ms": 0.674,
"full_lifespan_ms": 1.563,
"stage_ms": {
"后台任务登记器": 0.09,
"数据库准备": 0.048,
"HTTP 基础能力": 0.03,
"领域依赖装配": 0.033,
"数据库引擎预热": 0.031,
"数据库连接预算": 0.031,
"路由": 0.028,
"模块服务": 0.022,
"插件备份恢复": 0.021,
"插件": 0.025,
"定时器": 0.023,
"监控器": 0.021,
"待处理整理回放": 0.025,
"命令服务": 0.021,
"工作流": 0.023,
"插件同步与启动收尾": 0.024
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 1,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "normal",
"enabled_component_count": 25,
"startup_ms": 0.667,
"full_lifespan_ms": 1.559,
"stage_ms": {
"后台任务登记器": 0.078,
"数据库准备": 0.037,
"HTTP 基础能力": 0.027,
"领域依赖装配": 0.03,
"数据库引擎预热": 0.027,
"后台任务登记器": 0.081,
"数据库准备": 0.039,
"HTTP 基础能力": 0.031,
"领域依赖装配": 0.029,
"数据库引擎预热": 0.028,
"数据库连接预算": 0.024,
"路由": 0.026,
"模块服务": 0.026,
"插件备份恢复": 0.026,
"插件备份恢复": 0.024,
"插件": 0.025,
"定时器": 0.024,
"监控器": 0.024,
"待处理整理回放": 0.027,
"定时器": 0.026,
"监控器": 0.023,
"待处理整理回放": 0.021,
"命令服务": 0.026,
"工作流": 0.023,
"插件同步与启动收尾": 0.021
"插件同步与启动收尾": 0.025
},
"threads_before": 2,
"threads_started": 2,
@@ -109,24 +78,55 @@
{
"mode": "normal",
"enabled_component_count": 25,
"startup_ms": 0.735,
"full_lifespan_ms": 1.57,
"startup_ms": 0.662,
"full_lifespan_ms": 1.526,
"stage_ms": {
"后台任务登记器": 0.103,
"数据库准备": 0.041,
"后台任务登记器": 0.073,
"数据库准备": 0.036,
"HTTP 基础能力": 0.034,
"领域依赖装配": 0.028,
"数据库引擎预热": 0.024,
"数据库连接预算": 0.025,
"路由": 0.025,
"模块服务": 0.024,
"插件备份恢复": 0.027,
"插件": 0.024,
"定时器": 0.025,
"监控器": 0.024,
"待处理整理回放": 0.023,
"命令服务": 0.023,
"工作流": 0.023,
"插件同步与启动收尾": 0.023
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 1,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "normal",
"enabled_component_count": 25,
"startup_ms": 0.651,
"full_lifespan_ms": 1.495,
"stage_ms": {
"后台任务登记器": 0.077,
"数据库准备": 0.038,
"HTTP 基础能力": 0.031,
"领域依赖装配": 0.032,
"数据库引擎预热": 0.028,
"数据库连接预算": 0.026,
"路由": 0.027,
"模块服务": 0.026,
"插件备份恢复": 0.026,
"领域依赖装配": 0.028,
"数据库引擎预热": 0.025,
"数据库连接预算": 0.024,
"路由": 0.026,
"模块服务": 0.025,
"插件备份恢复": 0.023,
"插件": 0.025,
"定时器": 0.024,
"监控器": 0.025,
"定时器": 0.026,
"监控器": 0.024,
"待处理整理回放": 0.024,
"命令服务": 0.024,
"工作流": 0.024,
"命令服务": 0.021,
"工作流": 0.022,
"插件同步与启动收尾": 0.024
},
"threads_before": 2,
@@ -138,8 +138,8 @@
"database_connections_started": 0
}
],
"median_startup_ms": 0.702,
"median_full_lifespan_ms": 1.559,
"median_startup_ms": 0.662,
"median_full_lifespan_ms": 1.526,
"enabled_component_count": 25,
"enabled_components": [
"后台任务登记器",
@@ -174,66 +174,66 @@
{
"mode": "safe",
"enabled_component_count": 13,
"startup_ms": 0.527,
"full_lifespan_ms": 0.956,
"startup_ms": 0.485,
"full_lifespan_ms": 0.915,
"stage_ms": {
"后台任务登记器": 0.08,
"数据库准备": 0.038,
"HTTP 基础能力": 0.03,
"领域依赖装配": 0.031,
"数据库引擎预热": 0.026,
"数据库连接预算": 0.023,
"路由": 0.023,
"模块服务": 0.025,
"插件同步与启动收尾": 0.022
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 1,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "safe",
"enabled_component_count": 13,
"startup_ms": 0.548,
"full_lifespan_ms": 0.942,
"stage_ms": {
"后台任务登记器": 0.091,
"数据库准备": 0.042,
"HTTP 基础能力": 0.03,
"领域依赖装配": 0.03,
"数据库引擎预热": 0.028,
"数据库连接预算": 0.027,
"路由": 0.026,
"模块服务": 0.026,
"插件同步与启动收尾": 0.025
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 1,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "safe",
"enabled_component_count": 13,
"startup_ms": 0.523,
"full_lifespan_ms": 0.954,
"stage_ms": {
"后台任务登记器": 0.082,
"数据库准备": 0.045,
"HTTP 基础能力": 0.036,
"领域依赖装配": 0.033,
"数据库引擎预热": 0.026,
"数据库连接预算": 0.024,
"路由": 0.023,
"模块服务": 0.024,
"插件同步与启动收尾": 0.023
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 1,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "safe",
"enabled_component_count": 13,
"startup_ms": 0.515,
"full_lifespan_ms": 0.934,
"stage_ms": {
"后台任务登记器": 0.084,
"数据库准备": 0.044,
"HTTP 基础能力": 0.029,
"领域依赖装配": 0.027,
"数据库引擎预热": 0.031,
"数据库连接预算": 0.028,
"路由": 0.023,
"数据库准备": 0.04,
"HTTP 基础能力": 0.042,
"领域依赖装配": 0.029,
"数据库引擎预热": 0.023,
"数据库连接预算": 0.029,
"路由": 0.024,
"模块服务": 0.022,
"插件同步与启动收尾": 0.025
"插件同步与启动收尾": 0.021
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 1,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "safe",
"enabled_component_count": 13,
"startup_ms": 0.496,
"full_lifespan_ms": 0.923,
"stage_ms": {
"后台任务登记器": 0.077,
"数据库准备": 0.035,
"HTTP 基础能力": 0.035,
"领域依赖装配": 0.026,
"数据库引擎预热": 0.025,
"数据库连接预算": 0.024,
"路由": 0.024,
"模块服务": 0.024,
"插件同步与启动收尾": 0.026
},
"threads_before": 2,
"threads_started": 2,
@@ -244,8 +244,8 @@
"database_connections_started": 0
}
],
"median_startup_ms": 0.523,
"median_full_lifespan_ms": 0.954,
"median_startup_ms": 0.496,
"median_full_lifespan_ms": 0.923,
"enabled_component_count": 13,
"enabled_components": [
"后台任务登记器",
+47
View File
@@ -444,6 +444,53 @@ def test_chain_registry_has_no_dynamic_proxies_or_dead_context_injection():
assert "data_ports=" not in startup_source
def test_download_failure_and_mediaserver_ports_are_typed_and_detached():
"""下载失败与媒体库缓存端口不得退回 raw Oper、Any 或 ORM 投影。"""
data_path = APP_ROOT / "application" / "chain" / "data.py"
data_tree = ast.parse(
data_path.read_text(encoding="utf-8"),
filename=str(data_path),
)
data_class = next(
node
for node in data_tree.body
if isinstance(node, ast.ClassDef) and node.name == "ChainDataPorts"
)
annotations = {
node.target.id: ast.unparse(node.annotation)
for node in data_class.body
if isinstance(node, ast.AnnAssign)
and isinstance(node.target, ast.Name)
}
returns = {
node.name: ast.unparse(node.returns)
for node in data_tree.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and node.returns is not None
}
assert annotations["download_failure"] == "DownloadFailureRepositoryFactory"
assert annotations["media_server"] == "MediaServerRepositoryFactory"
assert returns["get_chain_download_failure_port"] == "DownloadFailureRepository"
assert returns["get_chain_media_server_port"] == "MediaServerRepository"
download_source = (APP_ROOT / "chain" / "download.py").read_text(
encoding="utf-8"
)
mediaserver_source = (APP_ROOT / "chain" / "mediaserver.py").read_text(
encoding="utf-8"
)
application_source = (
APP_ROOT / "application" / "mediaserver.py"
).read_text(encoding="utf-8")
startup_source = (
APP_ROOT / "startup" / "initializers" / "modules.py"
).read_text(encoding="utf-8")
assert "DownloadFailure = Any" not in download_source
assert "dboper" not in mediaserver_source
assert "async def async_get_item_id(" in application_source
assert "TransactionalMediaServerRepository(SessionFactory)" in startup_source
def test_canonical_workflow_oper_has_no_legacy_writer_or_duplicate_exports():
"""工作流旧写入口只能存在于 SDK Legacy facade。"""
oper_path = APP_ROOT / "db" / "oper" / "workflow.py"
+26
View File
@@ -158,6 +158,32 @@ def test_exists_by_title_matches_async_twin(db):
assert (sync_found is None) == (async_found is None)
def test_oper_season_lookup_accepts_json_string_keys(db):
"""跨 Session 后的 JSON 季号键为字符串,查询仍应按整数季号命中。"""
item = _item("emby", "season-json", item_type="电视剧", media_id="season-1")
item.seasoninfo = {"1": [1, 2]}
db.add(item)
assert MediaServerOper(db.session).get_item_id(
media_source=MediaSource.TMDB,
media_id="season-1",
mtype="电视剧",
season=1,
) == "season-json"
async def check() -> None:
"""验证异步查询与同步查询采用相同的 JSON 季号兼容规则。"""
async with async_session_scope() as session:
assert await MediaServerOper(session).async_get_item_id(
media_source=MediaSource.TMDB,
media_id="season-1",
mtype="电视剧",
season=1,
) == "season-json"
asyncio.run(check())
def test_empty_clears_only_the_given_server(db):
"""
指定服务器时只清空该服务器的条目不给则清空全表
+106 -12
View File
@@ -1,3 +1,4 @@
from dataclasses import asdict
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
@@ -5,10 +6,14 @@ from unittest.mock import MagicMock
import pytest
import app.chain.download as download_module
from app.application.download.failures import (
DownloadFailureSnapshot,
DownloadFailureWrite,
)
from app.chain.download import DownloadChain
from app.runtime.config import settings
from app.domain.context import Context, MediaInfo, SubtitleInfo, TorrentInfo
from app.domain.metainfo import MetaInfo
from app.runtime.config import settings
from app.schemas.file import FileItem
from app.schemas.mediaserver import NotExistMediaInfo
from app.schemas.system import TransferDirectoryConf
@@ -804,12 +809,11 @@ def test_download_single_records_failure_cooldown_when_downloader_rejects(monkey
捕获下载失败冷却记录避免测试写入数据库
"""
def record_failure(self, **kwargs: object) -> SimpleNamespace:
def record_failure(self, failure: DownloadFailureWrite) -> None:
"""
保存写入字段供断言使用
"""
captured.update(kwargs)
return SimpleNamespace(id=1)
captured.update(asdict(failure))
monkeypatch.setattr(
"app.application.directory.DirectoryHelper.get_download_dirs",
@@ -858,11 +862,95 @@ def test_download_single_records_failure_cooldown_when_downloader_rejects(monkey
assert download_id is None
assert returned_error == error_msg
assert captured["fingerprint"] == DownloadChain._build_download_failure_fingerprint(context)
assert captured["fingerprint"] == DownloadChain._build_download_failure_fingerprint(
context
)
assert captured["torrent_id"] == "example.com:id=484660"
assert captured["site"] == 12
assert captured["error_message"] == error_msg
assert captured["next_retry_at"] > captured["now_time"]
assert captured["next_retry_at"] > captured["failed_at"]
def test_download_failure_query_skips_non_subscribe_sources(monkeypatch):
"""非订阅下载不得读取失败冷却,避免改变手工下载行为。"""
get_repository = MagicMock(side_effect=AssertionError("unexpected query"))
monkeypatch.setattr(
download_module,
"get_chain_download_failure_port",
get_repository,
)
chain = DownloadChain.__new__(DownloadChain)
assert chain._active_download_failure_fingerprints(
contexts=[_build_tv_context()],
source="Manual",
) == {}
get_repository.assert_not_called()
def test_download_failure_query_failure_is_fail_open(monkeypatch):
"""冷却查询失败时继续下载候选,并记录一次明确错误。"""
class _FailingDownloadFailureRepository:
"""模拟失败的下载冷却查询端口。"""
def get_active_by_fingerprints(
self,
fingerprints: list[str],
now_time: str,
) -> dict[str, DownloadFailureSnapshot]:
"""拒绝查询以验证 Chain 的 fail-open 语义。"""
assert fingerprints
assert now_time
raise RuntimeError("query failed")
error = MagicMock()
monkeypatch.setattr(
download_module,
"get_chain_download_failure_port",
_FailingDownloadFailureRepository,
)
monkeypatch.setattr(download_module.logger, "error", error)
chain = DownloadChain.__new__(DownloadChain)
assert chain._active_download_failure_fingerprints(
contexts=[_build_tv_context()],
source="Subscribe|{}",
) == {}
error.assert_called_once_with("查询下载失败冷却失败:query failed")
def test_download_failure_write_failure_is_fail_open(monkeypatch):
"""冷却写入失败不得覆盖原下载结果,并记录一次明确错误。"""
class _FailingDownloadFailureRepository:
"""模拟失败的下载冷却写入端口。"""
def record_failure(self, failure: DownloadFailureWrite) -> None:
"""拒绝写入以验证 Chain 的 fail-open 语义。"""
assert failure.fingerprint
raise RuntimeError("write failed")
error = MagicMock()
monkeypatch.setattr(
download_module,
"get_chain_download_failure_port",
_FailingDownloadFailureRepository,
)
monkeypatch.setattr(download_module.logger, "error", error)
context = _build_tv_context()
chain = DownloadChain.__new__(DownloadChain)
fingerprint = chain._record_download_failure(
context=context,
error_msg="下载失败",
source="Subscribe|{}",
)
assert fingerprint == DownloadChain._build_download_failure_fingerprint(context)
error.assert_called_once_with("记录下载失败冷却失败:write failed")
def test_download_failure_fingerprint_distinguishes_special_season_zero():
@@ -943,17 +1031,23 @@ def test_batch_download_skips_failed_subscription_resource_and_tries_next(monkey
返回第一个候选的活跃失败冷却记录
"""
def get_active_by_fingerprints(self, fingerprints: list[str], now_time: str) -> dict:
def get_active_by_fingerprints(
self,
fingerprints: list[str],
now_time: str,
) -> dict[str, DownloadFailureSnapshot]:
"""
模拟数据库批量查询活跃失败记录
"""
assert now_time
assert failed_fingerprint in fingerprints
return {failed_fingerprint: SimpleNamespace(
fingerprint=failed_fingerprint,
error_message="无法读取种子文件",
next_retry_at="2026-01-02 03:04:05",
)}
return {
failed_fingerprint: DownloadFailureSnapshot(
fingerprint=failed_fingerprint,
error_message="无法读取种子文件",
next_retry_at="2026-01-02 03:04:05",
)
}
monkeypatch.setattr(
download_module,
+175 -43
View File
@@ -8,9 +8,10 @@ from sqlalchemy.orm import sessionmaker
from app import schemas
from app.chain import mediaserver as MEDIA_SERVER_CHAIN_MODULE
from app.chain.mediaserver import MediaServerChain
from app.db.adapters.mediaserver import TransactionalMediaServerRepository
from app.db.base import Base
from app.db.oper.mediaserver import MediaServerOper
from app.db.models.mediaserver import MediaServerItem
from app.db.oper.mediaserver import MediaServerOper
from app.runtime.config import global_vars
@@ -100,18 +101,16 @@ def test_sync_persists_music_without_querying_tv_episodes(database):
)
chain.episodes = lambda *_args, **_kwargs: pytest.fail("音乐条目不应查询电视剧分集")
with database() as session:
with patch.object(
MEDIA_SERVER_CHAIN_MODULE,
"get_chain_media_server_port",
lambda: MediaServerOper(session),
), patch.object(
MEDIA_SERVER_CHAIN_MODULE,
"get_mediaserver_configs",
return_value=[SimpleNamespace(name="navidrome", enabled=True, sync_libraries=["all"])],
):
chain.sync()
session.commit()
with patch.object(
MEDIA_SERVER_CHAIN_MODULE,
"get_chain_media_server_port",
lambda: TransactionalMediaServerRepository(database),
), patch.object(
MEDIA_SERVER_CHAIN_MODULE,
"get_mediaserver_configs",
return_value=[SimpleNamespace(name="navidrome", enabled=True, sync_libraries=["all"])],
):
chain.sync()
with database() as db:
item = db.query(MediaServerItem).one()
@@ -121,6 +120,43 @@ def test_sync_persists_music_without_querying_tv_episodes(database):
assert item.seasoninfo == {}
def test_sync_normalizes_incomplete_tv_episode_rows(database):
"""TV 同步应丢弃无季号记录,并把缺失集列表规范为空列表。"""
chain = object.__new__(MediaServerChain)
chain.librarys = lambda _server: [SimpleNamespace(id="shows", name="剧集库")]
chain.media_count = lambda _server: 1
chain.items_count = lambda **_kwargs: 1
chain.items = lambda **_kwargs: iter([
schemas.MediaServerItem(
server="plex",
library="shows",
item_id="show-1",
item_type="Series",
title="测试剧集",
)
])
chain.episodes = lambda *_args, **_kwargs: [
schemas.MediaServerSeasonInfo(season=None, episodes=[99]),
schemas.MediaServerSeasonInfo(season=1, episodes=None),
]
with patch.object(
MEDIA_SERVER_CHAIN_MODULE,
"get_chain_media_server_port",
lambda: TransactionalMediaServerRepository(database),
), patch.object(
MEDIA_SERVER_CHAIN_MODULE,
"get_mediaserver_configs",
return_value=[SimpleNamespace(name="plex", enabled=True, sync_libraries=["all"])],
):
chain.sync()
with database() as session:
item = session.query(MediaServerItem).one()
assert item.seasoninfo == {"1": []}
def test_sync_updates_rows_and_removes_stale_entries(database):
"""同步应更新已存在条目,并清理未再出现或已移除服务的数据。"""
old_sync_time = "2026-05-01 00:00:00"
@@ -193,18 +229,16 @@ def test_sync_updates_rows_and_removes_stale_entries(database):
)
chain.episodes = lambda *_args, **_kwargs: []
with database() as session:
with patch.object(
MEDIA_SERVER_CHAIN_MODULE,
"get_chain_media_server_port",
lambda: MediaServerOper(session),
), patch.object(
MEDIA_SERVER_CHAIN_MODULE,
"get_mediaserver_configs",
return_value=[SimpleNamespace(name="plex", enabled=True, sync_libraries=["movies"])],
):
chain.sync()
session.commit()
with patch.object(
MEDIA_SERVER_CHAIN_MODULE,
"get_chain_media_server_port",
lambda: TransactionalMediaServerRepository(database),
), patch.object(
MEDIA_SERVER_CHAIN_MODULE,
"get_mediaserver_configs",
return_value=[SimpleNamespace(name="plex", enabled=True, sync_libraries=["movies"])],
):
chain.sync()
with database() as db:
items = (
@@ -277,23 +311,21 @@ def test_sync_queries_counts_before_items_and_reports_media_progress(database):
chain.items = items
chain.episodes = lambda *_args, **_kwargs: []
with database() as session:
with patch.object(
MEDIA_SERVER_CHAIN_MODULE,
"get_chain_media_server_port",
lambda: MediaServerOper(session),
), patch.object(
MEDIA_SERVER_CHAIN_MODULE,
"get_mediaserver_configs",
return_value=[
SimpleNamespace(name="plex-a", enabled=True, sync_libraries=["all"]),
SimpleNamespace(name="plex-b", enabled=True, sync_libraries=["all"]),
],
):
chain.sync(
progress_callback=lambda **kwargs: progress_snapshots.append(kwargs)
)
session.commit()
with patch.object(
MEDIA_SERVER_CHAIN_MODULE,
"get_chain_media_server_port",
lambda: TransactionalMediaServerRepository(database),
), patch.object(
MEDIA_SERVER_CHAIN_MODULE,
"get_mediaserver_configs",
return_value=[
SimpleNamespace(name="plex-a", enabled=True, sync_libraries=["all"]),
SimpleNamespace(name="plex-b", enabled=True, sync_libraries=["all"]),
],
):
chain.sync(
progress_callback=lambda **kwargs: progress_snapshots.append(kwargs)
)
assert events == [
"count:plex-a",
@@ -354,6 +386,101 @@ def test_sync_targets_one_server_without_excluding_other_enabled_servers(monkeyp
assert excluded_server_calls == [["plex-a", "plex-b"]]
def test_sync_partial_commit_preserves_stale_rows_until_next_run(
database,
monkeypatch,
):
"""前序条目提交后发生失败时保留 stale,并由下一轮成功同步收敛。"""
old_sync_time = "2026-05-01 00:00:00"
with database() as session:
session.add(MediaServerItem(
server="plex",
library="movies",
item_id="stale",
item_type="电影",
title="陈旧条目",
lst_mod_date=old_sync_time,
))
session.commit()
chain = object.__new__(MediaServerChain)
stale_calls = []
items = [
schemas.MediaServerItem(
server="plex",
library="movies",
item_id=f"movie-{index}",
item_type="Movie",
title=f"测试电影 {index}",
)
for index in (1, 2)
]
transactional = TransactionalMediaServerRepository(database)
class FailingMediaServerRepository:
"""提交首个条目后让第二个条目失败,并记录不应发生的清理。"""
def __init__(self):
"""初始化本轮已尝试写入计数。"""
self.upsert_count = 0
def delete_excluded_servers(self, servers):
"""委托真实短事务清理已移除服务器。"""
return transactional.delete_excluded_servers(servers)
def upsert(self, item):
"""提交首条记录,在第二条写入前模拟数据库不可用。"""
self.upsert_count += 1
if self.upsert_count == 2:
raise RuntimeError("database unavailable")
return transactional.upsert(item)
def delete_stale(self, **kwargs):
"""记录不应发生的 stale 清理。"""
stale_calls.append(kwargs)
return transactional.delete_stale(**kwargs)
chain.librarys = lambda _server: [SimpleNamespace(id="movies", name="电影库")]
chain.media_count = lambda _server: len(items)
chain.items_count = lambda **_kwargs: len(items)
chain.items = lambda **_kwargs: iter(items)
chain.episodes = lambda *_args, **_kwargs: []
repository = FailingMediaServerRepository()
monkeypatch.setattr(
MEDIA_SERVER_CHAIN_MODULE,
"get_chain_media_server_port",
lambda: repository,
)
monkeypatch.setattr(
MEDIA_SERVER_CHAIN_MODULE,
"get_mediaserver_configs",
lambda **_kwargs: [
SimpleNamespace(name="plex", enabled=True, sync_libraries=["all"])
],
)
with pytest.raises(RuntimeError, match="database unavailable"):
chain.sync()
assert stale_calls == []
with database() as session:
assert {
item.item_id for item in session.query(MediaServerItem).all()
} == {"movie-1", "stale"}
monkeypatch.setattr(
MEDIA_SERVER_CHAIN_MODULE,
"get_chain_media_server_port",
lambda: transactional,
)
chain.sync()
with database() as session:
assert {
item.item_id for item in session.query(MediaServerItem).all()
} == {"movie-1", "movie-2"}
def test_sync_stops_without_emitting_completion_after_stop_signal(monkeypatch):
"""系统停止发生在逐库同步期间时,不应再发送服务器或全局完成进度。"""
chain = object.__new__(MediaServerChain)
@@ -379,7 +506,12 @@ def test_sync_stops_without_emitting_completion_after_stop_signal(monkeypatch):
monkeypatch.setattr(
chain,
"_prepare_sync_contexts",
lambda _servers, _server: ([server], 1, {"plex": ([], {})}, 0),
lambda _servers, _server, _repository: (
[server],
1,
{"plex": ([], {})},
0,
),
)
monkeypatch.setattr(chain, "_sync_server_libraries", stop_during_sync)
monkeypatch.setattr(
+128 -7
View File
@@ -1,33 +1,138 @@
"""下载失败冷却切片显式 UoW 的回归测试。"""
from dataclasses import FrozenInstanceError
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.application.download.failures import (
DownloadFailureSnapshot,
DownloadFailureWrite,
)
from app.db.adapters.download import TransactionalDownloadFailureRepository
from app.db.base import Base
from app.db.models.downloadfailure import DownloadFailure
from app.db.session import SessionFactory
def _session_factory(session: MagicMock):
def _session_factory(session: MagicMock) -> MagicMock:
"""返回支持 context manager 的固定会话工厂。"""
session.__enter__.return_value = session
session.__exit__.return_value = False
return MagicMock(return_value=session)
def test_record_failure_commits_explicit_unit_of_work() -> None:
"""写入成功后只由适配器显式提交一次事务"""
def _failure_write() -> DownloadFailureWrite:
"""构造事务测试共用的最小下载失败写入 DTO"""
return DownloadFailureWrite(
fingerprint="fp",
failed_at="now",
next_retry_at="next",
title="片名",
)
@pytest.fixture
def real_session_factory(tmp_path):
"""创建隔离的下载失败冷却数据库与会话工厂。"""
engine = create_engine(f"sqlite:///{tmp_path / 'download-failure.db'}")
factory = sessionmaker(bind=engine)
Base.metadata.create_all(bind=engine)
yield factory
engine.dispose()
def test_real_session_round_trip_returns_detached_snapshot(real_session_factory) -> None:
"""真实写入提交后,查询结果在 Session 关闭后仍是可读冻结快照。"""
repository = TransactionalDownloadFailureRepository(real_session_factory)
assert repository.record_failure(_failure_write()) is None
result = repository.get_active_by_fingerprints(["fp"], "before-next")
assert result == {
"fp": DownloadFailureSnapshot(
fingerprint="fp",
error_message=None,
next_retry_at="next",
)
}
with pytest.raises(FrozenInstanceError):
result["fp"].next_retry_at = "changed"
def test_active_failures_are_projected_to_detached_frozen_snapshots() -> None:
"""只读适配器必须在会话关闭前投影,不得返回 ORM 记录。"""
session = MagicMock()
record = SimpleNamespace(
fingerprint="fp",
error_message="无法读取种子文件",
next_retry_at="next",
)
oper = MagicMock()
oper.record_failure.return_value = object()
oper.get_active_by_fingerprints.return_value = {"fp": record}
repository = TransactionalDownloadFailureRepository(_session_factory(session))
with patch(
"app.db.adapters.download.DownloadFailureOper",
return_value=oper,
):
result = repository.record_failure("fp", "now", "next", title="片名")
result = repository.get_active_by_fingerprints(["fp"], "now")
assert result is oper.record_failure.return_value
snapshot = result["fp"]
assert snapshot == DownloadFailureSnapshot(
fingerprint="fp",
error_message="无法读取种子文件",
next_retry_at="next",
)
assert snapshot is not record
with pytest.raises(FrozenInstanceError):
setattr(snapshot, "error_message", "changed")
session.__exit__.assert_called_once()
def test_sqlite_snapshot_survives_repository_session_close(db) -> None:
"""真实 SQLite 查询关闭适配器 Session 后仍可完整读取快照。"""
db.watermark(DownloadFailure)
repository = TransactionalDownloadFailureRepository(SessionFactory)
repository.record_failure(DownloadFailureWrite(
fingerprint="typed-detached-snapshot",
failed_at="2026-08-28 00:00:00",
next_retry_at="2026-08-29 00:00:00",
error_message="无法读取种子文件",
))
snapshots = repository.get_active_by_fingerprints(
["typed-detached-snapshot"],
"2026-08-28 12:00:00",
)
assert snapshots["typed-detached-snapshot"] == DownloadFailureSnapshot(
fingerprint="typed-detached-snapshot",
error_message="无法读取种子文件",
next_retry_at="2026-08-29 00:00:00",
)
def test_record_failure_commits_explicit_unit_of_work() -> None:
"""写入成功后只由适配器显式提交一次事务。"""
session = MagicMock()
oper = MagicMock()
repository = TransactionalDownloadFailureRepository(_session_factory(session))
with patch(
"app.db.adapters.download.DownloadFailureOper",
return_value=oper,
):
result = repository.record_failure(_failure_write())
assert result is None
assert oper.record_failure.call_args.kwargs["fingerprint"] == "fp"
assert oper.record_failure.call_args.kwargs["now_time"] == "now"
assert oper.record_failure.call_args.kwargs["next_retry_at"] == "next"
assert oper.record_failure.call_args.kwargs["title"] == "片名"
session.commit.assert_called_once_with()
session.rollback.assert_not_called()
@@ -43,7 +148,23 @@ def test_record_failure_rolls_back_explicit_unit_of_work() -> None:
"app.db.adapters.download.DownloadFailureOper",
return_value=oper,
), pytest.raises(ValueError, match="duplicate"):
repository.record_failure("fp", "now", "next")
repository.record_failure(_failure_write())
session.rollback.assert_called_once_with()
session.commit.assert_not_called()
def test_record_failure_rolls_back_commit_failure() -> None:
"""提交失败时也必须回滚并传播提交异常。"""
session = MagicMock()
session.commit.side_effect = RuntimeError("commit failed")
repository = TransactionalDownloadFailureRepository(_session_factory(session))
with patch("app.db.adapters.download.DownloadFailureOper"), pytest.raises(
RuntimeError,
match="commit failed",
):
repository.record_failure(_failure_write())
session.commit.assert_called_once_with()
session.rollback.assert_called_once_with()
+183
View File
@@ -0,0 +1,183 @@
"""媒体服务器本地缓存短事务适配器的回归测试。"""
import json
from dataclasses import FrozenInstanceError
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.application.mediaserver import MediaServerQueryService, MediaServerSyncItem
from app.db.adapters.mediaserver import TransactionalMediaServerRepository
from app.db.base import Base
from app.db.models.mediaserver import MediaServerItem
from app.schemas.mediaserver import MediaServerItem as MediaServerItemSchema
from app.schemas.types import MediaSource
@pytest.fixture
def session_factory(tmp_path):
"""创建隔离的媒体服务器缓存数据库与会话工厂。"""
engine = create_engine(f"sqlite:///{tmp_path / 'mediaserver-adapter.db'}")
factory = sessionmaker(bind=engine)
Base.metadata.create_all(bind=engine)
yield factory
engine.dispose()
def _sync_item(*, title: str, sync_time: str) -> MediaServerSyncItem:
"""构造一个可重复 upsert 的冻结媒体库条目。"""
return MediaServerSyncItem(
server="plex",
library="movies",
item_id="item-1",
item_type="电影",
title=title,
original_title=None,
year="2026",
media_source=MediaSource.TMDB,
media_id="1001",
path=f"/media/{title}.mkv",
seasoninfo=((1, (1, 2)),),
note_json=None,
lst_mod_date=sync_time,
)
def test_upsert_commits_insert_and_update_without_external_session(
session_factory,
monkeypatch,
) -> None:
"""每次 upsert 应在单一短 Session 中完成查询、写入和提交。"""
from app.db import base as db_base
monkeypatch.setattr(
db_base,
"run_sync_transaction",
lambda _operation: (_ for _ in ()).throw(
AssertionError("显式 Session 不应创建兼容事务")
),
)
repository = TransactionalMediaServerRepository(session_factory)
assert repository.upsert(
_sync_item(title="旧标题", sync_time="2026-08-28 10:00:00")
)
assert not repository.upsert(
_sync_item(title="新标题", sync_time="2026-08-28 11:00:00")
)
with session_factory() as session:
items = session.query(MediaServerItem).all()
assert len(items) == 1
assert items[0].title == "新标题"
assert items[0].path == "/media/新标题.mkv"
assert items[0].seasoninfo == {"1": [1, 2]}
def test_get_item_id_returns_scalar_after_short_session_closes(session_factory) -> None:
"""查询端口只返回标量 ID,不把 ORM 条目带出短 Session。"""
repository = TransactionalMediaServerRepository(session_factory)
repository.upsert(
_sync_item(title="查询标题", sync_time="2026-08-28 10:00:00")
)
item_id = repository.get_item_id(
media_source=MediaSource.TMDB,
media_id="1001",
mtype="电影",
season=1,
)
assert item_id == "item-1"
def test_sync_item_deeply_detaches_mutable_remote_values() -> None:
"""冻结 DTO 必须规范化季集并断开与远端 note 的可变引用。"""
note = {"nested": ["original"]}
source = MediaServerItemSchema(
server="plex",
library="shows",
item_id="show-1",
item_type="Series",
title="剧集",
note=note,
)
snapshot = MediaServerSyncItem.from_item(
source,
item_type="电视剧",
seasoninfo={1: None, 2: [1, 2]},
sync_time="2026-08-28 10:00:00",
)
note["nested"].append("changed")
assert snapshot.seasoninfo == ((1, ()), (2, (1, 2)))
assert json.loads(snapshot.note_json or "null") == {"nested": ["original"]}
with pytest.raises(FrozenInstanceError):
snapshot.note_json = None
def test_upsert_rolls_back_and_preserves_original_error() -> None:
"""单条写入异常时必须回滚短事务并继续抛出原始异常。"""
session = MagicMock()
session.__enter__.return_value = session
session.__exit__.return_value = False
repository = TransactionalMediaServerRepository(MagicMock(return_value=session))
with patch(
"app.db.adapters.mediaserver.MediaServerOper.upsert",
side_effect=ValueError("invalid item"),
), pytest.raises(ValueError, match="invalid item"):
repository.upsert(
_sync_item(title="失败标题", sync_time="2026-08-28 10:00:00")
)
session.rollback.assert_called_once_with()
session.commit.assert_not_called()
def test_upsert_rolls_back_commit_failure() -> None:
"""提交阶段失败也必须回滚短事务并传播原始异常。"""
session = MagicMock()
session.__enter__.return_value = session
session.__exit__.return_value = False
session.commit.side_effect = RuntimeError("commit failed")
repository = TransactionalMediaServerRepository(MagicMock(return_value=session))
with patch(
"app.db.adapters.mediaserver.MediaServerOper.upsert",
return_value=True,
), pytest.raises(RuntimeError, match="commit failed"):
repository.upsert(
_sync_item(title="提交失败", sync_time="2026-08-28 10:00:00")
)
session.commit.assert_called_once_with()
session.rollback.assert_called_once_with()
@pytest.mark.asyncio
async def test_query_service_consumes_scalar_repository_result() -> None:
"""Application 查询服务只消费标量 ID,不读取 ORM 属性。"""
repository = MagicMock()
repository.async_get_item_id = AsyncMock(return_value="item-1")
service = MediaServerQueryService(repository)
result = await service.find_item_id(
media_source=MediaSource.TMDB,
media_id="1001",
mtype="电影",
)
assert result == "item-1"
repository.async_get_item_id.assert_awaited_once_with(
title=None,
year=None,
mtype="电影",
media_source=MediaSource.TMDB,
media_id="1001",
season=None,
)