mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 16:07:01 +08:00
refactor(chain): type download and media server ports
This commit is contained in:
@@ -10,10 +10,14 @@ from collections.abc import Callable
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Optional
|
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.execution import TransferExecutionRepository
|
||||||
from app.application.transfer.workflow import TransferAdmissionRepository
|
from app.application.transfer.workflow import TransferAdmissionRepository
|
||||||
|
|
||||||
OperFactory = Callable[[], Any]
|
OperFactory = Callable[[], Any]
|
||||||
|
DownloadFailureRepositoryFactory = Callable[[], DownloadFailureRepository]
|
||||||
|
MediaServerRepositoryFactory = Callable[[], MediaServerRepository]
|
||||||
TransferAdmissionRepositoryFactory = Callable[[], TransferAdmissionRepository]
|
TransferAdmissionRepositoryFactory = Callable[[], TransferAdmissionRepository]
|
||||||
TransferExecutionRepositoryFactory = Callable[[], TransferExecutionRepository]
|
TransferExecutionRepositoryFactory = Callable[[], TransferExecutionRepository]
|
||||||
|
|
||||||
@@ -28,8 +32,8 @@ class ChainDataPorts:
|
|||||||
transfer_history: OperFactory
|
transfer_history: OperFactory
|
||||||
transfer_pending: TransferAdmissionRepositoryFactory
|
transfer_pending: TransferAdmissionRepositoryFactory
|
||||||
transfer_execution: TransferExecutionRepositoryFactory
|
transfer_execution: TransferExecutionRepositoryFactory
|
||||||
media_server: OperFactory
|
media_server: MediaServerRepositoryFactory
|
||||||
download_failure: OperFactory
|
download_failure: DownloadFailureRepositoryFactory
|
||||||
user: OperFactory
|
user: OperFactory
|
||||||
|
|
||||||
|
|
||||||
@@ -44,8 +48,8 @@ def configure_chain_data_ports(
|
|||||||
transfer_history: OperFactory,
|
transfer_history: OperFactory,
|
||||||
transfer_pending: TransferAdmissionRepositoryFactory,
|
transfer_pending: TransferAdmissionRepositoryFactory,
|
||||||
transfer_execution: TransferExecutionRepositoryFactory,
|
transfer_execution: TransferExecutionRepositoryFactory,
|
||||||
media_server: OperFactory,
|
media_server: MediaServerRepositoryFactory,
|
||||||
download_failure: OperFactory,
|
download_failure: DownloadFailureRepositoryFactory,
|
||||||
user: OperFactory,
|
user: OperFactory,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""由启动组合根登记显式命名的 Chain 数据端口实现。"""
|
"""由启动组合根登记显式命名的 Chain 数据端口实现。"""
|
||||||
@@ -100,13 +104,13 @@ def get_chain_transfer_execution_port() -> TransferExecutionRepository:
|
|||||||
return get_chain_data_ports().transfer_execution()
|
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()
|
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()
|
return get_chain_data_ports().download_failure()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -1,16 +1,17 @@
|
|||||||
|
import json
|
||||||
import re
|
import re
|
||||||
from collections.abc import Iterable, Mapping
|
from collections.abc import Iterable, Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
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.application.service import ServiceBaseHelper
|
||||||
from app.schemas.mediaserver import MediaServerItemUserState as _SchemaMediaServerItemUserState
|
|
||||||
from app.domain.context import MusicInfo
|
from app.domain.context import MusicInfo
|
||||||
from app.runtime.log import logger
|
from app.runtime.log import logger
|
||||||
from app.schemas.media import normalize_media_source, resolve_media_identity
|
from app.schemas.media import normalize_media_source, resolve_media_identity
|
||||||
from app.application.service import ServiceBaseHelper
|
from app.schemas.mediaserver import MediaServerItem as _SchemaMediaServerItem
|
||||||
from app.schemas.system import MediaServerConf
|
from app.schemas.mediaserver import MediaServerItemUserState as _SchemaMediaServerItemUserState
|
||||||
from app.schemas.system import ServiceInfo
|
from app.schemas.system import MediaServerConf, ServiceInfo
|
||||||
from app.schemas.types import (
|
from app.schemas.types import (
|
||||||
MUSIC_ENTITY_ALBUM,
|
MUSIC_ENTITY_ALBUM,
|
||||||
MediaSource,
|
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):
|
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:
|
class MediaServerQueryService:
|
||||||
"""封装媒体服务器本地存在性查询与 ORM 投影。"""
|
"""封装媒体服务器本地存在性标量查询。"""
|
||||||
|
|
||||||
def __init__(self, repository: AsyncMediaServerQueryRepository):
|
def __init__(self, repository: AsyncMediaServerQueryRepository):
|
||||||
"""使用显式媒体服务器查询端口初始化服务。"""
|
"""使用显式媒体服务器查询端口初始化服务。"""
|
||||||
@@ -38,14 +129,14 @@ class MediaServerQueryService:
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
title: Optional[str] = None,
|
title: Optional[str] = None,
|
||||||
year: Optional[str] = None,
|
year: Optional[Union[str, int]] = None,
|
||||||
mtype: Optional[str] = None,
|
mtype: Optional[str] = None,
|
||||||
media_source: Optional[MediaSource] = None,
|
media_source: Optional[MediaSource] = None,
|
||||||
media_id: Optional[str] = None,
|
media_id: Optional[str] = None,
|
||||||
season: Optional[int] = None,
|
season: Optional[int] = None,
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""返回匹配条目的服务器 item_id,未命中时返回 None。"""
|
"""返回匹配条目的服务器 item_id,未命中时返回 None。"""
|
||||||
item = await self._repository.async_exists(
|
return await self._repository.async_get_item_id(
|
||||||
title=title,
|
title=title,
|
||||||
year=year,
|
year=year,
|
||||||
mtype=mtype,
|
mtype=mtype,
|
||||||
@@ -53,7 +144,6 @@ class MediaServerQueryService:
|
|||||||
media_id=media_id,
|
media_id=media_id,
|
||||||
season=season,
|
season=season,
|
||||||
)
|
)
|
||||||
return item.item_id if item else None
|
|
||||||
|
|
||||||
|
|
||||||
class MediaServerIdentityHelper:
|
class MediaServerIdentityHelper:
|
||||||
|
|||||||
+54
-35
@@ -6,7 +6,7 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
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 urllib.parse import parse_qs, urlencode, urljoin, urlparse
|
||||||
|
|
||||||
from app.adapters.network.http import RequestUtils
|
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.configuration import get_chain_runtime_config_snapshot
|
||||||
from app.application.directory import DirectoryHelper, validate_download_save_path
|
from app.application.directory import DirectoryHelper, validate_download_save_path
|
||||||
from app.application.download import selection as _selection
|
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.download.tasks import DownloadTaskService
|
||||||
from app.application.torrent import TorrentHelper
|
from app.application.torrent import TorrentHelper
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
@@ -63,12 +68,6 @@ from app.schemas.types import (
|
|||||||
)
|
)
|
||||||
from app.schemas.workflow import FileItem as _SchemaFileItem
|
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_RESOURCE_TTL_SECONDS = 24 * 60 * 60
|
||||||
DOWNLOAD_FAILURE_TRANSIENT_TTL_SECONDS = 60 * 60
|
DOWNLOAD_FAILURE_TRANSIENT_TTL_SECONDS = 60 * 60
|
||||||
DOWNLOAD_FAILURE_RESOURCE_ERROR_KEYWORDS = (
|
DOWNLOAD_FAILURE_RESOURCE_ERROR_KEYWORDS = (
|
||||||
@@ -880,25 +879,40 @@ class DownloadChain(ChainBase):
|
|||||||
torrent = context.torrent_info
|
torrent = context.torrent_info
|
||||||
site = getattr(torrent, "site", None)
|
site = getattr(torrent, "site", None)
|
||||||
try:
|
try:
|
||||||
get_chain_download_failure_port().record_failure(
|
repository: DownloadFailureRepository = get_chain_download_failure_port()
|
||||||
fingerprint=fingerprint,
|
repository.record_failure(
|
||||||
now_time=now_time,
|
DownloadFailureWrite(
|
||||||
next_retry_at=next_retry_at,
|
fingerprint=fingerprint,
|
||||||
type=getattr(getattr(media, "type", None), "value", getattr(media, "type", None)),
|
failed_at=now_time,
|
||||||
title=getattr(media, "title", None),
|
next_retry_at=next_retry_at,
|
||||||
year=getattr(media, "year", None),
|
media_type=getattr(
|
||||||
media_source=media_source,
|
getattr(media, "type", None),
|
||||||
media_id=media_id,
|
"value",
|
||||||
seasons=getattr(meta, "season", None),
|
getattr(media, "type", None),
|
||||||
episodes=episode_rules.format_ranges(list(episodes)) if episodes else self._format_failure_episodes(meta),
|
),
|
||||||
site=site if isinstance(site, int) else None,
|
title=getattr(media, "title", None),
|
||||||
site_name=getattr(torrent, "site_name", None),
|
year=getattr(media, "year", None),
|
||||||
torrent_id=self._torrent_resource_key(torrent),
|
media_source=media_source,
|
||||||
torrent_name=getattr(torrent, "title", None),
|
media_id=media_id,
|
||||||
torrent_size=getattr(torrent, "size", None),
|
seasons=(
|
||||||
downloader=downloader,
|
str(getattr(meta, "season", None))
|
||||||
source=str(source)[:1000] if source else None,
|
if getattr(meta, "season", None) is not None
|
||||||
error_message=str(error_msg or "")[:1000],
|
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:
|
except Exception as err:
|
||||||
logger.error(f"记录下载失败冷却失败:{str(err)}")
|
logger.error(f"记录下载失败冷却失败:{str(err)}")
|
||||||
@@ -908,7 +922,7 @@ class DownloadChain(ChainBase):
|
|||||||
self,
|
self,
|
||||||
contexts: List[Context],
|
contexts: List[Context],
|
||||||
source: Optional[str],
|
source: Optional[str],
|
||||||
) -> Dict[str, "DownloadFailure"]:
|
) -> Dict[str, DownloadFailureSnapshot]:
|
||||||
"""
|
"""
|
||||||
查询当前订阅候选中仍处于冷却期的失败记录,返回指纹到失败记录的映射。
|
查询当前订阅候选中仍处于冷却期的失败记录,返回指纹到失败记录的映射。
|
||||||
"""
|
"""
|
||||||
@@ -926,7 +940,8 @@ class DownloadChain(ChainBase):
|
|||||||
return {}
|
return {}
|
||||||
now_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
now_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||||
try:
|
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,
|
fingerprints=fingerprints, now_time=now_time,
|
||||||
)
|
)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
@@ -936,7 +951,7 @@ class DownloadChain(ChainBase):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _log_download_failure_cooldown(
|
def _log_download_failure_cooldown(
|
||||||
context: Context,
|
context: Context,
|
||||||
failure: Optional["DownloadFailure"],
|
failure: Optional[DownloadFailureSnapshot],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""记录候选资源处于失败冷却期时的跳过原因和下次重试时间。"""
|
"""记录候选资源处于失败冷却期时的跳过原因和下次重试时间。"""
|
||||||
reason = getattr(failure, "error_message", None) or "未知原因"
|
reason = getattr(failure, "error_message", None) or "未知原因"
|
||||||
@@ -957,7 +972,7 @@ class DownloadChain(ChainBase):
|
|||||||
contexts: List[Context],
|
contexts: List[Context],
|
||||||
downloader: Optional[str],
|
downloader: Optional[str],
|
||||||
source: 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 = event_data.updated_contexts
|
||||||
contexts = TorrentHelper().sort_torrents(contexts)
|
contexts = TorrentHelper().sort_torrents(contexts)
|
||||||
return contexts, self._active_download_failure_fingerprints(
|
active_failures: Dict[str, Optional[DownloadFailureSnapshot]] = {
|
||||||
contexts=contexts,
|
fingerprint: failure
|
||||||
source=source,
|
for fingerprint, failure in self._active_download_failure_fingerprints(
|
||||||
)
|
contexts=contexts,
|
||||||
|
source=source,
|
||||||
|
).items()
|
||||||
|
}
|
||||||
|
return contexts, active_failures
|
||||||
|
|
||||||
def _download_movie_music_candidates(
|
def _download_movie_music_candidates(
|
||||||
self,
|
self,
|
||||||
contexts: List[Context],
|
contexts: List[Context],
|
||||||
downloaded_list: List[Context],
|
downloaded_list: List[Context],
|
||||||
active_failure_records: Dict[str, "DownloadFailure"],
|
active_failure_records: Dict[str, Optional[DownloadFailureSnapshot]],
|
||||||
save_path: Optional[str],
|
save_path: Optional[str],
|
||||||
channel: Optional[NotificationChannel],
|
channel: Optional[NotificationChannel],
|
||||||
source: Optional[str],
|
source: Optional[str],
|
||||||
|
|||||||
+26
-16
@@ -3,7 +3,11 @@ from datetime import datetime
|
|||||||
from typing import Any, Callable, Dict, Generator, List, Optional, Tuple, Union
|
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.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.application.security.url import SecurityUtils
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
from app.runtime.log import logger
|
from app.runtime.log import logger
|
||||||
@@ -271,6 +275,7 @@ class MediaServerChain(ChainBase):
|
|||||||
self,
|
self,
|
||||||
mediaservers: List[Any],
|
mediaservers: List[Any],
|
||||||
server: Optional[str],
|
server: Optional[str],
|
||||||
|
repository: MediaServerRepository,
|
||||||
) -> Tuple[List[Any], int, Dict[str, Any], Optional[int]]:
|
) -> Tuple[List[Any], int, Dict[str, Any], Optional[int]]:
|
||||||
"""
|
"""
|
||||||
准备启用媒体服务器的同步库和进度总量。
|
准备启用媒体服务器的同步库和进度总量。
|
||||||
@@ -281,8 +286,7 @@ class MediaServerChain(ChainBase):
|
|||||||
item.name for item in mediaservers
|
item.name for item in mediaservers
|
||||||
if item and item.enabled and item.name
|
if item and item.enabled and item.name
|
||||||
]
|
]
|
||||||
dboper = get_chain_media_server_port()
|
repository.delete_excluded_servers(enabled_servers)
|
||||||
dboper.delete_excluded_servers(enabled_servers)
|
|
||||||
selected_servers = [
|
selected_servers = [
|
||||||
item for item in mediaservers
|
item for item in mediaservers
|
||||||
if item and item.enabled and (not server or item.name == server)
|
if item and item.enabled and (not server or item.name == server)
|
||||||
@@ -332,7 +336,7 @@ class MediaServerChain(ChainBase):
|
|||||||
server_name: str,
|
server_name: str,
|
||||||
selected_libraries: List[Any],
|
selected_libraries: List[Any],
|
||||||
library_media_counts: Dict[str, Optional[int]],
|
library_media_counts: Dict[str, Optional[int]],
|
||||||
dboper: Any,
|
repository: MediaServerRepository,
|
||||||
sync_time: str,
|
sync_time: str,
|
||||||
progress_callback: Optional[Callable[..., None]],
|
progress_callback: Optional[Callable[..., None]],
|
||||||
server_index: int,
|
server_index: int,
|
||||||
@@ -363,14 +367,17 @@ class MediaServerChain(ChainBase):
|
|||||||
item_type = self._normalize_item_type(item.item_type)
|
item_type = self._normalize_item_type(item.item_type)
|
||||||
if item_type == MediaType.TV.value:
|
if item_type == MediaType.TV.value:
|
||||||
for episode in self.episodes(server_name, item.item_id) or []:
|
for episode in self.episodes(server_name, item.item_id) or []:
|
||||||
seasoninfo[episode.season] = episode.episodes
|
if episode.season is None:
|
||||||
item_dict = item.model_dump()
|
continue
|
||||||
item_dict.update({
|
seasoninfo[episode.season] = episode.episodes or []
|
||||||
"seasoninfo": seasoninfo,
|
repository.upsert(
|
||||||
"item_type": item_type,
|
MediaServerSyncItem.from_item(
|
||||||
"lst_mod_date": sync_time,
|
item,
|
||||||
})
|
item_type=item_type,
|
||||||
dboper.upsert(**item_dict)
|
seasoninfo=seasoninfo,
|
||||||
|
sync_time=sync_time,
|
||||||
|
)
|
||||||
|
)
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
if global_media_total:
|
if global_media_total:
|
||||||
progress_value = min(global_media_finished / global_media_total, 1) * 100
|
progress_value = min(global_media_finished / global_media_total, 1) * 100
|
||||||
@@ -423,7 +430,10 @@ class MediaServerChain(ChainBase):
|
|||||||
"media_finished": global_media_finished,
|
"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}")
|
logger.info(f"媒体服务器 {server_name} 清理陈旧数据完成,删除数量:{stale_count}")
|
||||||
return total_count, global_media_finished
|
return total_count, global_media_finished
|
||||||
|
|
||||||
@@ -463,9 +473,9 @@ class MediaServerChain(ChainBase):
|
|||||||
with lock:
|
with lock:
|
||||||
# 汇总统计
|
# 汇总统计
|
||||||
total_count = 0
|
total_count = 0
|
||||||
dboper = get_chain_media_server_port()
|
repository = get_chain_media_server_port()
|
||||||
mediaservers, total_servers, server_sync_contexts, global_media_total = (
|
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:
|
if progress_callback:
|
||||||
progress_callback(
|
progress_callback(
|
||||||
@@ -543,7 +553,7 @@ class MediaServerChain(ChainBase):
|
|||||||
server_name=server_name,
|
server_name=server_name,
|
||||||
selected_libraries=selected_libraries,
|
selected_libraries=selected_libraries,
|
||||||
library_media_counts=library_media_counts,
|
library_media_counts=library_media_counts,
|
||||||
dboper=dboper,
|
repository=repository,
|
||||||
sync_time=sync_time,
|
sync_time=sync_time,
|
||||||
progress_callback=progress_callback,
|
progress_callback=progress_callback,
|
||||||
server_index=server_index,
|
server_index=server_index,
|
||||||
|
|||||||
+39
-22
@@ -3,8 +3,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
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.oper.downloadfailure import DownloadFailureOper
|
||||||
from app.db.uow import SqlAlchemyUnitOfWork
|
from app.db.uow import SqlAlchemyUnitOfWork
|
||||||
|
|
||||||
@@ -12,7 +17,7 @@ from app.db.uow import SqlAlchemyUnitOfWork
|
|||||||
class TransactionalDownloadFailureRepository:
|
class TransactionalDownloadFailureRepository:
|
||||||
"""为 Chain 下载失败读写创建短生命周期会话并显式收口事务。"""
|
"""为 Chain 下载失败读写创建短生命周期会话并显式收口事务。"""
|
||||||
|
|
||||||
def __init__(self, session_factory: Callable[[], Any]) -> None:
|
def __init__(self, session_factory: Callable[[], Session]) -> None:
|
||||||
"""保存由启动组合根提供的同步会话工厂。"""
|
"""保存由启动组合根提供的同步会话工厂。"""
|
||||||
self._session_factory = session_factory
|
self._session_factory = session_factory
|
||||||
|
|
||||||
@@ -20,36 +25,48 @@ class TransactionalDownloadFailureRepository:
|
|||||||
self,
|
self,
|
||||||
fingerprints: list[str],
|
fingerprints: list[str],
|
||||||
now_time: str,
|
now_time: str,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, DownloadFailureSnapshot]:
|
||||||
"""在独立只读会话中查询仍处于冷却期的失败记录。"""
|
"""在独立只读会话中查询仍处于冷却期的失败记录。"""
|
||||||
with self._session_factory() as session:
|
with self._session_factory() as session:
|
||||||
return cast(
|
records = DownloadFailureOper(db=session).get_active_by_fingerprints(
|
||||||
dict[str, Any],
|
fingerprints=fingerprints,
|
||||||
DownloadFailureOper(db=session).get_active_by_fingerprints(
|
now_time=now_time,
|
||||||
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(
|
def record_failure(self, failure: DownloadFailureWrite) -> None:
|
||||||
self,
|
|
||||||
fingerprint: str,
|
|
||||||
now_time: str,
|
|
||||||
next_retry_at: str,
|
|
||||||
**kwargs: object,
|
|
||||||
) -> Any:
|
|
||||||
"""在一个显式 UoW 中新增或更新下载失败记录。"""
|
"""在一个显式 UoW 中新增或更新下载失败记录。"""
|
||||||
with self._session_factory() as session:
|
with self._session_factory() as session:
|
||||||
transaction = SqlAlchemyUnitOfWork(session)
|
transaction = SqlAlchemyUnitOfWork(session)
|
||||||
try:
|
try:
|
||||||
failure = DownloadFailureOper(db=session).record_failure(
|
DownloadFailureOper(db=session).record_failure(
|
||||||
fingerprint=fingerprint,
|
fingerprint=failure.fingerprint,
|
||||||
now_time=now_time,
|
now_time=failure.failed_at,
|
||||||
next_retry_at=next_retry_at,
|
next_retry_at=failure.next_retry_at,
|
||||||
**kwargs,
|
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()
|
transaction.commit()
|
||||||
return failure
|
|
||||||
except Exception:
|
except Exception:
|
||||||
transaction.rollback()
|
transaction.rollback()
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -29,6 +29,12 @@ class MediaServerOper(DbOper):
|
|||||||
if hasattr(MediaServerItem, k) and k != "id"
|
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:
|
def add(self, **kwargs) -> bool:
|
||||||
"""
|
"""
|
||||||
新增媒体服务器数据
|
新增媒体服务器数据
|
||||||
@@ -132,10 +138,7 @@ class MediaServerOper(DbOper):
|
|||||||
|
|
||||||
if kwargs.get("season") is not None:
|
if kwargs.get("season") is not None:
|
||||||
# 判断季是否存在
|
# 判断季是否存在
|
||||||
if not item.seasoninfo:
|
if not self._contains_season(item, kwargs["season"]):
|
||||||
return None
|
|
||||||
seasoninfo = item.seasoninfo or {}
|
|
||||||
if kwargs.get("season") not in seasoninfo.keys():
|
|
||||||
return None
|
return None
|
||||||
return item
|
return item
|
||||||
|
|
||||||
@@ -169,10 +172,7 @@ class MediaServerOper(DbOper):
|
|||||||
|
|
||||||
if kwargs.get("season") is not None:
|
if kwargs.get("season") is not None:
|
||||||
# 判断季是否存在
|
# 判断季是否存在
|
||||||
if not item.seasoninfo:
|
if not self._contains_season(item, kwargs["season"]):
|
||||||
return None
|
|
||||||
seasoninfo = item.seasoninfo or {}
|
|
||||||
if kwargs.get("season") not in seasoninfo.keys():
|
|
||||||
return None
|
return None
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ from app.application.workflow import (
|
|||||||
from app.command import CommandChain
|
from app.command import CommandChain
|
||||||
from app.db.adapters.chain import TransactionalChainDurableEventWriter
|
from app.db.adapters.chain import TransactionalChainDurableEventWriter
|
||||||
from app.db.adapters.download import TransactionalDownloadFailureRepository
|
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.outbox import SqlAlchemyAsyncOutboxStager, SqlAlchemyOutboxRepository
|
||||||
from app.db.adapters.query import SqlAlchemyDataQueryAdapter
|
from app.db.adapters.query import SqlAlchemyDataQueryAdapter
|
||||||
from app.db.adapters.site import TransactionalSiteRepository
|
from app.db.adapters.site import TransactionalSiteRepository
|
||||||
@@ -892,7 +893,7 @@ async def init_modules() -> HostRuntime:
|
|||||||
transfer_execution=lambda: TransactionalTransferExecutionRepository(
|
transfer_execution=lambda: TransactionalTransferExecutionRepository(
|
||||||
SessionFactory
|
SessionFactory
|
||||||
),
|
),
|
||||||
media_server=lambda: MediaServerOper(),
|
media_server=lambda: TransactionalMediaServerRepository(SessionFactory),
|
||||||
download_failure=lambda: TransactionalDownloadFailureRepository(
|
download_failure=lambda: TransactionalDownloadFailureRepository(
|
||||||
SessionFactory
|
SessionFactory
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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 移植包环 |
|
| 非平凡 SCC | 2 | 新增 Chain 包根环;另一个是隔离的 29 模块 TMDB 移植包环 |
|
||||||
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
|
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
|
||||||
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
|
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
|
||||||
@@ -77,9 +77,9 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
|
|||||||
| Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement |
|
| Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement |
|
||||||
| Python 源码量 | 约 271,400 行 | 60 个文件超过 1,000 行,14 个超过 2,000 行 |
|
| Python 源码量 | 约 271,400 行 | 60 个文件超过 1,000 行,14 个超过 2,000 行 |
|
||||||
| 长方法 | 281 个超过 80 行 | 67 个超过 150 行,23 个超过 250 行;大量是私有方法 |
|
| 长方法 | 281 个超过 80 行 | 67 个超过 150 行,23 个超过 250 行;大量是私有方法 |
|
||||||
| 全量 mypy 历史债务 | 11,809 / 596 文件 | strict frontier 当前覆盖 41 个文件,本批迁移路径的类型债务已清零 |
|
| 全量 mypy 历史债务 | 11,808 / 596 文件 | strict frontier 当前覆盖 41 个文件,本批迁移路径的类型债务已清零 |
|
||||||
| Ruff 历史诊断 | 875 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
|
| Ruff 历史诊断 | 872 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
|
||||||
| 覆盖率低水位 | Application 78.89%,Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
|
| 覆盖率低水位 | Application 78.95%,Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
|
||||||
|
|
||||||
### 3.3 热点文件
|
### 3.3 热点文件
|
||||||
|
|
||||||
@@ -291,6 +291,8 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
|
|||||||
`WorkflowOper` 只保留显式 Session query/stage,旧无 Session 五方法只存在于 SDK Legacy/Compat。
|
`WorkflowOper` 只保留显式 Session query/stage,旧无 Session 五方法只存在于 SDK Legacy/Compat。
|
||||||
- [x] 删除 Chain registry 中零消费者 `*PortProxy`/动态转发和 `ChainRuntimeContext.data_ports`
|
- [x] 删除 Chain registry 中零消费者 `*PortProxy`/动态转发和 `ChainRuntimeContext.data_ports`
|
||||||
伪注入;Workflow 执行服务只在 Application owner 配置一次,不再重复注册到 `ChainDataPorts`。
|
伪注入;Workflow 执行服务只在 Application owner 配置一次,不再重复注册到 `ChainDataPorts`。
|
||||||
|
- [x] DownloadFailure/MediaServer 两个 registry 字段改用冻结 DTO 与 typed Repository factory;
|
||||||
|
ORM 不越过短 Session,媒体库远端枚举期间不持有事务,旧 Oper/Compat 与公开 Chain ABI 保持不变。
|
||||||
- [ ] `ChainDataPorts`/`AgentDataPorts` 可暂时保留为兼容聚合器,但字段必须显式、可类型检查。
|
- [ ] `ChainDataPorts`/`AgentDataPorts` 可暂时保留为兼容聚合器,但字段必须显式、可类型检查。
|
||||||
- [ ] 以一个业务纵切面迁移并验证后,再迁移下一组,禁止一次替换所有 Oper。
|
- [ ] 以一个业务纵切面迁移并验证后,再迁移下一组,禁止一次替换所有 Oper。
|
||||||
- [ ] 增加 AST 门禁,禁止向 `ChainDataPorts`、`AgentDataPorts` 和新的 canonical use-case service
|
- [ ] 增加 AST 门禁,禁止向 `ChainDataPorts`、`AgentDataPorts` 和新的 canonical use-case service
|
||||||
|
|||||||
@@ -697,15 +697,15 @@ flowchart LR
|
|||||||
SDK 导出(若公开)、`docs/rules/05-architecture.md` 与上述架构测试。
|
SDK 导出(若公开)、`docs/rules/05-architecture.md` 与上述架构测试。
|
||||||
- 延迟导入不被接受为隐藏循环依赖的手段。
|
- 延迟导入不被接受为隐藏循环依赖的手段。
|
||||||
|
|
||||||
### 10.1 2026-08-27 当前收口状态与后续边界
|
### 10.1 2026-08-28 当前收口状态与后续边界
|
||||||
|
|
||||||
当前宿主架构基线(排除 `app/plugins/**`)如下;数字来自
|
当前宿主架构基线(排除 `app/plugins/**`)如下;数字来自
|
||||||
`tests/fixtures/architecture/`,更新基线前必须先审查语义变化:
|
`tests/fixtures/architecture/`,更新基线前必须先审查语义变化:
|
||||||
|
|
||||||
| 指标 | 当前值 |
|
| 指标 | 当前值 |
|
||||||
|---|---:|
|
|---|---:|
|
||||||
| Python 模块 | 850 |
|
| Python 模块 | 852 |
|
||||||
| 内部导入边 | 6,944 |
|
| 内部导入边 | 6,962 |
|
||||||
| 非平凡 SCC | 2(`ARCH-107` 临时 Chain 包根环;精确 containment 的 TMDB 移植包环) |
|
| 非平凡 SCC | 2(`ARCH-107` 临时 Chain 包根环;精确 containment 的 TMDB 移植包环) |
|
||||||
| Direct egress | 66(12 条待迁移债务,54 条精确 containment) |
|
| Direct egress | 66(12 条待迁移债务,54 条精确 containment) |
|
||||||
| Module Contract V2 spec | 217(其中 215 个进入 `run_module` 观察面) |
|
| Module Contract V2 spec | 217(其中 215 个进入 `run_module` 观察面) |
|
||||||
|
|||||||
@@ -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 overlay;Unit Tests `33103913838`、Pylint `33103913935` 全绿,Application 覆盖率低水位提升至 `78.79%` |
|
| S1-L3.1 Workflow typed execution | `DELIVERED` | S1-L2 | `17d8be2af`、`b33b29876`:Chain 直连类型化事务服务且单次执行只取一个 port;canonical Oper 删除旧 writer/无 Session 写方法,旧 ABI 只在 `_legacy/workflow.py` 与 Compat overlay;Unit Tests `33103913838`、Pylint `33103913935` 全绿,Application 覆盖率低水位提升至 `78.79%` |
|
||||||
| S1-L3.2 Chain registry/DI | `ACTIVE` | S1-L3.1 | 显式类型化 factory,删除 PortProxy 与失效的双重注入,构造器注入真实控制调用 |
|
| 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.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.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.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 command,Session 内完成 DTO 投影 |
|
| S1-L3.6 Site | `PLANNED` | S1-L3.5 | 复用 Site query/health,补齐同步 typed command,Session 内完成 DTO 投影 |
|
||||||
@@ -153,7 +153,7 @@ canonical 主程序;兼容只经统一 Compat/SDK 门面提供。
|
|||||||
| S4-L2 Event strict contract | `PLANNED` | S0-L2.6,S1-L6 | 宿主事件输入/输出按风险 strict,诊断例外只属于第三方插件兼容 |
|
| S4-L2 Event strict contract | `PLANNED` | S0-L2.6,S1-L6 | 宿主事件输入/输出按风险 strict,诊断例外只属于第三方插件兼容 |
|
||||||
| S4-L3 Complexity v2 | `PLANNED` | S3 | 私有方法、class/file、圈复杂度进入门禁;所有超限通过职责拆分归零 |
|
| S4-L3 Complexity v2 | `PLANNED` | S3 | 私有方法、class/file、圈复杂度进入门禁;所有超限通过职责拆分归零 |
|
||||||
| S4-L4 全量 mypy 清零 | `PLANNED` | S3,S4-L1,S4-L2 | `mypy-baseline.json` 归零并删除债务接受路径,全宿主 strict 类型通过 |
|
| 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 | 高风险包纳入 coverage;raw concurrency 分类清零;Module Quality 有真实 evidence test |
|
| S4-L6 Coverage/并发/质量证据 | `PLANNED` | S3,S4-L1,S4-L2 | 高风险包纳入 coverage;raw concurrency 分类清零;Module Quality 有真实 evidence test |
|
||||||
|
|
||||||
### S5:Plugin、Agent、Domain、Startup 与最终收口
|
### S5:Plugin、Agent、Domain、Startup 与最终收口
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ to make the directory tree look symmetrical.
|
|||||||
| `app/application/*.py` | Established single-module application services and compatibility facades |
|
| `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/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/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/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/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 |
|
| `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
|
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
|
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`.
|
`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
|
Agent orchestration, memory and tool implementations follow the same rule via
|
||||||
the named `get_agent_*_port()` functions from `app/application/agentdata.py`.
|
the named `get_agent_*_port()` functions from `app/application/agentdata.py`.
|
||||||
The legacy Agent `*Port` proxy classes remain import-compatible boundaries and
|
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/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/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/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/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/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 |
|
| `app/application/chain/events.py` | Chain durable-event write port, settlement projection and replayable payload conversion |
|
||||||
|
|||||||
+2
-1
@@ -223,6 +223,7 @@ def configure_plugin_system_services():
|
|||||||
configure_agent_task_execution,
|
configure_agent_task_execution,
|
||||||
)
|
)
|
||||||
from app.db.adapters.download import TransactionalDownloadFailureRepository
|
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.site import TransactionalSiteRepository
|
||||||
from app.db.adapters.subscription import TransactionalSubscribeWriter
|
from app.db.adapters.subscription import TransactionalSubscribeWriter
|
||||||
from app.db.adapters.transaction import TransactionalWriteRunner
|
from app.db.adapters.transaction import TransactionalWriteRunner
|
||||||
@@ -312,7 +313,7 @@ def configure_plugin_system_services():
|
|||||||
transfer_execution=lambda: TransactionalTransferExecutionRepository(
|
transfer_execution=lambda: TransactionalTransferExecutionRepository(
|
||||||
SessionFactory
|
SessionFactory
|
||||||
),
|
),
|
||||||
media_server=lambda: MediaServerOper(),
|
media_server=lambda: TransactionalMediaServerRepository(SessionFactory),
|
||||||
download_failure=lambda: TransactionalDownloadFailureRepository(
|
download_failure=lambda: TransactionalDownloadFailureRepository(
|
||||||
SessionFactory
|
SessionFactory
|
||||||
),
|
),
|
||||||
|
|||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"application": {
|
"application": {
|
||||||
"covered_lines": 10084,
|
"covered_lines": 10133,
|
||||||
"percent": 78.89,
|
"percent": 78.95,
|
||||||
"statements": 12782
|
"statements": 12834
|
||||||
},
|
},
|
||||||
"domain": {
|
"domain": {
|
||||||
"covered_lines": 3392,
|
"covered_lines": 3392,
|
||||||
|
|||||||
+23
-3
@@ -1441,8 +1441,8 @@
|
|||||||
"runtime_only": true
|
"runtime_only": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"edge_count": 6944,
|
"edge_count": 6962,
|
||||||
"edge_sha256": "9f6be750c55150ef9e061f54ade997c328561b7950f6ec44bce6ada373d01f3e",
|
"edge_sha256": "d78cc1aa6f3837310c7460d2ea17873222b4b50708012ae2f3db249d8197f77e",
|
||||||
"edges": [
|
"edges": [
|
||||||
"app -> app.runtime",
|
"app -> app.runtime",
|
||||||
"app -> app.runtime.compat",
|
"app -> app.runtime.compat",
|
||||||
@@ -3984,6 +3984,9 @@
|
|||||||
"app.application.chain.context -> app.runtime",
|
"app.application.chain.context -> app.runtime",
|
||||||
"app.application.chain.context -> app.runtime.stop",
|
"app.application.chain.context -> app.runtime.stop",
|
||||||
"app.application.chain.data -> app.application",
|
"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",
|
||||||
"app.application.chain.data -> app.application.transfer.execution",
|
"app.application.chain.data -> app.application.transfer.execution",
|
||||||
"app.application.chain.data -> app.application.transfer.workflow",
|
"app.application.chain.data -> app.application.transfer.workflow",
|
||||||
@@ -4020,6 +4023,8 @@
|
|||||||
"app.application.directory -> app.schemas.file",
|
"app.application.directory -> app.schemas.file",
|
||||||
"app.application.directory -> app.schemas.system",
|
"app.application.directory -> app.schemas.system",
|
||||||
"app.application.directory -> app.schemas.types",
|
"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",
|
||||||
"app.application.download.selection -> app.domain.context",
|
"app.application.download.selection -> app.domain.context",
|
||||||
"app.application.download.selection -> app.schemas",
|
"app.application.download.selection -> app.schemas",
|
||||||
@@ -4640,6 +4645,7 @@
|
|||||||
"app.chain.download -> app.application.configuration",
|
"app.chain.download -> app.application.configuration",
|
||||||
"app.chain.download -> app.application.directory",
|
"app.chain.download -> app.application.directory",
|
||||||
"app.chain.download -> app.application.download",
|
"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.selection",
|
||||||
"app.chain.download -> app.application.download.tasks",
|
"app.chain.download -> app.application.download.tasks",
|
||||||
"app.chain.download -> app.application.torrent",
|
"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.transferpending",
|
||||||
"app.db.adapters.chain -> app.db.oper.transfersettlementreceipt",
|
"app.db.adapters.chain -> app.db.oper.transfersettlementreceipt",
|
||||||
"app.db.adapters.chain -> app.db.uow",
|
"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",
|
||||||
"app.db.adapters.download -> app.db.oper",
|
"app.db.adapters.download -> app.db.oper",
|
||||||
"app.db.adapters.download -> app.db.oper.downloadfailure",
|
"app.db.adapters.download -> app.db.oper.downloadfailure",
|
||||||
"app.db.adapters.download -> app.db.uow",
|
"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",
|
||||||
"app.db.adapters.outbox -> app.application.outbox",
|
"app.db.adapters.outbox -> app.application.outbox",
|
||||||
"app.db.adapters.outbox -> app.db",
|
"app.db.adapters.outbox -> app.db",
|
||||||
@@ -8020,6 +8037,7 @@
|
|||||||
"app.startup.initializers.modules -> app.db.adapters",
|
"app.startup.initializers.modules -> app.db.adapters",
|
||||||
"app.startup.initializers.modules -> app.db.adapters.chain",
|
"app.startup.initializers.modules -> app.db.adapters.chain",
|
||||||
"app.startup.initializers.modules -> app.db.adapters.download",
|
"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.outbox",
|
||||||
"app.startup.initializers.modules -> app.db.adapters.pluginidentity",
|
"app.startup.initializers.modules -> app.db.adapters.pluginidentity",
|
||||||
"app.startup.initializers.modules -> app.db.adapters.plugininstallation",
|
"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",
|
||||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||||
],
|
],
|
||||||
"module_count": 850,
|
"module_count": 852,
|
||||||
"modules": [
|
"modules": [
|
||||||
"app",
|
"app",
|
||||||
"app.adapters",
|
"app.adapters",
|
||||||
@@ -8652,6 +8670,7 @@
|
|||||||
"app.application.database",
|
"app.application.database",
|
||||||
"app.application.directory",
|
"app.application.directory",
|
||||||
"app.application.download",
|
"app.application.download",
|
||||||
|
"app.application.download.failures",
|
||||||
"app.application.download.selection",
|
"app.application.download.selection",
|
||||||
"app.application.download.tasks",
|
"app.application.download.tasks",
|
||||||
"app.application.downloader",
|
"app.application.downloader",
|
||||||
@@ -8783,6 +8802,7 @@
|
|||||||
"app.db.adapters",
|
"app.db.adapters",
|
||||||
"app.db.adapters.chain",
|
"app.db.adapters.chain",
|
||||||
"app.db.adapters.download",
|
"app.db.adapters.download",
|
||||||
|
"app.db.adapters.mediaserver",
|
||||||
"app.db.adapters.outbox",
|
"app.db.adapters.outbox",
|
||||||
"app.db.adapters.pluginidentity",
|
"app.db.adapters.pluginidentity",
|
||||||
"app.db.adapters.plugininstallation",
|
"app.db.adapters.plugininstallation",
|
||||||
|
|||||||
+1
-1
@@ -1422,7 +1422,7 @@
|
|||||||
"call-overload": 1,
|
"call-overload": 1,
|
||||||
"comparison-overlap": 1,
|
"comparison-overlap": 1,
|
||||||
"misc": 1,
|
"misc": 1,
|
||||||
"no-any-return": 4,
|
"no-any-return": 3,
|
||||||
"no-redef": 1,
|
"no-redef": 1,
|
||||||
"no-untyped-call": 7,
|
"no-untyped-call": 7,
|
||||||
"no-untyped-def": 6,
|
"no-untyped-def": 6,
|
||||||
|
|||||||
@@ -312,9 +312,6 @@
|
|||||||
"app/application/maintenance.py": {
|
"app/application/maintenance.py": {
|
||||||
"I001": 1
|
"I001": 1
|
||||||
},
|
},
|
||||||
"app/application/mediaserver.py": {
|
|
||||||
"I001": 1
|
|
||||||
},
|
|
||||||
"app/application/messaging/agent.py": {
|
"app/application/messaging/agent.py": {
|
||||||
"I001": 1
|
"I001": 1
|
||||||
},
|
},
|
||||||
@@ -1276,9 +1273,6 @@
|
|||||||
"tests/test_douban_recognition.py": {
|
"tests/test_douban_recognition.py": {
|
||||||
"I001": 1
|
"I001": 1
|
||||||
},
|
},
|
||||||
"tests/test_download_chain.py": {
|
|
||||||
"I001": 1
|
|
||||||
},
|
|
||||||
"tests/test_download_paths_endpoint.py": {
|
"tests/test_download_paths_endpoint.py": {
|
||||||
"I001": 1
|
"I001": 1
|
||||||
},
|
},
|
||||||
@@ -1410,9 +1404,6 @@
|
|||||||
"tests/test_mediaserver_image_signing.py": {
|
"tests/test_mediaserver_image_signing.py": {
|
||||||
"I001": 1
|
"I001": 1
|
||||||
},
|
},
|
||||||
"tests/test_mediaserver_sync_incremental.py": {
|
|
||||||
"I001": 1
|
|
||||||
},
|
|
||||||
"tests/test_mediaserver_sync_scheduler.py": {
|
"tests/test_mediaserver_sync_scheduler.py": {
|
||||||
"I001": 1
|
"I001": 1
|
||||||
},
|
},
|
||||||
|
|||||||
+140
-140
@@ -1,41 +1,41 @@
|
|||||||
{
|
{
|
||||||
"schema_version": 2,
|
"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",
|
"platform": "macOS-26.5.2-arm64-arm-64bit-Mach-O",
|
||||||
"python": "3.14.3",
|
"python": "3.14.3",
|
||||||
"repeat": 3,
|
"repeat": 3,
|
||||||
"targets": {
|
"targets": {
|
||||||
"app.startup.lifecycle": {
|
"app.startup.lifecycle": {
|
||||||
"loaded_app_module_count": 392,
|
"loaded_app_module_count": 394,
|
||||||
"max_ms": 1035.341,
|
"max_ms": 1298.973,
|
||||||
"median_ms": 993.245,
|
"median_ms": 999.845,
|
||||||
"min_ms": 991.998,
|
"min_ms": 994.938,
|
||||||
"samples_ms": [
|
"samples_ms": [
|
||||||
1035.341,
|
1298.973,
|
||||||
991.998,
|
999.845,
|
||||||
993.245
|
994.938
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"app.factory": {
|
"app.factory": {
|
||||||
"loaded_app_module_count": 404,
|
"loaded_app_module_count": 406,
|
||||||
"max_ms": 1006.172,
|
"max_ms": 1079.626,
|
||||||
"median_ms": 1005.88,
|
"median_ms": 1019.979,
|
||||||
"min_ms": 1003.841,
|
"min_ms": 1014.804,
|
||||||
"samples_ms": [
|
"samples_ms": [
|
||||||
1005.88,
|
1019.979,
|
||||||
1006.172,
|
1014.804,
|
||||||
1003.841
|
1079.626
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"app.main": {
|
"app.main": {
|
||||||
"loaded_app_module_count": 406,
|
"loaded_app_module_count": 408,
|
||||||
"max_ms": 1085.652,
|
"max_ms": 1075.788,
|
||||||
"median_ms": 1055.994,
|
"median_ms": 1060.558,
|
||||||
"min_ms": 1043.212,
|
"min_ms": 1057.105,
|
||||||
"samples_ms": [
|
"samples_ms": [
|
||||||
1055.994,
|
1075.788,
|
||||||
1043.212,
|
1060.558,
|
||||||
1085.652
|
1057.105
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -47,56 +47,25 @@
|
|||||||
{
|
{
|
||||||
"mode": "normal",
|
"mode": "normal",
|
||||||
"enabled_component_count": 25,
|
"enabled_component_count": 25,
|
||||||
"startup_ms": 0.702,
|
"startup_ms": 0.674,
|
||||||
"full_lifespan_ms": 1.54,
|
"full_lifespan_ms": 1.563,
|
||||||
"stage_ms": {
|
"stage_ms": {
|
||||||
"后台任务登记器": 0.09,
|
"后台任务登记器": 0.081,
|
||||||
"数据库准备": 0.048,
|
"数据库准备": 0.039,
|
||||||
"HTTP 基础能力": 0.03,
|
"HTTP 基础能力": 0.031,
|
||||||
"领域依赖装配": 0.033,
|
"领域依赖装配": 0.029,
|
||||||
"数据库引擎预热": 0.031,
|
"数据库引擎预热": 0.028,
|
||||||
"数据库连接预算": 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.024,
|
"数据库连接预算": 0.024,
|
||||||
"路由": 0.026,
|
"路由": 0.026,
|
||||||
"模块服务": 0.026,
|
"模块服务": 0.026,
|
||||||
"插件备份恢复": 0.026,
|
"插件备份恢复": 0.024,
|
||||||
"插件": 0.025,
|
"插件": 0.025,
|
||||||
"定时器": 0.024,
|
"定时器": 0.026,
|
||||||
"监控器": 0.024,
|
"监控器": 0.023,
|
||||||
"待处理整理回放": 0.027,
|
"待处理整理回放": 0.021,
|
||||||
"命令服务": 0.026,
|
"命令服务": 0.026,
|
||||||
"工作流": 0.023,
|
"工作流": 0.023,
|
||||||
"插件同步与启动收尾": 0.021
|
"插件同步与启动收尾": 0.025
|
||||||
},
|
},
|
||||||
"threads_before": 2,
|
"threads_before": 2,
|
||||||
"threads_started": 2,
|
"threads_started": 2,
|
||||||
@@ -109,24 +78,55 @@
|
|||||||
{
|
{
|
||||||
"mode": "normal",
|
"mode": "normal",
|
||||||
"enabled_component_count": 25,
|
"enabled_component_count": 25,
|
||||||
"startup_ms": 0.735,
|
"startup_ms": 0.662,
|
||||||
"full_lifespan_ms": 1.57,
|
"full_lifespan_ms": 1.526,
|
||||||
"stage_ms": {
|
"stage_ms": {
|
||||||
"后台任务登记器": 0.103,
|
"后台任务登记器": 0.073,
|
||||||
"数据库准备": 0.041,
|
"数据库准备": 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,
|
"HTTP 基础能力": 0.031,
|
||||||
"领域依赖装配": 0.032,
|
"领域依赖装配": 0.028,
|
||||||
"数据库引擎预热": 0.028,
|
"数据库引擎预热": 0.025,
|
||||||
"数据库连接预算": 0.026,
|
"数据库连接预算": 0.024,
|
||||||
"路由": 0.027,
|
"路由": 0.026,
|
||||||
"模块服务": 0.026,
|
"模块服务": 0.025,
|
||||||
"插件备份恢复": 0.026,
|
"插件备份恢复": 0.023,
|
||||||
"插件": 0.025,
|
"插件": 0.025,
|
||||||
"定时器": 0.024,
|
"定时器": 0.026,
|
||||||
"监控器": 0.025,
|
"监控器": 0.024,
|
||||||
"待处理整理回放": 0.024,
|
"待处理整理回放": 0.024,
|
||||||
"命令服务": 0.024,
|
"命令服务": 0.021,
|
||||||
"工作流": 0.024,
|
"工作流": 0.022,
|
||||||
"插件同步与启动收尾": 0.024
|
"插件同步与启动收尾": 0.024
|
||||||
},
|
},
|
||||||
"threads_before": 2,
|
"threads_before": 2,
|
||||||
@@ -138,8 +138,8 @@
|
|||||||
"database_connections_started": 0
|
"database_connections_started": 0
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"median_startup_ms": 0.702,
|
"median_startup_ms": 0.662,
|
||||||
"median_full_lifespan_ms": 1.559,
|
"median_full_lifespan_ms": 1.526,
|
||||||
"enabled_component_count": 25,
|
"enabled_component_count": 25,
|
||||||
"enabled_components": [
|
"enabled_components": [
|
||||||
"后台任务登记器",
|
"后台任务登记器",
|
||||||
@@ -174,66 +174,66 @@
|
|||||||
{
|
{
|
||||||
"mode": "safe",
|
"mode": "safe",
|
||||||
"enabled_component_count": 13,
|
"enabled_component_count": 13,
|
||||||
"startup_ms": 0.527,
|
"startup_ms": 0.485,
|
||||||
"full_lifespan_ms": 0.956,
|
"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": {
|
"stage_ms": {
|
||||||
"后台任务登记器": 0.091,
|
"后台任务登记器": 0.091,
|
||||||
"数据库准备": 0.042,
|
"数据库准备": 0.04,
|
||||||
"HTTP 基础能力": 0.03,
|
"HTTP 基础能力": 0.042,
|
||||||
"领域依赖装配": 0.03,
|
"领域依赖装配": 0.029,
|
||||||
"数据库引擎预热": 0.028,
|
"数据库引擎预热": 0.023,
|
||||||
"数据库连接预算": 0.027,
|
"数据库连接预算": 0.029,
|
||||||
"路由": 0.026,
|
"路由": 0.024,
|
||||||
"模块服务": 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.022,
|
"模块服务": 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_before": 2,
|
||||||
"threads_started": 2,
|
"threads_started": 2,
|
||||||
@@ -244,8 +244,8 @@
|
|||||||
"database_connections_started": 0
|
"database_connections_started": 0
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"median_startup_ms": 0.523,
|
"median_startup_ms": 0.496,
|
||||||
"median_full_lifespan_ms": 0.954,
|
"median_full_lifespan_ms": 0.923,
|
||||||
"enabled_component_count": 13,
|
"enabled_component_count": 13,
|
||||||
"enabled_components": [
|
"enabled_components": [
|
||||||
"后台任务登记器",
|
"后台任务登记器",
|
||||||
|
|||||||
@@ -444,6 +444,53 @@ def test_chain_registry_has_no_dynamic_proxies_or_dead_context_injection():
|
|||||||
assert "data_ports=" not in startup_source
|
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():
|
def test_canonical_workflow_oper_has_no_legacy_writer_or_duplicate_exports():
|
||||||
"""工作流旧写入口只能存在于 SDK Legacy facade。"""
|
"""工作流旧写入口只能存在于 SDK Legacy facade。"""
|
||||||
oper_path = APP_ROOT / "db" / "oper" / "workflow.py"
|
oper_path = APP_ROOT / "db" / "oper" / "workflow.py"
|
||||||
|
|||||||
@@ -158,6 +158,32 @@ def test_exists_by_title_matches_async_twin(db):
|
|||||||
assert (sync_found is None) == (async_found is None)
|
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):
|
def test_empty_clears_only_the_given_server(db):
|
||||||
"""
|
"""
|
||||||
指定服务器时只清空该服务器的条目,不给则清空全表。
|
指定服务器时只清空该服务器的条目,不给则清空全表。
|
||||||
|
|||||||
+106
-12
@@ -1,3 +1,4 @@
|
|||||||
|
from dataclasses import asdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
@@ -5,10 +6,14 @@ from unittest.mock import MagicMock
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import app.chain.download as download_module
|
import app.chain.download as download_module
|
||||||
|
from app.application.download.failures import (
|
||||||
|
DownloadFailureSnapshot,
|
||||||
|
DownloadFailureWrite,
|
||||||
|
)
|
||||||
from app.chain.download import DownloadChain
|
from app.chain.download import DownloadChain
|
||||||
from app.runtime.config import settings
|
|
||||||
from app.domain.context import Context, MediaInfo, SubtitleInfo, TorrentInfo
|
from app.domain.context import Context, MediaInfo, SubtitleInfo, TorrentInfo
|
||||||
from app.domain.metainfo import MetaInfo
|
from app.domain.metainfo import MetaInfo
|
||||||
|
from app.runtime.config import settings
|
||||||
from app.schemas.file import FileItem
|
from app.schemas.file import FileItem
|
||||||
from app.schemas.mediaserver import NotExistMediaInfo
|
from app.schemas.mediaserver import NotExistMediaInfo
|
||||||
from app.schemas.system import TransferDirectoryConf
|
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)
|
captured.update(asdict(failure))
|
||||||
return SimpleNamespace(id=1)
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"app.application.directory.DirectoryHelper.get_download_dirs",
|
"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 download_id is None
|
||||||
assert returned_error == error_msg
|
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["torrent_id"] == "example.com:id=484660"
|
||||||
assert captured["site"] == 12
|
assert captured["site"] == 12
|
||||||
assert captured["error_message"] == error_msg
|
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():
|
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 now_time
|
||||||
assert failed_fingerprint in fingerprints
|
assert failed_fingerprint in fingerprints
|
||||||
return {failed_fingerprint: SimpleNamespace(
|
return {
|
||||||
fingerprint=failed_fingerprint,
|
failed_fingerprint: DownloadFailureSnapshot(
|
||||||
error_message="无法读取种子文件",
|
fingerprint=failed_fingerprint,
|
||||||
next_retry_at="2026-01-02 03:04:05",
|
error_message="无法读取种子文件",
|
||||||
)}
|
next_retry_at="2026-01-02 03:04:05",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
download_module,
|
download_module,
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
from app import schemas
|
from app import schemas
|
||||||
from app.chain import mediaserver as MEDIA_SERVER_CHAIN_MODULE
|
from app.chain import mediaserver as MEDIA_SERVER_CHAIN_MODULE
|
||||||
from app.chain.mediaserver import MediaServerChain
|
from app.chain.mediaserver import MediaServerChain
|
||||||
|
from app.db.adapters.mediaserver import TransactionalMediaServerRepository
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.db.oper.mediaserver import MediaServerOper
|
|
||||||
from app.db.models.mediaserver import MediaServerItem
|
from app.db.models.mediaserver import MediaServerItem
|
||||||
|
from app.db.oper.mediaserver import MediaServerOper
|
||||||
from app.runtime.config import global_vars
|
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("音乐条目不应查询电视剧分集")
|
chain.episodes = lambda *_args, **_kwargs: pytest.fail("音乐条目不应查询电视剧分集")
|
||||||
|
|
||||||
with database() as session:
|
with patch.object(
|
||||||
with patch.object(
|
MEDIA_SERVER_CHAIN_MODULE,
|
||||||
MEDIA_SERVER_CHAIN_MODULE,
|
"get_chain_media_server_port",
|
||||||
"get_chain_media_server_port",
|
lambda: TransactionalMediaServerRepository(database),
|
||||||
lambda: MediaServerOper(session),
|
), patch.object(
|
||||||
), patch.object(
|
MEDIA_SERVER_CHAIN_MODULE,
|
||||||
MEDIA_SERVER_CHAIN_MODULE,
|
"get_mediaserver_configs",
|
||||||
"get_mediaserver_configs",
|
return_value=[SimpleNamespace(name="navidrome", enabled=True, sync_libraries=["all"])],
|
||||||
return_value=[SimpleNamespace(name="navidrome", enabled=True, sync_libraries=["all"])],
|
):
|
||||||
):
|
chain.sync()
|
||||||
chain.sync()
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
with database() as db:
|
with database() as db:
|
||||||
item = db.query(MediaServerItem).one()
|
item = db.query(MediaServerItem).one()
|
||||||
@@ -121,6 +120,43 @@ def test_sync_persists_music_without_querying_tv_episodes(database):
|
|||||||
assert item.seasoninfo == {}
|
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):
|
def test_sync_updates_rows_and_removes_stale_entries(database):
|
||||||
"""同步应更新已存在条目,并清理未再出现或已移除服务的数据。"""
|
"""同步应更新已存在条目,并清理未再出现或已移除服务的数据。"""
|
||||||
old_sync_time = "2026-05-01 00:00:00"
|
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: []
|
chain.episodes = lambda *_args, **_kwargs: []
|
||||||
|
|
||||||
with database() as session:
|
with patch.object(
|
||||||
with patch.object(
|
MEDIA_SERVER_CHAIN_MODULE,
|
||||||
MEDIA_SERVER_CHAIN_MODULE,
|
"get_chain_media_server_port",
|
||||||
"get_chain_media_server_port",
|
lambda: TransactionalMediaServerRepository(database),
|
||||||
lambda: MediaServerOper(session),
|
), patch.object(
|
||||||
), patch.object(
|
MEDIA_SERVER_CHAIN_MODULE,
|
||||||
MEDIA_SERVER_CHAIN_MODULE,
|
"get_mediaserver_configs",
|
||||||
"get_mediaserver_configs",
|
return_value=[SimpleNamespace(name="plex", enabled=True, sync_libraries=["movies"])],
|
||||||
return_value=[SimpleNamespace(name="plex", enabled=True, sync_libraries=["movies"])],
|
):
|
||||||
):
|
chain.sync()
|
||||||
chain.sync()
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
with database() as db:
|
with database() as db:
|
||||||
items = (
|
items = (
|
||||||
@@ -277,23 +311,21 @@ def test_sync_queries_counts_before_items_and_reports_media_progress(database):
|
|||||||
chain.items = items
|
chain.items = items
|
||||||
chain.episodes = lambda *_args, **_kwargs: []
|
chain.episodes = lambda *_args, **_kwargs: []
|
||||||
|
|
||||||
with database() as session:
|
with patch.object(
|
||||||
with patch.object(
|
MEDIA_SERVER_CHAIN_MODULE,
|
||||||
MEDIA_SERVER_CHAIN_MODULE,
|
"get_chain_media_server_port",
|
||||||
"get_chain_media_server_port",
|
lambda: TransactionalMediaServerRepository(database),
|
||||||
lambda: MediaServerOper(session),
|
), patch.object(
|
||||||
), patch.object(
|
MEDIA_SERVER_CHAIN_MODULE,
|
||||||
MEDIA_SERVER_CHAIN_MODULE,
|
"get_mediaserver_configs",
|
||||||
"get_mediaserver_configs",
|
return_value=[
|
||||||
return_value=[
|
SimpleNamespace(name="plex-a", enabled=True, sync_libraries=["all"]),
|
||||||
SimpleNamespace(name="plex-a", enabled=True, sync_libraries=["all"]),
|
SimpleNamespace(name="plex-b", enabled=True, sync_libraries=["all"]),
|
||||||
SimpleNamespace(name="plex-b", enabled=True, sync_libraries=["all"]),
|
],
|
||||||
],
|
):
|
||||||
):
|
chain.sync(
|
||||||
chain.sync(
|
progress_callback=lambda **kwargs: progress_snapshots.append(kwargs)
|
||||||
progress_callback=lambda **kwargs: progress_snapshots.append(kwargs)
|
)
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
assert events == [
|
assert events == [
|
||||||
"count:plex-a",
|
"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"]]
|
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):
|
def test_sync_stops_without_emitting_completion_after_stop_signal(monkeypatch):
|
||||||
"""系统停止发生在逐库同步期间时,不应再发送服务器或全局完成进度。"""
|
"""系统停止发生在逐库同步期间时,不应再发送服务器或全局完成进度。"""
|
||||||
chain = object.__new__(MediaServerChain)
|
chain = object.__new__(MediaServerChain)
|
||||||
@@ -379,7 +506,12 @@ def test_sync_stops_without_emitting_completion_after_stop_signal(monkeypatch):
|
|||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
chain,
|
chain,
|
||||||
"_prepare_sync_contexts",
|
"_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(chain, "_sync_server_libraries", stop_during_sync)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|||||||
@@ -1,33 +1,138 @@
|
|||||||
"""下载失败冷却切片显式 UoW 的回归测试。"""
|
"""下载失败冷却切片显式 UoW 的回归测试。"""
|
||||||
|
|
||||||
|
from dataclasses import FrozenInstanceError
|
||||||
|
from types import SimpleNamespace
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
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.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 的固定会话工厂。"""
|
"""返回支持 context manager 的固定会话工厂。"""
|
||||||
session.__enter__.return_value = session
|
session.__enter__.return_value = session
|
||||||
session.__exit__.return_value = False
|
session.__exit__.return_value = False
|
||||||
return MagicMock(return_value=session)
|
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()
|
session = MagicMock()
|
||||||
|
record = SimpleNamespace(
|
||||||
|
fingerprint="fp",
|
||||||
|
error_message="无法读取种子文件",
|
||||||
|
next_retry_at="next",
|
||||||
|
)
|
||||||
oper = MagicMock()
|
oper = MagicMock()
|
||||||
oper.record_failure.return_value = object()
|
oper.get_active_by_fingerprints.return_value = {"fp": record}
|
||||||
repository = TransactionalDownloadFailureRepository(_session_factory(session))
|
repository = TransactionalDownloadFailureRepository(_session_factory(session))
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"app.db.adapters.download.DownloadFailureOper",
|
"app.db.adapters.download.DownloadFailureOper",
|
||||||
return_value=oper,
|
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.commit.assert_called_once_with()
|
||||||
session.rollback.assert_not_called()
|
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",
|
"app.db.adapters.download.DownloadFailureOper",
|
||||||
return_value=oper,
|
return_value=oper,
|
||||||
), pytest.raises(ValueError, match="duplicate"):
|
), pytest.raises(ValueError, match="duplicate"):
|
||||||
repository.record_failure("fp", "now", "next")
|
repository.record_failure(_failure_write())
|
||||||
|
|
||||||
session.rollback.assert_called_once_with()
|
session.rollback.assert_called_once_with()
|
||||||
session.commit.assert_not_called()
|
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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user