refactor(history): close transfer history boundary

This commit is contained in:
jxxghp
2026-08-28 11:55:33 +08:00
parent 4804215adf
commit 017fecabd6
47 changed files with 2023 additions and 586 deletions

View File

@@ -101,6 +101,8 @@ class DeleteTransferHistoryTool(MoviePilotTool):
status = "成功" if history.status else "失败"
deleted_dest = False
if history.dest_fileitem and not (history.status and history.mode == "move"):
if not isinstance(history.dest_fileitem, dict):
return "删除失败:目标文件历史数据无效,已保留历史记录"
dest_fileitem = FileItem(**history.dest_fileitem)
try:
destination_exists, destination_deleted = await self.run_blocking(

View File

@@ -32,14 +32,13 @@ def get_mediaserver_query_service(
def get_dashboard_query_service(
db: Session = Depends(get_sync_session),
runtime: HostRuntime = Depends(get_host_runtime),
) -> DashboardQueryService:
"""组装 Dashboard 媒体与整理历史统计查询服务。"""
from app.chain.dashboard import DashboardChain
return DashboardQueryService(
repository=runtime.history.transfer_repository(db),
repository=runtime.history.transfer_repository,
media_statistics=DashboardChain().media_statistic,
)
@@ -62,17 +61,16 @@ def get_history_query_service(
"""组装历史列表和详情异步查询服务。"""
return HistoryQueryService(
download_repository=runtime.history.download_repository(db),
transfer_repository=runtime.history.transfer_repository(db),
transfer_repository=runtime.history.transfer_repository,
)
def get_transfer_history_lookup_service(
db: Session = Depends(get_sync_session),
runtime: HostRuntime = Depends(get_host_runtime),
) -> TransferHistoryLookupService:
"""组装手动整理使用的同步历史投影服务。"""
return TransferHistoryLookupService(
runtime.history.transfer_repository(db)
runtime.history.transfer_repository
)
@@ -83,7 +81,7 @@ def get_transfer_history_mutation_command(
"""组装整理历史删除、文件处理和事件发布用例。"""
storage_chain = StorageChain()
return TransferHistoryMutationCommand(
repository=runtime.history.transfer_repository(db),
repository=runtime.history.transfer_mutation_repository(db),
download_repository=runtime.history.download_repository(db),
unit_of_work=runtime.persistence.sync_transaction(db),
file_item_factory=lambda payload: _SchemaFileItem(**payload),

View File

@@ -5,7 +5,10 @@ from __future__ import annotations
from collections.abc import Callable
from typing import Any, cast
from app.application.history import DownloadHistoryRepository
from app.application.history import (
DownloadHistoryRepository,
TransferHistoryRepository,
)
from app.application.security.user import ChainUserRepository
AgentDataFactory = Callable[[], Any]
@@ -146,9 +149,12 @@ def get_agent_subscribe_history_port() -> Any:
return get_agent_data_ports().subscribe_history()
def get_agent_transfer_history_port() -> Any:
"""创建 Agent 整理历史数据端口实例。"""
return get_agent_data_ports().transfer_history()
def get_agent_transfer_history_port() -> TransferHistoryRepository:
"""创建 Agent 类型化整理历史数据端口实例。"""
return cast(
TransferHistoryRepository,
get_agent_data_ports().transfer_history(),
)
def get_agent_download_history_port() -> DownloadHistoryRepository:

View File

@@ -11,7 +11,10 @@ from dataclasses import dataclass
from typing import Any, Optional
from app.application.download.failures import DownloadFailureRepository
from app.application.history import DownloadHistoryRepository
from app.application.history import (
DownloadHistoryRepository,
TransferHistoryRepository,
)
from app.application.mediaserver import MediaServerRepository
from app.application.security.user import ChainUserRepository
from app.application.transfer.execution import TransferExecutionRepository
@@ -20,6 +23,7 @@ from app.application.transfer.workflow import TransferAdmissionRepository
OperFactory = Callable[[], Any]
DownloadFailureRepositoryFactory = Callable[[], DownloadFailureRepository]
DownloadHistoryRepositoryFactory = Callable[[], DownloadHistoryRepository]
TransferHistoryRepositoryFactory = Callable[[], TransferHistoryRepository]
MediaServerRepositoryFactory = Callable[[], MediaServerRepository]
ChainUserRepositoryFactory = Callable[[], ChainUserRepository]
TransferAdmissionRepositoryFactory = Callable[[], TransferAdmissionRepository]
@@ -33,7 +37,7 @@ class ChainDataPorts:
site: OperFactory
subscribe: OperFactory
download_history: DownloadHistoryRepositoryFactory
transfer_history: OperFactory
transfer_history: TransferHistoryRepositoryFactory
transfer_pending: TransferAdmissionRepositoryFactory
transfer_execution: TransferExecutionRepositoryFactory
media_server: MediaServerRepositoryFactory
@@ -49,7 +53,7 @@ def configure_chain_data_ports(
site: OperFactory,
subscribe: OperFactory,
download_history: DownloadHistoryRepositoryFactory,
transfer_history: OperFactory,
transfer_history: TransferHistoryRepositoryFactory,
transfer_pending: TransferAdmissionRepositoryFactory,
transfer_execution: TransferExecutionRepositoryFactory,
media_server: MediaServerRepositoryFactory,
@@ -93,8 +97,8 @@ def get_chain_download_history_port() -> DownloadHistoryRepository:
return get_chain_data_ports().download_history()
def get_chain_transfer_history_port() -> Any:
"""创建整理历史数据端口实例。"""
def get_chain_transfer_history_port() -> TransferHistoryRepository:
"""创建类型化的整理历史查询与变更端口实例。"""
return get_chain_data_ports().transfer_history()

View File

@@ -13,8 +13,8 @@ from uuid import uuid4
from app.application.history import (
DownloadFileWrite,
DownloadHistoryWrite,
TransferHistoryRecord,
TransferHistoryWriter,
TransferHistorySnapshot,
TransferHistoryStagingPort,
)
from app.application.transfer.execution import TransferSettlementResult
from app.domain.context import Context, MediaInfo, MusicInfo, TorrentInfo
@@ -44,11 +44,14 @@ class ChainDurableEventWriter(Protocol):
self,
*,
topic: str | None,
stage_history: Callable[[TransferHistoryWriter], TransferHistoryRecord | None],
stage_history: Callable[
[TransferHistoryStagingPort],
TransferHistorySnapshot | None,
],
event_payload: dict[str, Any],
publish: Callable[[dict[str, Any]], None] | None,
settlement: "TransferResultSettlement | None" = None,
) -> TransferHistoryRecord | TransferSettlementResult | None:
) -> TransferHistorySnapshot | TransferSettlementResult | None:
"""提交历史、可选任务终态和结果 intent,再按 topic 广播事件。"""
@@ -72,17 +75,6 @@ class TransferResultSettlement:
raise ValueError("整理失败终态必须包含可诊断原因")
@dataclass(frozen=True, slots=True)
class TransferHistoryRef:
"""事务关闭后仍可安全读取的最小整理历史投影。"""
id: int
status: bool
src: str | None
src_storage: str | None
src_fileitem: dict[str, Any] | None
def download_added_event_key(history_id: int) -> str:
"""由下载历史 ID 与本次事实标识构造 DownloadAdded 幂等键。"""
return f"download.added:{history_id}:{uuid4().hex}:v1"

View File

@@ -1,19 +1,26 @@
"""Dashboard 统计查询用例。"""
from collections.abc import Callable
from typing import Any, Optional, Protocol
from typing import Optional, Protocol
from app.application.history import (
TransferHistoryMonthlyStatistics,
TransferHistoryStatisticSnapshot,
)
from app.schemas.dashboard import Statistic
class TransferHistoryQueryRepository(Protocol):
"""Dashboard 所需的整理历史统计端口。"""
def monthly_media_statistics(self) -> tuple[int, int, int, int]:
def monthly_media_statistics(self) -> TransferHistoryMonthlyStatistics:
"""返回本月电影、剧集、单集和音乐数量。"""
...
async def async_statistic(self, days: int = 7) -> list[Any]:
async def async_statistic(
self,
days: int = 7,
) -> list[TransferHistoryStatisticSnapshot]:
"""返回最近若干天的整理趋势。"""
...
@@ -50,15 +57,14 @@ class DashboardQueryService:
else:
result = Statistic()
(
result.movie_count_month,
result.tv_count_month,
result.episode_count_month,
result.music_count_month,
) = self._repository.monthly_media_statistics()
monthly = self._repository.monthly_media_statistics()
result.movie_count_month = monthly.movies
result.tv_count_month = monthly.tv_shows
result.episode_count_month = monthly.episodes
result.music_count_month = monthly.music
return result
async def transfer(self, days: int = 7) -> list[int]:
"""返回最近若干天的整理数量序列。"""
rows = await self._repository.async_statistic(days)
return [row[1] for row in rows]
return [row.count for row in rows]

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Callable, Dict, NoReturn, Optional, Protocol, Union
@@ -33,65 +35,6 @@ FAILED_RETRY_TTL = 24 * 3600
_failed_retry_counts = TTLCache(region="transfer_failed_retry", maxsize=5000, ttl=FAILED_RETRY_TTL)
class TransferHistoryRecord(Protocol):
"""整理历史用例读取的最小记录投影。"""
id: int
status: bool
src: Optional[str]
src_storage: Optional[str]
src_fileitem: Optional[dict]
transfer_task_id: Optional[str]
class TransferHistoryWriter(Protocol):
"""整理历史写入和查重端口。"""
def get_by_src(self, src: str, storage: Optional[str] = None) -> Optional[TransferHistoryRecord]:
"""按源路径读取记录。"""
def get_success_by_src(self, src: str, storage: Optional[str] = None) -> Optional[TransferHistoryRecord]:
"""按源路径读取成功记录。"""
def add_force(self, **payload: Any) -> Optional[TransferHistoryRecord]:
"""强制写入整理历史。"""
_configured_transfer_history_provider: Callable[[], TransferHistoryWriter] | None = None
def configure_transfer_history_provider(
provider: Callable[[], TransferHistoryWriter],
) -> None:
"""由启动组合根登记整理历史数据端口提供器。"""
global _configured_transfer_history_provider
_configured_transfer_history_provider = provider
def _get_transfer_history_writer(
writer: Optional[TransferHistoryWriter],
) -> TransferHistoryWriter:
"""获取显式传入或组合根登记的整理历史数据端口。"""
if writer is not None:
return writer
if _configured_transfer_history_provider is None:
raise RuntimeError("整理历史数据端口尚未配置")
return _configured_transfer_history_provider()
class TransferHistoryPort:
"""把监控等宿主用例的存量构造形态转发到整理历史端口。"""
def __getattr__(self, name: str) -> Any:
"""转发整理历史读写方法,避免上层直接导入数据库操作器。"""
return getattr(_get_transfer_history_writer(None), name)
def get_transfer_history_port() -> TransferHistoryWriter:
"""返回启动组合根登记的整理历史数据端口实例。"""
return _get_transfer_history_writer(None)
@dataclass(frozen=True, slots=True)
class HistoryMutationResult:
"""描述历史记录维护操作是否成功及兼容提示。"""
@@ -105,7 +48,7 @@ class _FrozenJsonDict(dict[str, JsonData]):
def _reject_mutation(self, *args: Any, **kwargs: Any) -> NoReturn:
"""拒绝修改已经进入历史快照的嵌套 JSON。"""
raise TypeError("下载历史快照 JSON 不可修改")
raise TypeError("历史快照 JSON 不可修改")
__setitem__ = _reject_mutation
__delitem__ = _reject_mutation
@@ -122,7 +65,7 @@ class _FrozenJsonList(list[JsonData]):
def _reject_mutation(self, *args: Any, **kwargs: Any) -> NoReturn:
"""拒绝修改已经进入历史快照的嵌套 JSON。"""
raise TypeError("下载历史快照 JSON 不可修改")
raise TypeError("历史快照 JSON 不可修改")
__setitem__ = _reject_mutation
__delitem__ = _reject_mutation
@@ -147,6 +90,312 @@ def _freeze_json(value: JsonData) -> JsonData:
return value
@dataclass(frozen=True, slots=True)
class TransferHistorySnapshot:
"""脱离数据库会话后供整理、Agent 和历史用例读取的完整历史快照。"""
id: int
transfer_task_id: Optional[str] = None
transfer_settlement_revision: Optional[int] = None
src: Optional[str] = None
src_storage: Optional[str] = None
src_fileitem: Optional[JsonData] = None
dest: Optional[str] = None
dest_storage: Optional[str] = None
dest_fileitem: Optional[JsonData] = None
mode: Optional[str] = None
type: Optional[str] = None
category: Optional[str] = None
title: Optional[str] = None
year: Optional[str] = None
media_source: Optional[MediaSource] = None
media_id: Optional[str] = None
music_type: Optional[str] = None
total_tracks: Optional[int] = None
audio_format: Optional[str] = None
audio_lossless: Optional[bool] = None
bit_depth: Optional[int] = None
sample_rate: Optional[int] = None
bitrate: Optional[int] = None
seasons: Optional[str] = None
episodes: Optional[str] = None
image: Optional[str] = None
downloader: Optional[str] = None
download_hash: Optional[str] = None
status: bool = True
errmsg: Optional[str] = None
date: Optional[str] = None
files: Optional[JsonData] = None
episode_group: Optional[str] = None
def __post_init__(self) -> None:
"""递归冻结历史 JSON 字段,避免跨层共享可变 ORM 列值。"""
object.__setattr__(self, "src_fileitem", _freeze_json(self.src_fileitem))
object.__setattr__(self, "dest_fileitem", _freeze_json(self.dest_fileitem))
object.__setattr__(self, "files", _freeze_json(self.files))
@dataclass(frozen=True, slots=True)
class TransferHistoryWrite:
"""替换同源整理历史所需的完整稳定写入数据。"""
src: str
src_storage: Optional[str] = None
src_fileitem: Optional[JsonData] = None
dest: Optional[str] = None
dest_storage: Optional[str] = None
dest_fileitem: Optional[JsonData] = None
mode: Optional[str] = None
type: Optional[str] = None
category: Optional[str] = None
title: Optional[str] = None
year: Optional[str] = None
media_source: Optional[MediaSource] = None
media_id: Optional[str] = None
music_type: Optional[str] = None
total_tracks: Optional[int] = None
audio_format: Optional[str] = None
audio_lossless: Optional[bool] = None
bit_depth: Optional[int] = None
sample_rate: Optional[int] = None
bitrate: Optional[int] = None
seasons: Optional[str] = None
episodes: Optional[str] = None
image: Optional[str] = None
downloader: Optional[str] = None
download_hash: Optional[str] = None
status: bool = True
errmsg: Optional[str] = None
files: Optional[JsonData] = None
episode_group: Optional[str] = None
def to_payload(self) -> dict[str, object]:
"""返回仅含持久化字段的独立副本。"""
payload: dict[str, object] = asdict(self)
if self.media_source is not None:
payload["media_source"] = str(self.media_source)
return payload
@dataclass(frozen=True, slots=True)
class TransferHistoryStatisticSnapshot:
"""单日整理历史数量统计。"""
date: str
count: int
@dataclass(frozen=True, slots=True)
class TransferHistoryMonthlyStatistics:
"""本月按媒体类别聚合的整理历史数量。"""
movies: int
tv_shows: int
episodes: int
music: int
class TransferHistoryQueryPort(Protocol):
"""宿主整理、历史、Agent 和工作流所需的类型化查询端口。"""
def get(self, history_id: int) -> Optional[TransferHistorySnapshot]:
"""按主键返回整理历史快照。"""
...
def get_by_src(
self,
src: str,
storage: Optional[str] = None,
) -> Optional[TransferHistorySnapshot]:
"""按源路径和可选存储返回最新历史快照。"""
...
def get_success_by_src(
self,
src: str,
storage: Optional[str] = None,
) -> Optional[TransferHistorySnapshot]:
"""按源路径和可选存储返回最新成功历史快照。"""
...
def get_by_dest(
self,
dest: str,
storage: Optional[str] = None,
) -> Optional[TransferHistorySnapshot]:
"""按目标路径和可选存储返回最新历史快照。"""
...
def get_by_transfer_task_id(
self,
*,
task_id: str,
) -> Optional[TransferHistorySnapshot]:
"""按 durable 整理任务标识返回终态历史快照。"""
...
def get_by_media_identity(
self,
media_source: MediaSource,
media_id: str,
mtype: Optional[str] = None,
) -> Optional[TransferHistorySnapshot]:
"""按规范媒体身份和可选媒体类型返回历史快照。"""
...
def list_success_by_src(
self,
src: str,
storage: Optional[str] = None,
recursive: bool = False,
) -> list[TransferHistorySnapshot]:
"""按源路径返回成功整理历史快照。"""
...
def list_success_move_by_dest(
self,
dest: str,
storage: Optional[str] = None,
recursive: bool = False,
) -> list[TransferHistorySnapshot]:
"""按目标路径返回成功移动历史快照。"""
...
def list_by_hash(self, download_hash: str) -> list[TransferHistorySnapshot]:
"""按下载任务 Hash 返回历史快照。"""
...
async def async_get(
self,
history_id: int,
) -> Optional[TransferHistorySnapshot]:
"""异步按主键返回整理历史快照。"""
...
async def async_list_by_title(
self,
title: str,
page: int = 1,
count: int = 30,
status: Optional[bool] = None,
wildcard: bool = False,
) -> list[TransferHistorySnapshot]:
"""异步按标题或路径分页返回历史快照。"""
...
async def async_list_by_page(
self,
page: int = 1,
count: int = 30,
status: Optional[bool] = None,
) -> list[TransferHistorySnapshot]:
"""异步按时间倒序分页返回历史快照。"""
...
async def async_count(self, status: Optional[bool] = None) -> int:
"""异步统计指定状态的整理历史数量。"""
...
async def async_count_by_title(
self,
title: str,
status: Optional[bool] = None,
wildcard: bool = False,
) -> int:
"""异步统计匹配标题或路径的整理历史数量。"""
...
async def async_statistic(
self,
days: int = 7,
) -> list[TransferHistoryStatisticSnapshot]:
"""异步返回最近若干天的每日整理数量。"""
...
def monthly_media_statistics(self) -> TransferHistoryMonthlyStatistics:
"""返回本月电影、剧集、单集和音乐整理数量。"""
...
class TransferHistoryWritePort(Protocol):
"""整理历史替换、删除与维护所需的类型化事务端口。"""
def replace(self, history: TransferHistoryWrite) -> TransferHistorySnapshot:
"""在独立事务中替换同源历史并返回快照。"""
...
def delete(self, history_id: int) -> None:
"""在独立事务中删除一条旧历史。"""
...
async def async_delete(self, history_id: int) -> None:
"""在独立异步事务中删除一条旧历史。"""
...
def truncate(self) -> None:
"""在独立事务中清空没有 durable 任务映射的历史。"""
...
def update_download_hash(self, history_id: int, download_hash: str) -> None:
"""在独立事务中补充整理历史的下载任务 Hash。"""
...
class TransferHistoryReplacePort(Protocol):
"""整理历史业务写入规则需要的最小替换端口。"""
def replace(self, history: TransferHistoryWrite) -> TransferHistorySnapshot:
"""替换同源历史并返回冻结快照。"""
...
class TransferHistoryStagingPort(TransferHistoryReplacePort, Protocol):
"""durable 结算事务内查询并替换整理历史的类型化暂存端口。"""
def get_by_src(
self,
src: str,
storage: Optional[str] = None,
) -> Optional[TransferHistorySnapshot]:
"""在调用方 Session 内按源路径读取历史快照。"""
...
def get_success_by_src(
self,
src: str,
storage: Optional[str] = None,
) -> Optional[TransferHistorySnapshot]:
"""在调用方 Session 内按源路径读取成功历史快照。"""
...
class TransferHistoryRepository(
TransferHistoryQueryPort,
TransferHistoryWritePort,
Protocol,
):
"""组合宿主所需全部整理历史查询和变更能力。"""
_configured_transfer_history_repository: (
Callable[[], TransferHistoryRepository] | None
) = None
def configure_transfer_history_repository(
provider: Callable[[], TransferHistoryRepository],
) -> None:
"""由启动组合根登记类型化整理历史仓储提供器。"""
global _configured_transfer_history_repository
_configured_transfer_history_repository = provider
def get_transfer_history_repository() -> TransferHistoryRepository:
"""返回启动组合根登记的类型化整理历史仓储。"""
if _configured_transfer_history_repository is None:
raise RuntimeError("类型化整理历史仓储尚未配置")
return _configured_transfer_history_repository()
@dataclass(frozen=True, slots=True)
class DownloadHistorySnapshot:
"""脱离数据库会话后供宿主下载、订阅和整理用例读取的历史快照。"""
@@ -346,47 +595,6 @@ class AsyncDownloadHistoryQueryRepository(Protocol):
...
class AsyncTransferHistoryQueryRepository(Protocol):
"""整理历史列表和详情查询需要的最小异步持久化端口。"""
async def async_get(self, historyid: int) -> Optional[Any]:
"""按主键读取单条整理历史。"""
...
async def async_list_by_title(
self,
title: str,
page: int = 1,
count: int = 30,
status: Optional[bool] = None,
wildcard: bool = False,
) -> list[Any]:
"""按标题或路径分页读取整理历史。"""
...
async def async_list_by_page(
self,
page: int = 1,
count: int = 30,
status: Optional[bool] = None,
) -> list[Any]:
"""按时间倒序分页读取整理历史。"""
...
async def async_count(self, status: Optional[bool] = None) -> Optional[int]:
"""统计指定状态的整理历史数量。"""
...
async def async_count_by_title(
self,
title: str,
status: Optional[bool] = None,
wildcard: bool = False,
) -> Optional[int]:
"""统计匹配标题或路径的整理历史数量。"""
...
@dataclass(frozen=True, slots=True)
class ManualTransferHistory:
"""手动整理准备阶段需要的稳定历史投影。"""
@@ -394,8 +602,8 @@ class ManualTransferHistory:
id: int
status: bool
mode: Optional[str]
src_fileitem: Optional[dict]
dest_fileitem: Optional[dict]
src_fileitem: Optional[dict[str, JsonData]]
dest_fileitem: Optional[dict[str, JsonData]]
downloader: Optional[str]
download_hash: Optional[str]
type: Optional[str]
@@ -410,7 +618,7 @@ class ManualTransferHistory:
class TransferHistoryLookupRepository(Protocol):
"""手动整理历史投影所需的同步查询端口。"""
def get(self, history_id: int) -> Optional[Any]:
def get(self, history_id: int) -> Optional[TransferHistorySnapshot]:
"""按主键读取整理历史。"""
...
@@ -427,12 +635,18 @@ class TransferHistoryLookupService:
record = self._repository.get(history_id)
if record is None:
return None
src_fileitem = (
record.src_fileitem if isinstance(record.src_fileitem, dict) else None
)
dest_fileitem = (
record.dest_fileitem if isinstance(record.dest_fileitem, dict) else None
)
return ManualTransferHistory(
id=record.id,
status=bool(record.status),
mode=record.mode,
src_fileitem=record.src_fileitem,
dest_fileitem=record.dest_fileitem,
src_fileitem=src_fileitem,
dest_fileitem=dest_fileitem,
downloader=record.downloader,
download_hash=record.download_hash,
type=record.type,
@@ -452,7 +666,7 @@ class HistoryQueryService:
self,
*,
download_repository: AsyncDownloadHistoryQueryRepository,
transfer_repository: AsyncTransferHistoryQueryRepository,
transfer_repository: TransferHistoryQueryPort,
) -> None:
"""保存下载历史和整理历史的只读端口。"""
self._download_repository = download_repository
@@ -555,7 +769,7 @@ class DownloadHistoryMutationRepository(Protocol):
class TransferHistoryMutationRepository(Protocol):
"""整理历史删除与清理用例需要的最小持久化端口。"""
def get(self, history_id: int) -> Optional[Any]:
def get(self, history_id: int) -> Optional[TransferHistorySnapshot]:
"""读取整理历史。"""
...
@@ -625,9 +839,9 @@ class TransferHistoryMutationCommand:
repository: TransferHistoryMutationRepository,
download_repository: DownloadFileMutationRepository,
unit_of_work: HistoryUnitOfWork,
file_item_factory: Callable[[dict], Any],
file_item_factory: Callable[[dict[str, JsonData]], Any],
delete_media_file: Callable[[Any], bool],
publish_download_file_deleted: Callable[[dict], None],
publish_download_file_deleted: Callable[[dict[str, Any]], None],
clear_failures: Callable[[Optional[str], Optional[str]], None],
) -> None:
"""保存历史事务、存储删除、事件和失败状态清理端口。"""
@@ -657,12 +871,18 @@ class TransferHistoryMutationCommand:
)
if delete_destination and history.dest_fileitem:
destination = self._file_item_factory(history.dest_fileitem)
destination_payload = self._file_item_payload(history.dest_fileitem)
if destination_payload is None:
return HistoryMutationResult(False, "目标文件历史数据无效")
destination = self._file_item_factory(destination_payload)
self._delete_media_file(destination)
source_deleted = False
if delete_source and history.src_fileitem:
source = self._file_item_factory(history.src_fileitem)
source_payload = self._file_item_payload(history.src_fileitem)
if source_payload is None:
return HistoryMutationResult(False, "源文件历史数据无效")
source = self._file_item_factory(source_payload)
if not self._delete_media_file(source):
return HistoryMutationResult(False, f"{source.path} 删除失败")
self._download_repository.stage_delete_file_by_fullpath(
@@ -680,6 +900,11 @@ class TransferHistoryMutationCommand:
self._clear_failures(history.src, history.src_storage)
return HistoryMutationResult(True)
@staticmethod
def _file_item_payload(value: JsonData) -> Optional[dict[str, JsonData]]:
"""仅接受可安全构造文件项的历史 JSON 对象。"""
return value if isinstance(value, dict) else None
def truncate(self) -> HistoryMutationResult:
"""在单一事务中清空旧历史,并保留当前失败任务记录。"""
self._repository.stage_truncate()
@@ -805,7 +1030,7 @@ def file_fingerprint(
:param fileid: 存储器文件唯一标识
:return: 非空且可比较的指纹字段
"""
fingerprint = {}
fingerprint: Dict[str, Any] = {}
size = coerce_size(file_size)
if size is not None:
fingerprint["size"] = size
@@ -960,7 +1185,7 @@ def coerce_size(size: Any) -> Optional[int]:
return None
def history_src_size(history: TransferHistoryRecord) -> Optional[int]:
def history_src_size(history: TransferHistorySnapshot) -> Optional[int]:
"""
读取整理记录中的源文件大小。
src_fileitem 是 JSON 列,历史数据可能为空、缺 size 键甚至不是字典,
@@ -971,7 +1196,7 @@ def history_src_size(history: TransferHistoryRecord) -> Optional[int]:
return history_src_fingerprint(history).get("size")
def history_src_fingerprint(history: TransferHistoryRecord) -> Dict[str, Any]:
def history_src_fingerprint(history: TransferHistorySnapshot) -> Dict[str, Any]:
"""
读取整理记录中的源文件版本指纹。
:param history: 整理记录
@@ -987,9 +1212,11 @@ def history_src_fingerprint(history: TransferHistoryRecord) -> Dict[str, Any]:
)
def resolve_history(src_path: str, storage: Optional[str] = None,
transfer_history_oper: Optional[TransferHistoryWriter] = None
) -> Optional[TransferHistoryRecord]:
def resolve_history(
src_path: str,
storage: Optional[str] = None,
transfer_history_oper: Optional[TransferHistoryQueryPort] = None,
) -> Optional[TransferHistorySnapshot]:
"""
查询源路径对应的整理记录。
@@ -998,17 +1225,17 @@ def resolve_history(src_path: str, storage: Optional[str] = None,
此处吞掉,由调用方按各自的重试策略处理。
:param src_path: 整理记录使用的源路径
:param storage: 存储
:param transfer_history_oper: 复用的历史操作对象,未传时新建
:param transfer_history_oper: 兼容旧关键字的类型化仓储,未传时使用组合根实现
:return: 命中的整理记录,未命中时为 None
"""
oper = _get_transfer_history_writer(transfer_history_oper)
history = oper.get_by_src(src_path, storage=storage)
repository = transfer_history_oper or get_transfer_history_repository()
history = repository.get_by_src(src_path, storage=storage)
if history is not None and not history.status:
history = oper.get_success_by_src(src_path, storage=storage) or history
history = repository.get_success_by_src(src_path, storage=storage) or history
return history
def evaluate_history_gate(history: Optional[TransferHistoryRecord],
def evaluate_history_gate(history: Optional[TransferHistorySnapshot],
file_size: Optional[float] = None,
file_modify_time: Optional[float] = None,
fileid: Optional[str] = None,
@@ -1060,7 +1287,7 @@ def evaluate_history_gate(history: Optional[TransferHistoryRecord],
return HistoryGateAction.SKIP
def describe_history_gate(history: Optional[TransferHistoryRecord],
def describe_history_gate(history: Optional[TransferHistorySnapshot],
file_size: Optional[float] = None,
file_modify_time: Optional[float] = None,
fileid: Optional[str] = None) -> str:
@@ -1102,7 +1329,7 @@ def describe_history_gate(history: Optional[TransferHistoryRecord],
# 整理历史的写入路径
#
# 这两个函数把 FileItem / MetaBase / MediaInfo / TransferInfo 四个领域对象翻译成
# 一行整理历史,是整理历史表的唯一写入口。它们此前长在 TransferHistoryOper 上,但
# 一行整理历史,是整理历史表的唯一写入口。它们此前由表级适配器承载,但
# 拼标题、拆季集、取海报、判音乐字段都是整理链的业务规则而非数据访问——Oper 只该
# 收敛查询,领域对象不该出现在它的入参里。搬到本模块与查重闸(读侧)作伴:同一张
# 表的读写规则放在一起,字段含义只有一处需要维护。
@@ -1112,18 +1339,34 @@ def _history_title(meta: MetaBase,
mediainfo: Optional[Union[MediaInfo, MusicInfo]] = None) -> Optional[str]:
"""音乐文件优先记录曲目标题,其它媒体保持识别标题。"""
if isinstance(meta, MetaMusic) and meta.title:
return meta.title
return str(meta.title)
if mediainfo and mediainfo.title:
return mediainfo.title
return meta.name
return str(mediainfo.title)
return str(meta.name) if meta.name else None
def add_transfer_success(fileitem: FileItem, mode: str, meta: MetaBase,
mediainfo: Union[MediaInfo, MusicInfo], transferinfo: TransferInfo,
downloader: Optional[str] = None,
download_hash: Optional[str] = None,
transfer_history_oper: Optional[TransferHistoryWriter] = None
) -> Optional[TransferHistoryRecord]:
def _history_source_path(fileitem: FileItem) -> str:
"""返回整理历史必需的源路径,拒绝持久化无身份记录。"""
if not fileitem.path:
raise ValueError("整理历史缺少源文件路径")
return fileitem.path
def _history_year(value: object) -> Optional[str]:
"""把媒体年份规范为整理历史稳定字符串。"""
return str(value) if value is not None else None
def add_transfer_success(
fileitem: FileItem,
mode: str,
meta: MetaBase,
mediainfo: Union[MediaInfo, MusicInfo],
transferinfo: TransferInfo,
downloader: Optional[str] = None,
download_hash: Optional[str] = None,
transfer_history_oper: Optional[TransferHistoryReplacePort] = None,
) -> TransferHistorySnapshot:
"""
新增转移成功历史记录。
:param fileitem: 源文件项
@@ -1133,13 +1376,13 @@ def add_transfer_success(fileitem: FileItem, mode: str, meta: MetaBase,
:param transferinfo: 整理结果
:param downloader: 下载器
:param download_hash: 种子 hash
:param transfer_history_oper: 复用的历史操作对象,未传时新建
:param transfer_history_oper: 兼容旧关键字的暂存端口,未传时使用组合根仓储
:return: 落库后的整理记录
"""
oper = _get_transfer_history_writer(transfer_history_oper)
repository = transfer_history_oper or get_transfer_history_repository()
media_source, media_id = resolve_media_identity(media=mediainfo)
return oper.add_force(
src=fileitem.path,
return repository.replace(TransferHistoryWrite(
src=_history_source_path(fileitem),
src_storage=fileitem.storage,
src_fileitem=fileitem.model_dump(),
dest=transferinfo.target_item.path if transferinfo.target_item else None,
@@ -1149,7 +1392,7 @@ def add_transfer_success(fileitem: FileItem, mode: str, meta: MetaBase,
type=mediainfo.type.value,
category=mediainfo.category,
title=_history_title(meta, mediainfo),
year=mediainfo.year,
year=_history_year(mediainfo.year),
media_source=media_source,
media_id=media_id,
music_type=getattr(mediainfo, "music_type", None),
@@ -1164,18 +1407,21 @@ def add_transfer_success(fileitem: FileItem, mode: str, meta: MetaBase,
image=mediainfo.get_poster_image(),
downloader=downloader,
download_hash=download_hash,
status=1,
files=transferinfo.file_list
)
status=True,
files=transferinfo.file_list,
))
def add_transfer_fail(fileitem: FileItem, mode: str, meta: MetaBase,
mediainfo: Optional[Union[MediaInfo, MusicInfo]] = None,
transferinfo: Optional[TransferInfo] = None,
downloader: Optional[str] = None,
download_hash: Optional[str] = None,
transfer_history_oper: Optional[TransferHistoryWriter] = None
) -> Optional[TransferHistoryRecord]:
def add_transfer_fail(
fileitem: FileItem,
mode: str,
meta: MetaBase,
mediainfo: Optional[Union[MediaInfo, MusicInfo]] = None,
transferinfo: Optional[TransferInfo] = None,
downloader: Optional[str] = None,
download_hash: Optional[str] = None,
transfer_history_oper: Optional[TransferHistoryReplacePort] = None,
) -> TransferHistorySnapshot:
"""
新增转移失败历史记录。
@@ -1188,14 +1434,14 @@ def add_transfer_fail(fileitem: FileItem, mode: str, meta: MetaBase,
:param transferinfo: 整理结果,未进入整理时为 None
:param downloader: 下载器
:param download_hash: 种子 hash
:param transfer_history_oper: 复用的历史操作对象,未传时新建
:param transfer_history_oper: 兼容旧关键字的暂存端口,未传时使用组合根仓储
:return: 落库后的整理记录
"""
oper = _get_transfer_history_writer(transfer_history_oper)
repository = transfer_history_oper or get_transfer_history_repository()
if mediainfo and transferinfo:
media_source, media_id = resolve_media_identity(media=mediainfo)
his = oper.add_force(
src=fileitem.path,
history = repository.replace(TransferHistoryWrite(
src=_history_source_path(fileitem),
src_storage=fileitem.storage,
src_fileitem=fileitem.model_dump(),
dest=transferinfo.target_item.path if transferinfo.target_item else None,
@@ -1205,7 +1451,7 @@ def add_transfer_fail(fileitem: FileItem, mode: str, meta: MetaBase,
type=mediainfo.type.value,
category=mediainfo.category,
title=_history_title(meta, mediainfo),
year=mediainfo.year or meta.year,
year=_history_year(mediainfo.year or meta.year),
media_source=media_source,
media_id=media_id,
music_type=getattr(mediainfo, "music_type", None),
@@ -1221,16 +1467,16 @@ def add_transfer_fail(fileitem: FileItem, mode: str, meta: MetaBase,
downloader=downloader,
download_hash=download_hash,
episode_group=mediainfo.episode_group,
status=0,
status=False,
errmsg=transferinfo.message or '未知错误',
files=transferinfo.file_list
)
files=transferinfo.file_list,
))
else:
media_source, media_id = resolve_media_identity(media=meta)
his = oper.add_force(
history = repository.replace(TransferHistoryWrite(
type=meta.type.value if meta.type else None,
title=_history_title(meta),
year=meta.year,
year=_history_year(meta.year),
media_source=media_source,
media_id=media_id,
music_type=MUSIC_ENTITY_RECORDING if isinstance(meta, MetaMusic) else None,
@@ -1239,7 +1485,7 @@ def add_transfer_fail(fileitem: FileItem, mode: str, meta: MetaBase,
bit_depth=getattr(meta, "bit_depth", None),
sample_rate=getattr(meta, "sample_rate", None),
bitrate=getattr(meta, "bitrate", None),
src=fileitem.path,
src=_history_source_path(fileitem),
src_storage=fileitem.storage,
src_fileitem=fileitem.model_dump(),
mode=mode,
@@ -1247,7 +1493,7 @@ def add_transfer_fail(fileitem: FileItem, mode: str, meta: MetaBase,
episodes=meta.episode,
downloader=downloader,
download_hash=download_hash,
status=0,
errmsg="未识别到媒体信息"
)
return his
status=False,
errmsg="未识别到媒体信息",
))
return history

View File

@@ -74,11 +74,14 @@ class HistoryQueryPort(Protocol):
*,
filters: DownloadHistoryFilter,
page: QueryPageRequest,
) -> QueryRows[object]:
) -> QueryRows[DownloadHistorySnapshot]:
"""按组合筛选和稳定分页读取下载历史。"""
...
def get_download_history(self, history_id: int) -> object | None:
def get_download_history(
self,
history_id: int,
) -> DownloadHistorySnapshot | None:
"""按 ID 读取一条下载历史。"""
...
@@ -87,11 +90,14 @@ class HistoryQueryPort(Protocol):
*,
filters: TransferHistoryFilter,
page: QueryPageRequest,
) -> QueryRows[object]:
) -> QueryRows[TransferHistorySnapshot]:
"""按组合筛选和稳定分页读取整理历史。"""
...
def get_transfer_history(self, history_id: int) -> object | None:
def get_transfer_history(
self,
history_id: int,
) -> TransferHistorySnapshot | None:
"""按 ID 读取一条整理历史。"""
...

View File

@@ -26,6 +26,8 @@ from app.application.history import (
DownloadFileSnapshot,
DownloadHistoryQueryPort,
DownloadHistorySnapshot,
TransferHistoryRepository,
TransferHistorySnapshot,
clear_transfer_failures,
resolve_history,
)
@@ -58,11 +60,9 @@ from app.schemas.types import (
)
from app.schemas.workflow import FileItem
TransferHistory = Any
def _request_durable_transfer_retry(
history: TransferHistory,
history: TransferHistorySnapshot,
*,
requested_by: str,
) -> Optional[Tuple[bool, str]]:
@@ -357,7 +357,10 @@ class FileFilterMixin:
return file_meta, file_info
@staticmethod
def _is_music_retry_source(history: TransferHistory, src_path: Path) -> bool:
def _is_music_retry_source(
history: TransferHistorySnapshot,
src_path: Path,
) -> bool:
"""
判断重新整理来源是否应走音乐链路:历史类型为音乐,或源路径为音频文件。
"""
@@ -367,7 +370,7 @@ class FileFilterMixin:
def _recognize_music_retry_media(
self,
history: TransferHistory,
history: TransferHistorySnapshot,
src_path: Path,
) -> Optional[Union[MusicInfo, MediaInfo]]:
"""
@@ -457,7 +460,7 @@ class FileFilterMixin:
@staticmethod
def _is_overwrite_declined(task: TransferTask, transferinfo: TransferInfo,
transferhis: Any) -> bool:
transferhis: TransferHistoryRepository) -> bool:
"""
判断本次未入库是否为「同路径已有成功记录 + 覆盖模式裁定不覆盖」。
@@ -470,7 +473,11 @@ class FileFilterMixin:
:param transferhis: 历史操作对象
:return: True 表示应保留原成功记录
"""
if not transferinfo.overwrite_skipped or not task.fileitem:
if (
not transferinfo.overwrite_skipped
or not task.fileitem
or not task.fileitem.path
):
return False
try:
history = resolve_history(
@@ -1184,7 +1191,9 @@ class ManualHistoryMixin:
)
@staticmethod
def _is_successful_move_history(history: Optional[TransferHistory]) -> bool:
def _is_successful_move_history(
history: Optional[TransferHistorySnapshot],
) -> bool:
"""判断历史记录是否为已成功完成的移动类整理。"""
return bool(
history
@@ -1196,10 +1205,12 @@ class ManualHistoryMixin:
def _get_manual_transfer_history(
self,
fileitem: FileItem,
transfer_history_oper: Any,
transfer_history_oper: TransferHistoryRepository,
include_move_dest: bool = False,
) -> Optional[TransferHistory]:
) -> Optional[TransferHistorySnapshot]:
"""查询文件源路径历史,并兼容从成功移动后的目标现址重新整理。"""
if not fileitem.path:
return None
# resolve_history 在命中失败记录时会再确认一次有无成功记录,
# 避免 get_by_src 无排序导致同源多行时返回哪条不确定
history = resolve_history(
@@ -1219,7 +1230,7 @@ class ManualHistoryMixin:
def get_manual_transfer_histories(
self,
fileitems: List[FileItem],
) -> List[TransferHistory]:
) -> List[TransferHistorySnapshot]:
"""
查询文件或目录命中的成功整理记录,供手动整理界面显示重整状态。
@@ -1227,7 +1238,7 @@ class ManualHistoryMixin:
:return: 去重后的成功整理记录
"""
transfer_history_oper = get_chain_transfer_history_port()
histories: Dict[int, TransferHistory] = {}
histories: Dict[int, TransferHistorySnapshot] = {}
for fileitem in fileitems or []:
if not fileitem or not fileitem.path:
continue
@@ -1259,7 +1270,7 @@ class ManualHistoryMixin:
def _request_durable_transfer_retry(
self,
history: TransferHistory,
history: TransferHistorySnapshot,
*,
requested_by: str,
) -> Optional[Tuple[bool, str]]:
@@ -1279,8 +1290,8 @@ class ManualHistoryMixin:
def _delete_manual_transfer_history(
self,
history: TransferHistory,
transfer_history_oper: Any,
history: TransferHistorySnapshot,
transfer_history_oper: TransferHistoryRepository,
) -> Tuple[bool, str]:
"""删除手动重整历史;非成功移动记录同时清理可能存在的旧目标。"""
durable_retry = self._request_durable_transfer_retry(
@@ -1295,6 +1306,8 @@ class ManualHistoryMixin:
history.dest_fileitem
and not ManualHistoryMixin._is_successful_move_history(history)
):
if not isinstance(history.dest_fileitem, dict):
return False, "目标文件历史数据无效"
dest_fileitem = FileItem(**history.dest_fileitem)
storage_chain = StorageChain()
if (
@@ -1601,7 +1614,7 @@ class FailedRetryMixin:
:param media_id: 数据源原生 ID,必须与 media_source 成对提供
"""
# 查询历史记录
history: TransferHistory = get_chain_transfer_history_port().get(logid)
history = get_chain_transfer_history_port().get(logid)
if not history:
logger.error(f"整理记录不存在,ID:{logid}")
return False, "整理记录不存在"
@@ -1676,12 +1689,16 @@ class FailedRetryMixin:
# 删除旧的已整理文件
if history.dest_fileitem:
if not isinstance(history.dest_fileitem, dict):
return False, "目标文件历史数据无效"
# 解析目标文件对象
dest_fileitem = FileItem(**history.dest_fileitem)
StorageChain().delete_file(dest_fileitem)
# 强制整理
if history.src_fileitem:
if not isinstance(history.src_fileitem, dict):
return False, "源文件历史数据无效"
state, errmsg = self.do_transfer(
fileitem=FileItem(**history.src_fileitem),
mediainfo=mediainfo,

View File

@@ -27,6 +27,8 @@ from app.application.formatting import FormatParser
from app.application.history import (
DownloadHistoryQueryPort,
DownloadHistorySnapshot,
TransferHistorySnapshot,
TransferHistoryStagingPort,
add_transfer_fail,
add_transfer_success,
clear_transfer_failures,
@@ -524,8 +526,12 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
if writer is None:
raise RuntimeError("旧整理兼容命令缺少 durable 原子写入端口")
def stage_history(staging: Any) -> Any:
def stage_history(
staging: TransferHistoryStagingPort,
) -> Optional[TransferHistorySnapshot]:
"""按兼容结果暂存成功、失败或覆盖跳过的唯一历史投影。"""
if not task.fileitem or not task.fileitem.path:
raise ValueError("整理终态缺少源文件路径")
if overwrite_declined:
return staging.get_success_by_src(
task.fileitem.path,
@@ -578,7 +584,11 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
self.__forget_owned_lease(task.admission_task_id, task.lease_token)
@staticmethod
def __transfer_history_id(history: Any) -> Optional[int]:
def __transfer_history_id(
history: Optional[
Union[TransferHistorySnapshot, TransferSettlementResult]
],
) -> Optional[int]:
"""统一读取旧历史投影和 task-aware 结算结果的历史标识。"""
if isinstance(history, TransferSettlementResult):
return history.history_id
@@ -943,7 +953,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
# 转移失败
if not transferinfo.success:
# 查重闸放行同路径新版本后由 overwrite_mode 判定不覆盖,是一次正常裁决而非故障:
# 媒体库里原有版本仍然在位,写失败记录会用 add_force 顶掉原成功记录,此后该路径
# 媒体库里原有版本仍然在位,写失败记录会按同源 replace 替换原成功记录,此后该路径
# 永远处于失败态,每个新事件都会重试并重推失败通知。此时保留原记录、不写历史、
# 不发事件与通知、不触发重试,仅把任务置为未入库
history = None
@@ -4559,7 +4569,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
file_items, matched_episode_format_template = self._collect_transfer_candidates(
fileitem=fileitem,
batch_mtype=batch_mtype,
min_filesize=min_filesize,
min_filesize=min_filesize or 0,
epformat=epformat,
season=season,
continue_callback=continue_callback,

View File

@@ -12,7 +12,6 @@ from sqlalchemy.orm import Session
from app.application.chain.events import (
ChainDurableEventWriter,
TransferHistoryRef,
TransferResultSettlement,
download_added_event_key,
snapshot_download_added,
@@ -22,8 +21,9 @@ from app.application.chain.events import (
from app.application.history import (
DownloadFileWrite,
DownloadHistoryWrite,
TransferHistoryRecord,
TransferHistoryWriter,
TransferHistorySnapshot,
TransferHistoryStagingPort,
TransferHistoryWrite,
)
from app.application.outbox import (
DOWNLOAD_ADDED_TOPIC,
@@ -37,6 +37,7 @@ from app.application.transfer.execution import (
TransferExecutionState,
TransferSettlementResult,
)
from app.db.adapters.history.transfer import project_transfer_history
from app.db.adapters.outbox import (
SqlAlchemyOutboxDispatchStore,
SqlAlchemyOutboxStager,
@@ -50,7 +51,7 @@ from app.db.oper.transfersettlementreceipt import TransferSettlementReceiptOper
from app.db.uow import SqlAlchemyUnitOfWork
class _StagingTransferHistoryWriter:
class _StagingTransferHistoryRepository:
"""让既有历史字段映射复用无提交的 replace 适配器。"""
def __init__(
@@ -69,46 +70,56 @@ class _StagingTransferHistoryWriter:
self,
src: str,
storage: str | None = None,
) -> TransferHistoryRecord | None:
) -> TransferHistorySnapshot | None:
"""转发按源路径读取。"""
return self._repository.get_by_src(src, storage)
record = self._repository.get_by_src(src, storage)
return project_transfer_history(record) if record is not None else None
def get_success_by_src(
self,
src: str,
storage: str | None = None,
) -> TransferHistoryRecord | None:
) -> TransferHistorySnapshot | None:
"""读取成功记录;任务结算时绑定当前任务投影。"""
if self._settlement is not None:
if self._settlement_revision is None:
raise RuntimeError("整理任务结算缺少事务内修订号")
return self._repository.stage_bind_settlement(
record = self._repository.stage_bind_settlement(
task_id=self._settlement.task_id,
settlement_revision=self._settlement_revision,
src=src,
storage=storage,
)
return self._repository.get_success_by_src(src, storage)
return (
project_transfer_history(record)
if record is not None
else None
)
record = self._repository.get_success_by_src(src, storage)
return project_transfer_history(record) if record is not None else None
def add_force(self, **payload: Any) -> TransferHistoryRecord:
"""保持旧端口名,并按是否存在任务身份选择暂存策略。"""
def replace(self, history: TransferHistoryWrite) -> TransferHistorySnapshot:
"""按是否存在任务身份选择事务内类型化替换策略。"""
payload = history.to_payload()
if self._settlement is not None:
if self._settlement_revision is None:
raise RuntimeError("整理任务结算缺少事务内修订号")
return self._repository.stage_upsert_by_transfer_task_id(
record = self._repository.stage_upsert_by_transfer_task_id(
task_id=self._settlement.task_id,
settlement_revision=self._settlement_revision,
retain_task_mapping=self._settlement.outcome == "failed",
payload=payload,
)
return self._repository.stage_replace_by_src(**payload)
else:
record = self._repository.stage_replace_by_src(**payload)
return project_transfer_history(record)
@dataclass(frozen=True, slots=True)
class _StagedTransferResult:
"""保存构造 outbox 所需历史投影及可选任务结算结果。"""
history: TransferHistoryRef
history: TransferHistorySnapshot
settlement: TransferSettlementResult | None = None
@@ -170,11 +181,14 @@ class TransactionalChainDurableEventWriter(ChainDurableEventWriter):
self,
*,
topic: str | None,
stage_history: Callable[[TransferHistoryWriter], TransferHistoryRecord | None],
stage_history: Callable[
[TransferHistoryStagingPort],
TransferHistorySnapshot | None,
],
event_payload: dict[str, Any],
publish: Callable[[dict[str, Any]], None] | None,
settlement: TransferResultSettlement | None = None,
) -> TransferHistoryRecord | TransferSettlementResult | None:
) -> TransferHistorySnapshot | TransferSettlementResult | None:
"""原子写历史、可选任务终态与 intent,再返回稳定投影。"""
if topic is None and settlement is None:
raise ValueError("无事件 topic 的整理写入必须绑定 durable 任务结算")
@@ -208,7 +222,7 @@ class TransactionalChainDurableEventWriter(ChainDurableEventWriter):
if expected_revision is not None
else None
)
staging = _StagingTransferHistoryWriter(
staging = _StagingTransferHistoryRepository(
history_repository,
settlement=settlement,
settlement_revision=next_revision,
@@ -216,13 +230,7 @@ class TransactionalChainDurableEventWriter(ChainDurableEventWriter):
history = stage_history(staging)
if history is None:
raise RuntimeError("整理历史暂存失败,无法登记 durable 结果事件")
projected = TransferHistoryRef(
id=history.id,
status=bool(history.status),
src=history.src,
src_storage=history.src_storage,
src_fileitem=history.src_fileitem,
)
projected = history
if settlement is None:
return _StagedTransferResult(history=projected)
assert expected_revision is not None
@@ -479,7 +487,7 @@ class TransactionalChainDurableEventWriter(ChainDurableEventWriter):
@staticmethod
def _validate_history_outcome(
history: TransferHistoryRef,
history: TransferHistorySnapshot,
settlement: TransferResultSettlement,
) -> None:
"""拒绝结算终态与历史状态不一致的调用。"""

View File

@@ -0,0 +1,401 @@
"""整理历史的类型化查询、写入与短事务适配器。"""
from __future__ import annotations
from collections.abc import Callable
from contextlib import AbstractAsyncContextManager
from copy import deepcopy
from typing import Optional, TypeVar
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.application.history import (
TransferHistoryMonthlyStatistics,
TransferHistorySnapshot,
TransferHistoryStatisticSnapshot,
TransferHistoryWrite,
)
from app.db.oper.transferhistory import TransferHistoryOper
from app.db.uow import SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork
from app.schemas.media import normalize_media_source
from app.schemas.types import MediaSource
ResultT = TypeVar("ResultT")
def project_transfer_history(record: object) -> TransferHistorySnapshot:
"""在 Session 内把整理历史 ORM 记录投影为不可变快照。"""
history_id = getattr(record, "id", None)
if not isinstance(history_id, int):
raise ValueError("整理历史记录缺少稳定身份")
media_source = normalize_media_source(getattr(record, "media_source", None))
media_id_value = getattr(record, "media_id", None)
media_id = str(media_id_value).strip() if media_id_value is not None else None
if not media_source or not media_id or media_id == "0":
media_source = None
media_id = None
return TransferHistorySnapshot(
id=history_id,
transfer_task_id=getattr(record, "transfer_task_id", None),
transfer_settlement_revision=getattr(
record,
"transfer_settlement_revision",
None,
),
src=getattr(record, "src", None),
src_storage=getattr(record, "src_storage", None),
src_fileitem=deepcopy(getattr(record, "src_fileitem", None)),
dest=getattr(record, "dest", None),
dest_storage=getattr(record, "dest_storage", None),
dest_fileitem=deepcopy(getattr(record, "dest_fileitem", None)),
mode=getattr(record, "mode", None),
type=getattr(record, "type", None),
category=getattr(record, "category", None),
title=getattr(record, "title", None),
year=getattr(record, "year", None),
media_source=media_source,
media_id=media_id,
music_type=getattr(record, "music_type", None),
total_tracks=getattr(record, "total_tracks", None),
audio_format=getattr(record, "audio_format", None),
audio_lossless=getattr(record, "audio_lossless", None),
bit_depth=getattr(record, "bit_depth", None),
sample_rate=getattr(record, "sample_rate", None),
bitrate=getattr(record, "bitrate", None),
seasons=getattr(record, "seasons", None),
episodes=getattr(record, "episodes", None),
image=getattr(record, "image", None),
downloader=getattr(record, "downloader", None),
download_hash=getattr(record, "download_hash", None),
status=bool(getattr(record, "status", False)),
errmsg=getattr(record, "errmsg", None),
date=getattr(record, "date", None),
files=deepcopy(getattr(record, "files", None)),
episode_group=getattr(record, "episode_group", None),
)
class TransactionalTransferHistoryRepository:
"""为整理、Agent、工作流和历史接口创建短生命周期 Session。"""
def __init__(
self,
*,
sync_session: Callable[[], Session],
async_session: Callable[[], AbstractAsyncContextManager[AsyncSession]],
) -> None:
"""保存由启动组合根提供的同步与异步 Session 工厂。"""
self._sync_session = sync_session
self._async_session = async_session
def _read(self, operation: Callable[[TransferHistoryOper], ResultT]) -> ResultT:
"""在独立同步 Session 中执行一次只读操作。"""
session = self._sync_session()
try:
return operation(TransferHistoryOper(session))
finally:
session.close()
def _write(self, operation: Callable[[TransferHistoryOper], ResultT]) -> ResultT:
"""在独立同步 Session 和单一 UoW 中执行一次写操作。"""
session = self._sync_session()
unit_of_work = SqlAlchemyUnitOfWork(session)
try:
result = operation(TransferHistoryOper(session))
unit_of_work.commit()
return result
except Exception:
unit_of_work.rollback()
raise
finally:
session.close()
def get(self, history_id: int) -> Optional[TransferHistorySnapshot]:
"""按主键返回整理历史快照。"""
return self._read(
lambda repository: (
project_transfer_history(record)
if (record := repository.get(history_id)) is not None
else None
)
)
def get_by_src(
self,
src: str,
storage: Optional[str] = None,
) -> Optional[TransferHistorySnapshot]:
"""按源路径和可选存储返回最新历史快照。"""
return self._read(
lambda repository: (
project_transfer_history(record)
if (record := repository.get_by_src(src, storage)) is not None
else None
)
)
def get_success_by_src(
self,
src: str,
storage: Optional[str] = None,
) -> Optional[TransferHistorySnapshot]:
"""按源路径和可选存储返回最新成功历史快照。"""
return self._read(
lambda repository: (
project_transfer_history(record)
if (record := repository.get_success_by_src(src, storage)) is not None
else None
)
)
def get_by_dest(
self,
dest: str,
storage: Optional[str] = None,
) -> Optional[TransferHistorySnapshot]:
"""按目标路径和可选存储返回最新历史快照。"""
return self._read(
lambda repository: (
project_transfer_history(record)
if (record := repository.get_by_dest(dest, storage)) is not None
else None
)
)
def get_by_transfer_task_id(
self,
*,
task_id: str,
) -> Optional[TransferHistorySnapshot]:
"""按 durable 整理任务标识返回终态历史快照。"""
return self._read(
lambda repository: (
project_transfer_history(record)
if (
record := repository.get_by_transfer_task_id(task_id=task_id)
) is not None
else None
)
)
def get_by_media_identity(
self,
media_source: MediaSource,
media_id: str,
mtype: Optional[str] = None,
) -> Optional[TransferHistorySnapshot]:
"""按规范媒体身份和可选媒体类型返回历史快照。"""
return self._read(
lambda repository: (
project_transfer_history(record)
if (
record := repository.get_by_media_identity(
media_source,
media_id,
mtype,
)
) is not None
else None
)
)
def list_success_by_src(
self,
src: str,
storage: Optional[str] = None,
recursive: bool = False,
) -> list[TransferHistorySnapshot]:
"""按源路径返回成功整理历史快照。"""
return self._read(
lambda repository: [
project_transfer_history(record)
for record in repository.list_success_by_src(
src,
storage,
recursive,
)
]
)
def list_success_move_by_dest(
self,
dest: str,
storage: Optional[str] = None,
recursive: bool = False,
) -> list[TransferHistorySnapshot]:
"""按目标路径返回成功移动历史快照。"""
return self._read(
lambda repository: [
project_transfer_history(record)
for record in repository.list_success_move_by_dest(
dest,
storage,
recursive,
)
]
)
def list_by_hash(self, download_hash: str) -> list[TransferHistorySnapshot]:
"""按下载任务 Hash 返回历史快照。"""
return self._read(
lambda repository: [
project_transfer_history(record)
for record in repository.list_by_hash(download_hash)
]
)
async def async_get(
self,
history_id: int,
) -> Optional[TransferHistorySnapshot]:
"""异步按主键返回整理历史快照。"""
async with self._async_session() as session:
record = await TransferHistoryOper(session).async_get(history_id)
return project_transfer_history(record) if record is not None else None
async def async_list_by_title(
self,
title: str,
page: int = 1,
count: int = 30,
status: Optional[bool] = None,
wildcard: bool = False,
) -> list[TransferHistorySnapshot]:
"""异步按标题或路径分页返回历史快照。"""
async with self._async_session() as session:
records = await TransferHistoryOper(session).async_list_by_title(
title,
page,
count,
status,
wildcard,
)
return [project_transfer_history(record) for record in records]
async def async_list_by_page(
self,
page: int = 1,
count: int = 30,
status: Optional[bool] = None,
) -> list[TransferHistorySnapshot]:
"""异步按时间倒序分页返回历史快照。"""
async with self._async_session() as session:
records = await TransferHistoryOper(session).async_list_by_page(
page,
count,
status,
)
return [project_transfer_history(record) for record in records]
async def async_count(self, status: Optional[bool] = None) -> int:
"""异步统计指定状态的整理历史数量。"""
async with self._async_session() as session:
count = await TransferHistoryOper(session).async_count(status)
return int(count or 0)
async def async_count_by_title(
self,
title: str,
status: Optional[bool] = None,
wildcard: bool = False,
) -> int:
"""异步统计匹配标题或路径的整理历史数量。"""
async with self._async_session() as session:
count = await TransferHistoryOper(session).async_count_by_title(
title,
status,
wildcard,
)
return int(count or 0)
async def async_statistic(
self,
days: int = 7,
) -> list[TransferHistoryStatisticSnapshot]:
"""异步返回最近若干天的每日整理数量。"""
async with self._async_session() as session:
rows = await TransferHistoryOper(session).async_statistic(days)
return [
TransferHistoryStatisticSnapshot(
date=str(row[0]),
count=int(row[1]),
)
for row in rows
]
def monthly_media_statistics(self) -> TransferHistoryMonthlyStatistics:
"""返回本月电影、剧集、单集和音乐整理数量。"""
movies, tv_shows, episodes, music = self._read(
lambda repository: repository.monthly_media_statistics()
)
return TransferHistoryMonthlyStatistics(
movies=movies,
tv_shows=tv_shows,
episodes=episodes,
music=music,
)
def replace(self, history: TransferHistoryWrite) -> TransferHistorySnapshot:
"""在独立事务中替换同源历史并返回快照。"""
return self._write(
lambda repository: project_transfer_history(
repository.stage_replace_by_src(**history.to_payload())
)
)
def delete(self, history_id: int) -> None:
"""在独立事务中删除一条没有 durable 任务映射的旧历史。"""
self._write(lambda repository: repository.stage_delete(history_id))
async def async_delete(self, history_id: int) -> None:
"""在独立异步事务中删除一条没有 durable 任务映射的旧历史。"""
async with self._async_session() as session:
unit_of_work = SqlAlchemyAsyncUnitOfWork(session)
try:
await TransferHistoryOper(session).async_stage_delete(history_id)
await unit_of_work.commit()
except Exception:
await unit_of_work.rollback()
raise
def truncate(self) -> None:
"""在独立事务中清空没有 durable 任务映射的历史。"""
self._write(lambda repository: repository.stage_truncate())
def update_download_hash(self, history_id: int, download_hash: str) -> None:
"""在独立事务中补充整理历史的下载任务 Hash。"""
self._write(
lambda repository: repository.stage_update_download_hash(
history_id,
download_hash,
)
)
class SessionTransferHistoryRepository:
"""把 API 请求持有的同步 Session 适配为整理历史查询和暂存端口。"""
def __init__(self, session: Session) -> None:
"""保存由请求依赖独占的同步数据库 Session。"""
self._session = session
def get(self, history_id: int) -> Optional[TransferHistorySnapshot]:
"""在请求 Session 内读取并立即投影整理历史。"""
record = TransferHistoryOper(self._session).get(history_id)
return project_transfer_history(record) if record is not None else None
def replace(self, history: TransferHistoryWrite) -> TransferHistorySnapshot:
"""在请求 Session 内暂存同源替换并返回冻结快照。"""
record = TransferHistoryOper(self._session).stage_replace_by_src(
**history.to_payload()
)
return project_transfer_history(record)
def stage_delete(self, history_id: int) -> None:
"""在请求 Session 内暂存一条旧整理历史删除。"""
TransferHistoryOper(self._session).stage_delete(history_id)
def stage_truncate(self) -> None:
"""在请求 Session 内暂存全部旧整理历史删除。"""
TransferHistoryOper(self._session).stage_truncate()

View File

@@ -31,7 +31,6 @@ if TYPE_CHECKING:
from app.db.oper.subscribe import SubscribeOper
from app.db.oper.subscribehistory import SubscribeHistoryOper
from app.db.oper.systemconfig import SystemConfigOper
from app.db.oper.transferhistory import TransferHistoryOper
from app.db.oper.transferpending import TransferPendingOper
from app.db.oper.transfersettlementreceipt import TransferSettlementReceiptOper
from app.db.oper.user import UserOper
@@ -50,7 +49,6 @@ _OPER_MODULES = {
"SubscribeHistoryOper": "subscribehistory",
"SubscribeOper": "subscribe",
"SystemConfigOper": "systemconfig",
"TransferHistoryOper": "transferhistory",
"TransferPendingOper": "transferpending",
"TransferSettlementReceiptOper": "transfersettlementreceipt",
"UserConfigOper": "userconfig",
@@ -92,7 +90,6 @@ __all__ = [
"SubscribeHistoryOper",
"SubscribeOper",
"SystemConfigOper",
"TransferHistoryOper",
"TransferPendingOper",
"TransferSettlementReceiptOper",
"UserConfigOper",

View File

@@ -414,6 +414,17 @@ class TransferHistoryOper(DbOper):
)
)
async def async_stage_delete(self, historyid: int) -> None:
"""在调用方异步事务内暂存旧整理记录删除。"""
if not isinstance(self._db, AsyncSession):
raise RuntimeError("整理历史异步删除需要调用方提供异步 Session")
await self._db.execute(
sqlalchemy_delete(TransferHistory).where(
TransferHistory.id == historyid,
TransferHistory.transfer_task_id.is_(None),
)
)
def stage_truncate(self) -> None:
"""暂存旧整理记录删除,只保留当前失败任务历史。"""
self._db.execute(
@@ -449,49 +460,13 @@ class TransferHistoryOper(DbOper):
)
)
def add_force(self, **kwargs) -> Optional[TransferHistory]:
"""
新增转移历史,并以同源存储的记录为准替换旧记录。
"""
# 文件项的默认存储是 local;归一化旧调用传入的 None,确保运行时语义与
# (src, src_storage) 唯一索引一致。
kwargs["src_storage"] = kwargs.get("src_storage") or "local"
# 旧记录的清理交给 replace_by_src 按 (src, src_storage) 处理:
# 仅按 src 删除会连带删掉其他存储下同路径的记录。
kwargs.update({
"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
})
def stage(session: Session) -> Optional[TransferHistory]:
"""在同一事务替换记录并返回兼容查询投影。"""
TransferHistory.replace_by_src(session, **kwargs)
return TransferHistory.get_by_src(
session,
kwargs.get("src"),
kwargs["src_storage"],
)
# 保持 add_force 的既有返回契约:返回可被调用方安全读取字段的查询结果,
# 而非事务提交后可能已脱离会话的新建实例。
return self._execute_sync_write(stage)
def stage_replace_by_src(self, **kwargs) -> TransferHistory:
"""在调用方事务内按源路径替换整理历史并返回已分配 ID 的新记录。"""
if not isinstance(self._db, Session):
raise RuntimeError("整理历史事务写入需要调用方提供同步 Session")
kwargs["src_storage"] = kwargs.get("src_storage") or "local"
kwargs["date"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
self._db.execute(
sqlalchemy_delete(TransferHistory).where(
TransferHistory.src == kwargs.get("src"),
TransferHistory.src_storage == kwargs["src_storage"],
TransferHistory.transfer_task_id.is_(None),
)
)
self._db.flush()
history = TransferHistory(**kwargs)
self._db.add(history)
self._db.flush()
return history
return TransferHistory.replace_by_src(self._db, **kwargs)
def stage_upsert_by_transfer_task_id(
self,
@@ -546,6 +521,20 @@ class TransferHistoryOper(DbOper):
)
)
def stage_update_download_hash(
self,
historyid: int,
download_hash: str,
) -> None:
"""在调用方事务内暂存整理历史下载任务 Hash 更新。"""
if not isinstance(self._db, Session):
raise RuntimeError("整理历史同步更新需要调用方提供同步 Session")
TransferHistory.update_download_hash(
self._db,
historyid,
download_hash,
)
def list_by_date(self, date: str) -> List[TransferHistory]:
"""
查询某时间之后的转移历史

View File

@@ -4,23 +4,23 @@ from pathlib import Path
from threading import Lock
from typing import Any, Dict, List, Optional, Tuple
from app.chain.transfer import TransferChain
from app.runtime.cache import TTLCache
from app.adapters.system.fsproxy import fsproxy
from app.application.directory import DirectoryHelper
from app.application.history import (
HistoryGateAction,
describe_history_gate,
evaluate_history_gate,
get_transfer_history_port,
get_transfer_history_repository,
is_skip_action,
max_failed_retries,
resolve_history,
)
from app.chain.transfer import TransferChain
from app.runtime.cache import TTLCache
from app.runtime.log import logger
from app.adapters.system.fsproxy import fsproxy
from app.schemas.workflow import FileItem
from app.schemas.types import MediaType
from app.runtime.settings import get_runtime_setting
from app.schemas.types import MediaType
from app.schemas.workflow import FileItem
class TransferDispatcher:
@@ -111,7 +111,7 @@ class TransferDispatcher:
"""
try:
history = resolve_history(src_path, storage=storage,
transfer_history_oper=get_transfer_history_port())
transfer_history_oper=get_transfer_history_repository())
except Exception as err:
logger.error(f"查询整理历史失败: {src_path} - {err}")
return None

View File

@@ -775,6 +775,11 @@ _MESSAGE_NOTIFICATION_SYMBOL_ALIASES: Dict[str, SymbolAlias] = {
SYMBOL_ALIASES: Dict[str, Dict[str, SymbolAlias]] = {
"app.db.oper": {
"TransferHistoryOper": SymbolAlias(
target_module="app.sdk._legacy.history",
target_name="TransferHistoryOper",
replacement="app.application.history.TransferHistoryRepository",
),
"WorkflowOper": SymbolAlias(
target_module="app.sdk._legacy.workflow",
target_name="WorkflowOper",

View File

@@ -1,15 +1,43 @@
"""把旧整理历史 Oper 的业务写入方法转交给应用服务。"""
from typing import Any, Optional
from typing import Any, Optional, cast
from app.application.history import add_transfer_fail, add_transfer_success
from sqlalchemy.orm import Session
from app.application.history import (
TransferHistoryReplacePort,
TransferHistorySnapshot,
TransferHistoryWrite,
add_transfer_fail,
add_transfer_success,
)
from app.db.adapters.history.transfer import (
SessionTransferHistoryRepository,
TransactionalTransferHistoryRepository,
)
from app.db.oper.transferhistory import TransferHistoryOper as CanonicalTransferHistoryOper
from app.db.session import SessionFactory, async_session_scope
from app.domain.context import MediaInfo, MusicInfo
from app.domain.meta.metabase import MetaBase
from app.schemas.file import FileItem
from app.schemas.transfer import TransferInfo
class _LegacyTransferHistoryStager:
"""把旧 Oper 写入口适配到 Application 类型化暂存合同。"""
def __init__(self, owner: "TransferHistoryOper") -> None:
"""保存兼容 Oper。"""
self._owner = owner
def replace(self, history: TransferHistoryWrite) -> TransferHistorySnapshot:
"""把类型化写入转回旧 add_force ABI。"""
return cast(
TransferHistorySnapshot,
self._owner.add_force(**history.to_payload()),
)
class TransferHistoryOper(CanonicalTransferHistoryOper):
"""继承新的查询接口,并保留旧的成功/失败历史写入方法。"""
@@ -36,7 +64,7 @@ class TransferHistoryOper(CanonicalTransferHistoryOper):
transferinfo=transferinfo,
downloader=downloader,
download_hash=download_hash,
transfer_history_oper=self,
transfer_history_oper=_LegacyTransferHistoryStager(self),
)
def add_fail(
@@ -62,8 +90,25 @@ class TransferHistoryOper(CanonicalTransferHistoryOper):
transferinfo=transferinfo,
downloader=downloader,
download_hash=download_hash,
transfer_history_oper=self,
transfer_history_oper=_LegacyTransferHistoryStager(self),
)
def add_force( # type: ignore[no-untyped-def]
self,
**kwargs,
) -> Optional[TransferHistorySnapshot]:
"""按旧动态字段签名替换同源历史,并返回脱离 Session 的快照。"""
payload = dict(kwargs)
payload.pop("date", None)
repository: TransferHistoryReplacePort
if isinstance(getattr(self, "_db", None), Session):
repository = SessionTransferHistoryRepository(self._db)
else:
repository = TransactionalTransferHistoryRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
)
return repository.replace(TransferHistoryWrite(**payload))
__all__ = ["TransferHistoryOper"]

View File

@@ -5,6 +5,10 @@ from dataclasses import dataclass, field
from typing import Protocol
from app.application.configuration import RuntimeConfiguration, RuntimeSettingsService
from app.application.history import (
TransferHistoryMutationRepository,
TransferHistoryRepository,
)
from app.application.messaging.chat import (
AgentChatPersistenceService,
AsyncAgentChatRepository,
@@ -92,6 +96,14 @@ class RepositoryFactory(Protocol):
...
class TransferHistoryRepositoryFactory(Protocol):
"""由请求 Session 构造整理历史事务仓储的工厂。"""
def __call__(self, session: object) -> TransferHistoryMutationRepository:
"""绑定请求会话并返回整理历史暂存仓储。"""
...
class StandaloneRepositoryFactory(Protocol):
"""构造自持有兼容事务边界的领域仓储。"""
@@ -150,7 +162,8 @@ class HistoryRuntime:
"""下载、整理、媒体服务器与 Dashboard 领域的数据工厂。"""
download_repository: RepositoryFactory
transfer_repository: RepositoryFactory
transfer_repository: TransferHistoryRepository
transfer_mutation_repository: TransferHistoryRepositoryFactory
media_server_repository: RepositoryFactory

View File

@@ -61,7 +61,7 @@ from app.application.configuration import (
get_configured_system_config,
)
from app.application.database import configure_database_governance
from app.application.history import configure_transfer_history_provider
from app.application.history import configure_transfer_history_repository
from app.application.image import configure_wallpaper_providers
from app.application.messaging.agent import (
dispatch_web_agent_message_event,
@@ -125,6 +125,10 @@ from app.db.adapters.history.download import (
SessionDownloadHistoryRepository,
TransactionalDownloadHistoryRepository,
)
from app.db.adapters.history.transfer import (
SessionTransferHistoryRepository,
TransactionalTransferHistoryRepository,
)
from app.db.adapters.mediaserver import TransactionalMediaServerRepository
from app.db.adapters.outbox import (
SqlAlchemyAsyncOutboxDispatchStore,
@@ -157,7 +161,6 @@ from app.db.oper.site import SiteOper
from app.db.oper.subscribe import SubscribeOper
from app.db.oper.subscribehistory import SubscribeHistoryOper
from app.db.oper.systemconfig import SystemConfigOper
from app.db.oper.transferhistory import TransferHistoryOper
from app.db.oper.workflow import WorkflowOper
from app.db.session import (
SessionFactory,
@@ -855,7 +858,6 @@ async def init_modules() -> HostRuntime:
"site": SiteOper,
"subscribe": SubscribeOper,
"subscribe_history": SubscribeHistoryOper,
"transfer_history": TransferHistoryOper,
"user": SqlAlchemyUserRepository,
"workflow": WorkflowOper,
},
@@ -886,6 +888,10 @@ async def init_modules() -> HostRuntime:
sync_session=SessionFactory,
async_session=async_session_scope,
)
transfer_history_repository = TransactionalTransferHistoryRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
)
agent_chat_persistence = AgentChatPersistenceService(
repository=lambda session: AgentChatOper(session),
async_executor=database_worker,
@@ -914,7 +920,8 @@ async def init_modules() -> HostRuntime:
messaging=MessagingRuntime(repository=MessageOper),
history=HistoryRuntime(
download_repository=SessionDownloadHistoryRepository,
transfer_repository=TransferHistoryOper,
transfer_repository=transfer_history_repository,
transfer_mutation_repository=SessionTransferHistoryRepository,
media_server_repository=MediaServerOper,
),
site=SiteRuntime(repository=SiteOper),
@@ -954,7 +961,7 @@ async def init_modules() -> HostRuntime:
),
subscribe=lambda: SubscribeOper(),
download_history=lambda: download_history_repository,
transfer_history=lambda: TransferHistoryOper(),
transfer_history=lambda: transfer_history_repository,
transfer_pending=lambda: TransactionalTransferAdmissionRepository(
SessionFactory
),
@@ -998,7 +1005,7 @@ async def init_modules() -> HostRuntime:
)
)
configure_passkey_service(PasskeyService(repository=PassKeyOper()))
configure_transfer_history_provider(lambda: TransferHistoryOper())
configure_transfer_history_repository(lambda: transfer_history_repository)
configure_site_query_service(SiteQueryService(repository=TransactionalSiteRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
@@ -1017,7 +1024,7 @@ async def init_modules() -> HostRuntime:
),
subscribe=lambda: SubscribeOper(),
subscribe_history=lambda: SubscribeHistoryOper(),
transfer_history=lambda: TransferHistoryOper(),
transfer_history=lambda: transfer_history_repository,
download_history=lambda: download_history_repository,
plugin_data=lambda: PluginDataOper(),
)

View File

@@ -84,6 +84,9 @@ class TransferFileAction(BaseAction):
if not fileitem:
logger.info(f"文件 {download.path} 不存在")
continue
if not fileitem.path:
logger.warn("工作流文件缺少可查询的源路径,跳过")
continue
transferd = transferhis.get_by_src(fileitem.path, storage=fileitem.storage)
if transferd:
# 已经整理过的文件不再整理
@@ -102,6 +105,9 @@ class TransferFileAction(BaseAction):
for fileitem in copy.deepcopy(context.fileitems):
if not check_continue():
break
if not fileitem.path:
logger.warn("工作流文件缺少可查询的源路径,跳过")
continue
# 检查缓存
cache_key = f"{fileitem.path}"
if self.check_cache(workflow_id, cache_key):

View File

@@ -69,7 +69,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
| 指标 | 当前值 | 解释 |
|---|---:|---|
| 宿主 Python 模块 / 内部依赖边 | 856 / 7,006 | `dependency-baseline.json` 当前快照 |
| 宿主 Python 模块 / 内部依赖边 | 857 / 7,024 | `dependency-baseline.json` 当前快照 |
| 非平凡 SCC | 2 | 新增 Chain 包根环;另一个是隔离的 29 模块 TMDB 移植包环 |
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
@@ -77,9 +77,9 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
| Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement |
| Python 源码量 | 约 271,400 行 | 60 个文件超过 1,000 行,14 个超过 2,000 行 |
| 长方法 | 281 个超过 80 行 | 67 个超过 150 行,23 个超过 250 行;大量是私有方法 |
| 全量 mypy 历史债务 | 11,808 / 596 文件 | strict frontier 当前覆盖 41 个文件,本批迁移路径的类型债务已清零 |
| Ruff 历史诊断 | 840 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| 覆盖率低水位 | Application 79.39%,Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
| 全量 mypy 历史债务 | 11,763 / 596 文件 | strict frontier 当前覆盖 41 个文件,本批迁移路径的类型债务已清零 |
| Ruff 历史诊断 | 834 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| 覆盖率低水位 | Application 79.47%,Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
### 3.3 热点文件
@@ -268,11 +268,11 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
**问题与证据**
- `app/application/chain/data.py` 的 Site/Subscribe/TransferHistory factory 仍是 `Any`;
- `app/application/chain/data.py` 的 Site/Subscribe factory 仍是 `Any`;
`app/application/agentdata.py` 仍通过 `__dict__.update()` 动态组装未迁移端口。
- `app/startup/initializers/modules.py` 仍向生产 Chain/Agent 注入部分无 Session Oper。
- 无 Session Oper 会为单次调用独立创建事务;一个业务操作的“查询后更新”可能被拆成多个事务。
- Workflow、User 和 DownloadHistory 已迁入冻结 DTO 与 adapter-owned Session 投影;
- Workflow、User、DownloadHistory 和 TransferHistory 已迁入冻结 DTO 与 adapter-owned Session 投影;
剩余 Chain/Agent raw data port 仍可返回 `Any`/ORM,Subscription mutation 内部也消费 ORM,
因而尚有 Session 生命周期外 detached/lazy-load 风险。
- S1-L2 由 `b4f873654`、`a01a35bcb` 交付;精确 head SHA 的 Unit Tests `33098869736` 与
@@ -295,8 +295,9 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
- [x] User Chain/Agent/认证查询改用冻结 `UserSnapshot`/`UserAuthSnapshot`;创建、更名和
删除由请求级 UoW 原子提交,用户名唯一约束、最后一个启用超级管理员保护与
UserConfig/PassKey 级联约束共同守住身份聚合。
- [x] DownloadHistory 查询和写入改用冻结 DTO、typed Port 与短 Session adapter,删除在单一
UoW 中处理;TransferHistory 仍是 raw port,History 整体仍在执行中。
- [x] DownloadHistory 与 TransferHistory 查询和写入改用深度冻结 DTO、typed Port 与短
Session adapter;请求级删除和 durable 结算在各自单一 UoW 中处理,canonical 调用方不再
接收 raw Oper/ORM,旧动态写入只保留在 SDK Legacy/Compat。
- [ ] `ChainDataPorts`/`AgentDataPorts` 可暂时保留为兼容聚合器,但字段必须显式、可类型检查。
- [ ] 以一个业务纵切面迁移并验证后,再迁移下一组,禁止一次替换所有 Oper。
- [ ] 增加 AST 门禁,禁止向 `ChainDataPorts`、`AgentDataPorts` 和新的 canonical use-case service
@@ -304,10 +305,10 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
**后续顺序**
1. 完成 TransferHistory,收口 History 剩余半边。
2. Chain/Agent 的 Subscribe/Site raw port。
1. Chain/Agent 的 Site raw port。
2. Chain/Workflow/interaction 的 Subscription raw port。
3. Subscription mutation 与站点、规则组引用更新。
4. Agent 数据能力。
4. Agent/Transfer locator gate。
**验收**

View File

@@ -705,7 +705,7 @@ flowchart LR
| 指标 | 当前值 |
|---|---:|
| Python 模块 | 856 |
| 内部导入边 | 7,006 |
| 内部导入边 | 7,024 |
| 非平凡 SCC | 2(`ARCH-107` 临时 Chain 包根环;精确 containment 的 TMDB 移植包环) |
| Direct egress | 66(12 条待迁移债务,54 条精确 containment) |
| Module Contract V2 spec | 217(其中 215 个进入 `run_module` 观察面) |

View File

@@ -104,7 +104,7 @@ canonical 主程序;兼容只经统一 Compat/SDK 门面提供。
| 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 | `DELIVERED` | S1-L3.2 | `5fb62108a`:两组 raw factory 已替换为冻结 DTO/typed Port;失败冷却在 Session 内投影,媒体库查询只返回标量且每个 upsert/cleanup 独立短事务,远端枚举不持有 Session;旧 Oper 与插件可见 Chain ABI 保持不变;Unit Tests `33127544925`、Pylint `33127544927` 全绿,Application 覆盖率低水位提升至 `78.95%` |
| S1-L3.4 User | `VERIFIED` | S1-L3.3 | User Chain/Agent/认证改用冻结 typed snapshot;创建、更名、删除与最后一个启用超级管理员保护归并到单 UoW;用户名唯一索引及 UserConfig/PassKey 级联迁移落地,UserConfig 在 commit 后持写锁重载数据库事实源并发布内存快照 |
| S1-L3.5 History | `IN_PROGRESS` | S1-L3.4 | DownloadHistory 已迁入冻结 DTO、typed query/write Port 与短 Session adapter,下载历史删除不再拆成 fail-open 双事务;TransferHistory 半边尚未完成,本项不宣称整体交付 |
| S1-L3.5 History | `VERIFIED` | S1-L3.4 | DownloadHistory 与 TransferHistory 均已迁入深度冻结 DTO、typed query/write/staging Port 与短 Session adapter;请求级删除和 durable 结算各自保持单一 UoW,canonical 调用方不再接收 raw Oper/ORM,旧写入 ABI 只由 SDK Legacy/Compat 提供 |
| S1-L3.6 Site | `PLANNED` | S1-L3.5 | 复用 Site query/health,补齐同步 typed command,Session 内完成 DTO 投影 |
| S1-L3.7 Subscription | `PLANNED` | S1-L3.6 | Chain/Workflow/interaction 全部消费 typed query/command;完成后进入 S1-L4 原子事务收口 |
| S1-L3.8 Agent/Transfer locator gate | `PLANNED` | S1-L3.7 | 删除 AgentDataPorts 与 Chain locator 跨层泄漏,AST 门禁确认 canonical 无 raw getter/Oper/Any |
@@ -153,7 +153,7 @@ canonical 主程序;兼容只经统一 Compat/SDK 门面提供。
| S4-L2 Event strict contract | `PLANNED` | S0-L2.6,S1-L6 | 宿主事件输入/输出按风险 strict,诊断例外只属于第三方插件兼容 |
| S4-L3 Complexity v2 | `PLANNED` | S3 | 私有方法、class/file、圈复杂度进入门禁;所有超限通过职责拆分归零 |
| S4-L4 全量 mypy 清零 | `PLANNED` | S3,S4-L1,S4-L2 | `mypy-baseline.json` 归零并删除债务接受路径,全宿主 strict 类型通过 |
| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 840 条诊断归零,规则集扩展经过独立审查且新增诊断为零 |
| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 834 条诊断归零,规则集扩展经过独立审查且新增诊断为零 |
| S4-L6 Coverage/并发/质量证据 | `PLANNED` | S3,S4-L1,S4-L2 | 高风险包纳入 coverage;raw concurrency 分类清零;Module Quality 有真实 evidence test |
### S5:Plugin、Agent、Domain、Startup 与最终收口

View File

@@ -64,7 +64,7 @@ to make the directory tree look symmetrical.
| `app/application/subscription/` | Subscription use cases: `write.py` owns media-to-row translation and the write port; `contract.py` owns shared metadata/media-key projection; query, mutation, deletion, identity and search stay in their single-word modules |
| `app/application/search/` | Search state and later search-plan use cases |
| `app/application/download/` | Download task querying/control and selection use cases; `failures.py` owns the frozen failure-cooldown write/query DTOs and persistence Port |
| `app/application/history.py` | History use cases and persistence contracts; DownloadHistory owns frozen query/write DTOs and typed ports, while TransferHistory remains a separate unfinished migration surface |
| `app/application/history.py` | History use cases and persistence contracts; DownloadHistory and TransferHistory own deeply frozen DTOs plus typed query/write/staging ports |
| `app/application/music/` | Multi-source music catalog orchestration |
| `app/application/chain/` | Injectable Chain runtime capabilities: `context.py` owns the runtime dependency aggregate, `data.py` owns named persistence ports, and `events.py` owns durable event write contracts plus replayable payload conversion |
| `app/application/agentdata.py` | Named Agent data ports; canonical Agent consumers use `get_agent_*_port()` and do not alias legacy proxies to Oper classes |
@@ -151,9 +151,9 @@ Agent orchestration, memory and tool implementations follow the same rule via
the named `get_agent_*_port()` functions from `app/application/agentdata.py`.
The legacy Agent `*Port` proxy classes remain import-compatible boundaries and
must not be reintroduced as Oper aliases in canonical Agent modules.
Monitor history checks use `get_transfer_history_port()` from
`app/application/history.py`; the constructible `TransferHistoryPort` facade is
retained only for compatibility and is not a canonical Oper substitute.
Monitor history checks use `get_transfer_history_repository()` from
`app/application/history.py`; old constructible Oper-style facades are available
only through the exact SDK Legacy/Compat mapping.
Durable transfer execution follows one explicit boundary. The Chain freezes each
external file operation into the Application-owned contract in
@@ -491,12 +491,14 @@ The configured user-configuration repository publishes its in-memory snapshot
only after the user transaction commits, and reloads from the database if
publication fails.
Download history is the verified half of the History migration:
`app/application/history.py` owns frozen `DownloadHistorySnapshot` /
`DownloadFileSnapshot` values and typed query/write ports;
`app/db/adapters/history/download.py` performs projection and mutations in short
Session scopes. TransferHistory still uses its existing ports, so this must not
be described as completion of the entire History boundary.
History is a verified typed boundary: `app/application/history.py` owns deeply
frozen DownloadHistory and TransferHistory snapshots plus typed query/write and
staging ports. `app/db/adapters/history/download.py` and
`app/db/adapters/history/transfer.py` perform ORM projection and mutations inside
short Session scopes; durable transfer settlement uses the staging port in its
caller-owned transaction. Canonical callers never receive history ORM rows or
raw Oper objects, while old plugin imports resolve only through SDK Legacy and
the exact Compat manifest.
Durable post-commit side effects have a separate boundary:
@@ -712,8 +714,9 @@ driven workflow registration.
| `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/history.py` | History use cases; frozen DownloadHistory DTOs and typed query/write ports, with TransferHistory migration still pending |
| `app/application/history.py` | History use cases; deeply frozen DownloadHistory/TransferHistory DTOs and typed query/write/staging ports |
| `app/db/adapters/history/download.py` | DownloadHistory short-session snapshot, query and mutation adapter |
| `app/db/adapters/history/transfer.py` | TransferHistory short-session snapshot/query/mutation adapter and caller-owned transaction stager |
| `app/application/security/user.py` | Frozen user/auth projections and atomic user aggregate service contracts |
| `app/db/adapters/user.py` | User projection plus request-UoW mutation adapter |
| `app/db/adapters/configuration.py` | Commit-after UserConfig snapshot publication and fact-source reload adapter |

View File

@@ -140,10 +140,11 @@ adapters; it does not retain reusable repository implementations.
cascades on user delete; `(UserConfig.username, UserConfig.key)` is unique and
non-null. A schema change to any of these constraints requires a replay-safe
migration that repairs legacy duplicates/orphans before creating constraints.
- DownloadHistory is projected into frozen DTOs within the adapter Session.
Its typed query/write port and delete mutation use short Session/UoW scopes.
TransferHistory has not completed the same migration and must be tracked
separately rather than treating the whole History area as typed.
- DownloadHistory and TransferHistory are projected into deeply frozen DTOs
within the adapter Session. Their typed query/write ports use short
Session/UoW scopes; TransferHistory additionally exposes a staging port for
durable settlement inside the caller-owned transaction. Canonical callers
must not receive raw history ORM rows or Oper objects.
### Durable post-commit side effects

View File

@@ -1,8 +1,8 @@
{
"application": {
"covered_lines": 10432,
"percent": 79.39,
"statements": 13141
"covered_lines": 10572,
"percent": 79.47,
"statements": 13303
},
"domain": {
"covered_lines": 3392,

View File

@@ -1435,8 +1435,8 @@
"runtime_only": true
}
},
"edge_count": 7006,
"edge_sha256": "74169f74212541ac2c1f990578f2e1d67f084944c08f1894c5fc1bff1d9016bd",
"edge_count": 7024,
"edge_sha256": "188c70617997f86402b34bf46a4963f6dc2a55f6102737e86528e120c58208c3",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -4012,6 +4012,8 @@
"app.application.configuration -> app.application.database",
"app.application.configuration -> app.schemas",
"app.application.configuration -> app.schemas.types",
"app.application.dashboard -> app.application",
"app.application.dashboard -> app.application.history",
"app.application.dashboard -> app.schemas",
"app.application.dashboard -> app.schemas.dashboard",
"app.application.directory -> app.adapters",
@@ -5140,6 +5142,8 @@
"app.db.adapters.chain -> app.application.transfer.execution",
"app.db.adapters.chain -> app.db",
"app.db.adapters.chain -> app.db.adapters",
"app.db.adapters.chain -> app.db.adapters.history",
"app.db.adapters.chain -> app.db.adapters.history.transfer",
"app.db.adapters.chain -> app.db.adapters.outbox",
"app.db.adapters.chain -> app.db.models",
"app.db.adapters.chain -> app.db.models.transfersettlementreceipt",
@@ -5173,6 +5177,15 @@
"app.db.adapters.history.download -> app.schemas",
"app.db.adapters.history.download -> app.schemas.media",
"app.db.adapters.history.download -> app.schemas.types",
"app.db.adapters.history.transfer -> app.application",
"app.db.adapters.history.transfer -> app.application.history",
"app.db.adapters.history.transfer -> app.db",
"app.db.adapters.history.transfer -> app.db.oper",
"app.db.adapters.history.transfer -> app.db.oper.transferhistory",
"app.db.adapters.history.transfer -> app.db.uow",
"app.db.adapters.history.transfer -> app.schemas",
"app.db.adapters.history.transfer -> app.schemas.media",
"app.db.adapters.history.transfer -> app.schemas.types",
"app.db.adapters.mediaserver -> app.application",
"app.db.adapters.mediaserver -> app.application.mediaserver",
"app.db.adapters.mediaserver -> app.db",
@@ -7757,8 +7770,12 @@
"app.sdk._legacy.history -> app.application",
"app.sdk._legacy.history -> app.application.history",
"app.sdk._legacy.history -> app.db",
"app.sdk._legacy.history -> app.db.adapters",
"app.sdk._legacy.history -> app.db.adapters.history",
"app.sdk._legacy.history -> app.db.adapters.history.transfer",
"app.sdk._legacy.history -> app.db.oper",
"app.sdk._legacy.history -> app.db.oper.transferhistory",
"app.sdk._legacy.history -> app.db.session",
"app.sdk._legacy.history -> app.domain",
"app.sdk._legacy.history -> app.domain.context",
"app.sdk._legacy.history -> app.domain.meta",
@@ -7906,6 +7923,7 @@
"app.startup.composition.configuration -> app.schemas.types",
"app.startup.composition.context -> app.application",
"app.startup.composition.context -> app.application.configuration",
"app.startup.composition.context -> app.application.history",
"app.startup.composition.context -> app.application.messaging",
"app.startup.composition.context -> app.application.messaging.chat",
"app.startup.composition.context -> app.application.outbox",
@@ -8077,6 +8095,7 @@
"app.startup.initializers.modules -> app.db.adapters.download",
"app.startup.initializers.modules -> app.db.adapters.history",
"app.startup.initializers.modules -> app.db.adapters.history.download",
"app.startup.initializers.modules -> app.db.adapters.history.transfer",
"app.startup.initializers.modules -> app.db.adapters.mediaserver",
"app.startup.initializers.modules -> app.db.adapters.outbox",
"app.startup.initializers.modules -> app.db.adapters.pluginidentity",
@@ -8101,7 +8120,6 @@
"app.startup.initializers.modules -> app.db.oper.subscribe",
"app.startup.initializers.modules -> app.db.oper.subscribehistory",
"app.startup.initializers.modules -> app.db.oper.systemconfig",
"app.startup.initializers.modules -> app.db.oper.transferhistory",
"app.startup.initializers.modules -> app.db.oper.workflow",
"app.startup.initializers.modules -> app.db.session",
"app.startup.initializers.modules -> app.db.uow",
@@ -8445,7 +8463,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 856,
"module_count": 857,
"modules": [
"app",
"app.adapters",
@@ -8843,6 +8861,7 @@
"app.db.adapters.download",
"app.db.adapters.history",
"app.db.adapters.history.download",
"app.db.adapters.history.transfer",
"app.db.adapters.mediaserver",
"app.db.adapters.outbox",
"app.db.adapters.pluginidentity",

View File

@@ -837,7 +837,7 @@
"redundant-cast": 1
},
"app/api/dependencies/history.py": {
"arg-type": 11,
"arg-type": 7,
"attr-defined": 1
},
"app/api/dependencies/site.py": {
@@ -1140,10 +1140,8 @@
"type-arg": 2
},
"app/application/history.py": {
"assignment": 2,
"attr-defined": 1,
"no-any-return": 1,
"type-arg": 5
"no-any-return": 1
},
"app/application/image.py": {
"arg-type": 1,
@@ -1380,7 +1378,7 @@
"type-arg": 4
},
"app/chain/_transfer.py": {
"arg-type": 9,
"arg-type": 8,
"assignment": 4,
"attr-defined": 55,
"no-any-return": 3,
@@ -1603,9 +1601,6 @@
"valid-type": 1,
"var-annotated": 1
},
"app/db/adapters/chain.py": {
"return-value": 1
},
"app/db/adapters/plugininstallation.py": {
"no-any-return": 1,
"redundant-cast": 2
@@ -1730,9 +1725,8 @@
"no-untyped-def": 4
},
"app/db/oper/transferhistory.py": {
"arg-type": 1,
"no-any-return": 2,
"no-untyped-def": 7,
"no-untyped-def": 6,
"union-attr": 2
},
"app/db/oper/user.py": {
@@ -3380,7 +3374,7 @@
"no-untyped-def": 2
},
"app/startup/initializers/modules.py": {
"arg-type": 13,
"arg-type": 12,
"assignment": 1,
"attr-defined": 2,
"misc": 1,

View File

@@ -834,9 +834,6 @@
"app/monitor/__init__.py": {
"I001": 1
},
"app/monitor/dispatcher.py": {
"I001": 1
},
"app/monitor/monitor.py": {
"I001": 1
},
@@ -1181,9 +1178,6 @@
"tests/test_db_lazy_engine.py": {
"I001": 1
},
"tests/test_db_oper_layer_extra.py": {
"I001": 1
},
"tests/test_db_public_api.py": {
"I001": 1
},
@@ -1366,10 +1360,6 @@
"tests/test_module_quality.py": {
"I001": 1
},
"tests/test_monitor_dispatcher_history.py": {
"F401": 1,
"I001": 1
},
"tests/test_monitor_gap_recovery.py": {
"I001": 1
},
@@ -1642,18 +1632,12 @@
"tests/test_transfer_history_retransfer.py": {
"I001": 1
},
"tests/test_transfer_history_write_path.py": {
"I001": 1
},
"tests/test_transfer_queue_count.py": {
"I001": 1
},
"tests/test_transfer_rename_build_event.py": {
"I001": 1
},
"tests/test_transferhistory_media_source_migration.py": {
"I001": 1
},
"tests/test_uvicorn_entrypoint.py": {
"I001": 1
},

View File

@@ -927,6 +927,11 @@
}
},
"app.db.oper": {
"TransferHistoryOper": {
"replacement": "app.application.history.TransferHistoryRepository",
"target_module": "app.sdk._legacy.history",
"target_name": "TransferHistoryOper"
},
"WorkflowOper": {
"replacement": "app.application.workflow.WorkflowExecutionPort",
"target_module": "app.sdk._legacy.workflow",

View File

@@ -24,6 +24,7 @@ from app.application.history import (
DownloadFileWrite,
DownloadHistoryWrite,
TransferHistoryMutationCommand,
TransferHistoryWrite,
)
from app.application.transfer.execution import (
TransferExecutionCheckpoint,
@@ -186,13 +187,13 @@ def _stage_result_history(
succeeded: bool,
src_path: str | None = None,
):
"""通过兼容历史端口暂存一条最小任务结算记录。"""
return repository.add_force(
"""通过类型化历史端口暂存一条最小任务结算记录。"""
return repository.replace(TransferHistoryWrite(
src=src_path or f"/downloads/{task_id}.mkv",
src_storage="local",
status=succeeded,
errmsg=None if succeeded else "目标文件校验失败",
)
))
def _assert_event_key(
@@ -416,11 +417,11 @@ def test_transfer_succeeds_when_history_id_is_reused_with_retained_outbox():
"""写入一条最小成功整理事实并完成即时事件。"""
return writer.transfer_result(
topic="transfer.completed",
stage_history=lambda repository: repository.add_force(
stage_history=lambda repository: repository.replace(TransferHistoryWrite(
src=src,
src_storage="local",
status=1,
),
status=True,
)),
event_payload={},
publish=lambda _payload: None,
)
@@ -460,15 +461,15 @@ def test_transfer_event_failure_leaves_committed_intent_pending():
def stage_history(repository):
"""通过应用历史端口名暂存一条成功整理记录。"""
return repository.add_force(
return repository.replace(TransferHistoryWrite(
src=fileitem.path,
src_storage=fileitem.storage,
src_fileitem=fileitem.model_dump(mode="json"),
dest=transferinfo.target_item.path,
dest_storage=transferinfo.target_item.storage,
dest_fileitem=transferinfo.target_item.model_dump(mode="json"),
status=1,
)
status=True,
))
def fail_publish(_payload):
"""模拟插件事件总线在业务提交后失败。"""

View File

@@ -9,13 +9,13 @@ import asyncio
import pytest
from app.db.oper.downloadfailure import DownloadFailureOper
from app.db.oper.message import MessageOper
from app.db.models.downloadfailure import DownloadFailure
from app.db.models.message import Message
from app.db.models.subscribe import Subscribe
from app.db.models.subscribehistory import SubscribeHistory
from app.db.models.transferhistory import TransferHistory
from app.db.oper.downloadfailure import DownloadFailureOper
from app.db.oper.message import MessageOper
from app.db.oper.subscribe import SubscribeOper
from app.db.oper.subscribehistory import SubscribeHistoryOper
from app.db.oper.transferhistory import TransferHistoryOper
@@ -93,14 +93,16 @@ def test_transferhistory_oper_identity_and_hash_lookups(db):
assert oper.statistic(days=36500)
def test_transferhistory_oper_add_force_replaces_same_source(db):
def test_transferhistory_oper_stage_replace_replaces_same_source(db):
"""
强制新增用同源新记录替换旧的,同一源路径只留一条。
"""
oper = TransferHistoryOper(db=db.session)
oper.add(**_transfer_kwargs("旧记录", "/data/op-force.mkv"))
created = oper.add_force(**_transfer_kwargs("新记录", "/data/op-force.mkv"))
created = oper.stage_replace_by_src(
**_transfer_kwargs("新记录", "/data/op-force.mkv")
)
assert created.title == "新记录"
assert [h.title for h in oper.list_success_by_src("/data/op-force.mkv")] == ["新记录"]

View File

@@ -155,6 +155,7 @@ def _runtime() -> HostRuntime:
history=HistoryRuntime(
download_repository=_Repository,
transfer_repository=_Repository,
transfer_mutation_repository=_Repository,
media_server_repository=_Repository,
),
site=SiteRuntime(repository=_Repository),

View File

@@ -190,7 +190,10 @@ def test_legacy_transfer_history_writes_delegate_to_application_service(
monkeypatch.setattr(legacy, service_name, fake_service)
assert getattr(oper, method_name)(**arguments) == "history"
assert captured == {**arguments, "transfer_history_oper": oper}
stager = captured.pop("transfer_history_oper")
assert captured == arguments
assert stager._owner is oper
assert callable(stager.replace)
def test_legacy_transfer_history_mutations_preserve_durable_receipts(db):

View File

@@ -482,7 +482,10 @@ def test_plugin_scan_reports_moved_symbol_import(tmp_path: Path):
def test_symbol_alias_manifest_covers_all_moved_public_symbols():
"""符号级映射清单应覆盖媒体身份、整理工作项、刮削拆分与消息/通知命名统一的旧入口。"""
assert set(SYMBOL_ALIASES["app.db.oper"]) == {"WorkflowOper"}
assert set(SYMBOL_ALIASES["app.db.oper"]) == {
"TransferHistoryOper",
"WorkflowOper",
}
assert set(SYMBOL_ALIASES["app.workflow"]) == {"WorkFlowManager"}
assert set(SYMBOL_ALIASES["app.domain.media"]) == {
"MEDIA_SOURCE_ALIASES",

View File

@@ -7,13 +7,15 @@ from app.api.endpoints.transfer import (
query_manual_transfer_history,
)
from app.application.history import (
TransferHistoryWrite,
clear_transfer_failures,
failed_retry_count,
max_failed_retries,
record_transfer_failure,
)
from app.chain.transfer import TransferChain
from app.db.oper.transferhistory import TransferHistoryOper
from app.db.adapters.history.transfer import TransactionalTransferHistoryRepository
from app.db.session import SessionFactory, async_session_scope
from app.runtime.config import settings
from app.schemas.transfer import ManualTransferItem
from tests.test_transfer_sync_extra_files import (
@@ -28,6 +30,14 @@ def _reset_failed_retries(src_path, storage=None):
clear_transfer_failures(src_path, storage)
def _history_repository() -> TransactionalTransferHistoryRepository:
"""构造类型化整理历史仓储。"""
return TransactionalTransferHistoryRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
)
def _patch_transfer_planning(monkeypatch, chain, fileitem, history, planned, deleted):
"""隔离整理规划依赖,并记录历史及旧目标清理动作。"""
monkeypatch.setattr(
@@ -207,23 +217,23 @@ def test_history_endpoint_reorganize_uses_chain_cleanup(monkeypatch):
def test_success_history_directory_query_excludes_failed_and_siblings():
"""目录历史查询应限定路径边界,并且只返回成功记录。"""
transfer_history_oper = TransferHistoryOper()
transfer_history_oper = _history_repository()
created_histories = [
transfer_history_oper.add_force(
transfer_history_oper.replace(TransferHistoryWrite(
src="/issue-6191/show/episode-1.mkv",
src_storage="local",
status=True,
),
transfer_history_oper.add_force(
)),
transfer_history_oper.replace(TransferHistoryWrite(
src="/issue-6191/show/episode-2.mkv",
src_storage="local",
status=False,
),
transfer_history_oper.add_force(
)),
transfer_history_oper.replace(TransferHistoryWrite(
src="/issue-6191/show-other/episode-3.mkv",
src_storage="local",
status=True,
),
)),
]
try:
histories = transfer_history_oper.list_success_by_src(
@@ -242,18 +252,18 @@ def test_success_history_directory_query_excludes_failed_and_siblings():
def test_success_history_directory_query_escapes_sql_wildcards():
"""目录名中的 SQL 通配符应按普通字符匹配,不能扩大查询范围。"""
transfer_history_oper = TransferHistoryOper()
transfer_history_oper = _history_repository()
created_histories = [
transfer_history_oper.add_force(
transfer_history_oper.replace(TransferHistoryWrite(
src="/issue-6191/show_100%/episode-1.mkv",
src_storage="local",
status=True,
),
transfer_history_oper.add_force(
)),
transfer_history_oper.replace(TransferHistoryWrite(
src="/issue-6191/showA100B/episode-2.mkv",
src_storage="local",
status=True,
),
)),
]
try:
histories = transfer_history_oper.list_success_by_src(
@@ -272,9 +282,9 @@ def test_success_history_directory_query_escapes_sql_wildcards():
def test_successful_move_history_is_found_by_current_destination():
"""成功移动后从媒体库现址打开整理界面时,应识别原整理历史。"""
transfer_history_oper = TransferHistoryOper()
transfer_history_oper = _history_repository()
destination = make_fileitem("/library/Test Show/Test.Show.S01E01.mkv")
history = transfer_history_oper.add_force(
history = transfer_history_oper.replace(TransferHistoryWrite(
src="/downloads/Test.Show.S01E01.mkv",
src_storage="local",
dest=destination.path,
@@ -282,7 +292,7 @@ def test_successful_move_history_is_found_by_current_destination():
dest_fileitem=destination.model_dump(),
mode="move",
status=True,
)
))
try:
histories = TransferChain.get_manual_transfer_histories(
make_transfer_chain(),

View File

@@ -3,13 +3,12 @@ from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
from app.runtime.config import settings
from app.application.history import (
clear_transfer_failures,
failed_retry_count,
record_transfer_failure,
)
from app.monitor.dispatcher import TransferDispatcher
from app.runtime.config import settings
def _build_dispatcher() -> TransferDispatcher:
@@ -54,7 +53,10 @@ def _patch_history(monkeypatch, record=None, success_record=None) -> MagicMock:
oper = MagicMock()
oper.get_by_src.return_value = record
oper.get_success_by_src.return_value = success_record
monkeypatch.setattr("app.monitor.dispatcher.get_transfer_history_port", MagicMock(return_value=oper))
monkeypatch.setattr(
"app.monitor.dispatcher.get_transfer_history_repository",
MagicMock(return_value=oper),
)
return oper
@@ -229,7 +231,10 @@ def test_history_query_error_registers_pending(monkeypatch):
dispatcher = _build_dispatcher()
oper = MagicMock()
oper.get_by_src.side_effect = RuntimeError("数据库不可用")
monkeypatch.setattr("app.monitor.dispatcher.get_transfer_history_port", MagicMock(return_value=oper))
monkeypatch.setattr(
"app.monitor.dispatcher.get_transfer_history_repository",
MagicMock(return_value=oper),
)
chain = _patch_chain(monkeypatch)
assert dispatcher.handle_file(storage="local", event_path=Path("/downloads/a.mkv"), file_size=100) is False

View File

@@ -4,6 +4,7 @@ from unittest.mock import MagicMock
from watchfiles import Change
from app.application.history import TransferHistorySnapshot
from app.monitor.dispatcher import TransferDispatcher
from app.monitor.monitor import Monitor
from app.monitor.watcher import DirectoryChangeEvent, LocalDirectoryWatcher
@@ -257,12 +258,25 @@ def test_handle_file_skips_transfer_when_history_exists(monkeypatch):
记录查询参数并返回已有记录。
"""
lookups.append((src, storage))
return object()
return TransferHistorySnapshot(
id=1,
src=src,
src_storage=storage,
src_fileitem={"size": 1024},
status=True,
)
def get_success_by_src(self, src: str, storage: str = None):
"""成功记录已由首次查询返回,无需二次回退。"""
return None
transfer_chain = MagicMock()
logger_info = MagicMock()
logger_debug = MagicMock()
monkeypatch.setattr("app.monitor.dispatcher.get_transfer_history_port", FakeTransferHistoryOper)
monkeypatch.setattr(
"app.monitor.dispatcher.get_transfer_history_repository",
FakeTransferHistoryOper,
)
monkeypatch.setattr("app.monitor.dispatcher.TransferChain", transfer_chain)
monkeypatch.setattr("app.monitor.dispatcher.logger.info", logger_info)
monkeypatch.setattr("app.monitor.dispatcher.logger.debug", logger_debug)
@@ -277,7 +291,7 @@ def test_handle_file_skips_transfer_when_history_exists(monkeypatch):
assert lookups == [(event_path.as_posix(), "local")]
transfer_chain.assert_not_called()
logger_info.assert_not_called()
logger_debug.assert_not_called()
assert "已整理过且文件未变化" in logger_debug.call_args.args[0]
def test_handle_file_invokes_transfer_when_history_missing(monkeypatch):
@@ -298,9 +312,16 @@ def test_handle_file_invokes_transfer_when_history_missing(monkeypatch):
"""
return None
def get_success_by_src(self, src: str, storage: str = None):
"""空仓储没有成功历史。"""
return None
transfer_chain_instance = MagicMock()
transfer_chain = MagicMock(return_value=transfer_chain_instance)
monkeypatch.setattr("app.monitor.dispatcher.get_transfer_history_port", FakeTransferHistoryOper)
monkeypatch.setattr(
"app.monitor.dispatcher.get_transfer_history_repository",
FakeTransferHistoryOper,
)
monkeypatch.setattr("app.monitor.dispatcher.TransferChain", transfer_chain)
handled = dispatcher.handle_file(

View File

@@ -0,0 +1,236 @@
"""整理历史类型化适配器的投影与事务测试。"""
import asyncio
import pytest
from app.application.history import (
TransferHistoryMonthlyStatistics,
TransferHistorySnapshot,
TransferHistoryStatisticSnapshot,
TransferHistoryWrite,
)
from app.db.adapters.history.transfer import (
SessionTransferHistoryRepository,
TransactionalTransferHistoryRepository,
)
from app.db.models.transferhistory import TransferHistory
from app.db.session import SessionFactory, async_session_scope
from app.db.uow import SqlAlchemyUnitOfWork
from app.schemas.types import MediaSource, MediaType
def _repository() -> TransactionalTransferHistoryRepository:
"""构造绑定测试数据库短 Session 的整理历史仓储。"""
return TransactionalTransferHistoryRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
)
def _history_write(
*,
src: str = "/downloads/Typed.Show.S01/Episode01.mkv",
status: bool = True,
) -> TransferHistoryWrite:
"""构造覆盖整理、历史和 Agent 消费字段的类型化写入。"""
return TransferHistoryWrite(
src=src,
src_storage="local",
src_fileitem={
"path": src,
"size": 1024,
"nested": {"parts": [1, 2]},
},
dest="/media/Typed Show (2026)/Season 01/Episode01.mkv",
dest_storage="local",
dest_fileitem={"path": "/media/Typed Show (2026)/Season 01/Episode01.mkv"},
mode="copy",
type=MediaType.TV.value,
category="剧集",
title="Typed Show",
year="2026",
media_source=MediaSource.TMDB,
media_id="7001",
seasons="S01",
episodes="E01",
image="https://example.test/poster.jpg",
downloader="qb",
download_hash="typed-transfer-hash",
status=status,
errmsg=None if status else "transfer failed",
files=["Episode01.mkv"],
episode_group="group-1",
)
def test_transactional_repository_projects_detached_snapshots(db) -> None:
"""同步查询应在 Session 内完成投影,并冻结所有嵌套 JSON。"""
repository = _repository()
created = repository.replace(_history_write())
by_id = repository.get(created.id)
by_src = repository.get_by_src(created.src or "", "local")
successful = repository.get_success_by_src(created.src or "", "local")
by_dest = repository.get_by_dest(created.dest or "", "local")
by_identity = repository.get_by_media_identity(
MediaSource.TMDB,
"7001",
MediaType.TV.value,
)
by_hash = repository.list_by_hash("typed-transfer-hash")
assert isinstance(created, TransferHistorySnapshot)
assert by_id == created
assert by_src == created
assert successful == created
assert by_dest == created
assert by_identity == created
assert by_hash == [created]
assert created.media_source == MediaSource.TMDB
assert not hasattr(created, "_sa_instance_state")
assert isinstance(created.src_fileitem, dict)
with pytest.raises(TypeError, match="不可修改"):
created.src_fileitem["size"] = 2048
nested = created.src_fileitem["nested"]
assert isinstance(nested, dict)
parts = nested["parts"]
assert isinstance(parts, list)
with pytest.raises(TypeError, match="不可修改"):
parts.append(3)
refreshed = repository.get(created.id)
assert refreshed is not None
assert refreshed.src_fileitem == {
"path": created.src,
"size": 1024,
"nested": {"parts": [1, 2]},
}
def test_transactional_repository_rolls_back_replace_on_commit_failure(
db,
monkeypatch,
) -> None:
"""替换提交失败时应恢复旧记录,不留下未提交的新投影。"""
repository = _repository()
original = repository.replace(_history_write())
def fail_commit(_unit_of_work) -> None:
"""模拟数据库提交失败。"""
raise RuntimeError("commit failed")
monkeypatch.setattr(SqlAlchemyUnitOfWork, "commit", fail_commit)
with pytest.raises(RuntimeError, match="commit failed"):
repository.replace(
_history_write(status=False),
)
current = repository.get_by_src(original.src or "", "local")
assert current == original
def test_transactional_repository_sync_mutations_use_committed_uow(db) -> None:
"""Hash 更新、普通删除和清空应分别由独立事务完整提交。"""
repository = _repository()
first = repository.replace(_history_write(src="/downloads/first.mkv"))
second = repository.replace(_history_write(src="/downloads/second.mkv"))
repository.update_download_hash(first.id, "updated-hash")
updated = repository.get(first.id)
assert updated is not None
assert updated.download_hash == "updated-hash"
repository.delete(first.id)
assert repository.get(first.id) is None
assert repository.get(second.id) is not None
repository.truncate()
assert repository.get(second.id) is None
def test_transactional_repository_preserves_durable_history(db) -> None:
"""普通删除和清空不得破坏 durable 任务恢复证据。"""
durable = db.add(
TransferHistory(
transfer_task_id="task-typed-history",
transfer_settlement_revision=1,
src="/downloads/durable.mkv",
src_storage="local",
status=False,
)
)
repository = _repository()
repository.delete(durable.id)
repository.truncate()
preserved = repository.get_by_transfer_task_id(task_id="task-typed-history")
assert preserved is not None
assert preserved.id == durable.id
def test_transactional_repository_async_queries_and_delete(db) -> None:
"""异步分页与计数返回快照,删除由独立异步事务提交。"""
repository = _repository()
created = repository.replace(_history_write(src="/downloads/async.mkv"))
async def exercise() -> tuple[
list[TransferHistorySnapshot],
int,
int,
list[TransferHistoryStatisticSnapshot],
]:
"""在同一事件循环执行全部异步查询和删除。"""
by_id = await repository.async_get(created.id)
assert by_id == created
by_title = await repository.async_list_by_title("Typed", count=10)
by_page = await repository.async_list_by_page(count=10)
total = await repository.async_count()
matching = await repository.async_count_by_title("Typed")
statistics = await repository.async_statistic()
assert by_title == [created]
await repository.async_delete(created.id)
return by_page, total, matching, statistics
records, total, matching, statistics = asyncio.run(exercise())
assert created in records
assert total >= 1
assert matching >= 1
assert all(isinstance(item, TransferHistoryStatisticSnapshot) for item in statistics)
assert sum(item.count for item in statistics) >= 1
assert all(not hasattr(record, "_sa_instance_state") for record in records)
assert repository.get(created.id) is None
def test_transactional_repository_returns_typed_monthly_statistics(db) -> None:
"""月度聚合应返回命名 DTO,不向 Application 泄漏位置元组。"""
repository = _repository()
repository.replace(_history_write(src="/downloads/monthly.mkv"))
statistics = repository.monthly_media_statistics()
assert isinstance(statistics, TransferHistoryMonthlyStatistics)
assert statistics.tv_shows >= 1
assert statistics.episodes >= 1
def test_session_repository_obeys_request_uow(db) -> None:
"""请求级 adapter 只暂存删除,提交或回滚由请求 UoW 决定。"""
repository = _repository()
created = repository.replace(_history_write(src="/downloads/request.mkv"))
with SessionFactory() as session:
request_repository = SessionTransferHistoryRepository(session)
assert request_repository.get(created.id) == created
request_repository.stage_delete(created.id)
session.rollback()
assert repository.get(created.id) is not None
with SessionFactory() as session:
request_repository = SessionTransferHistoryRepository(session)
request_repository.stage_truncate()
session.commit()
assert repository.get(created.id) is None

View File

@@ -0,0 +1,265 @@
"""整理历史类型化边界与适配器布局门禁。"""
import ast
from pathlib import Path
PROJECT_ROOT = Path(__file__).parents[1]
APP_ROOT = PROJECT_ROOT / "app"
HISTORY_APPLICATION_PATH = APP_ROOT / "application" / "history.py"
QUERY_APPLICATION_PATH = APP_ROOT / "application" / "query.py"
HISTORY_ADAPTER_PACKAGE = APP_ROOT / "db" / "adapters" / "history"
CANONICAL_CONSUMER_PATHS = (
APP_ROOT / "agent",
APP_ROOT / "api",
APP_ROOT / "application",
APP_ROOT / "chain",
APP_ROOT / "modules",
APP_ROOT / "monitor",
APP_ROOT / "startup",
APP_ROOT / "workflow",
APP_ROOT / "scheduler.py",
)
def _python_paths(root: Path) -> list[Path]:
"""返回 canonical 消费边界内的 Python 源文件。"""
if root.is_file():
return [root]
return sorted(root.rglob("*.py"))
def _annotation_contains_any(annotation: ast.expr | None) -> bool:
"""判断类型注解是否仍以 Any 隐藏跨层数据合同。"""
return annotation is not None and any(
isinstance(node, ast.Name) and node.id == "Any"
for node in ast.walk(annotation)
)
def _is_frozen_slotted_dataclass(node: ast.ClassDef) -> bool:
"""判断类是否声明为不可变且使用 slots 的数据类。"""
for decorator in node.decorator_list:
if not isinstance(decorator, ast.Call):
continue
if not isinstance(decorator.func, ast.Name) or decorator.func.id != "dataclass":
continue
keywords = {
keyword.arg: keyword.value
for keyword in decorator.keywords
if keyword.arg is not None
}
return all(
isinstance(keywords.get(name), ast.Constant)
and keywords[name].value is True
for name in ("frozen", "slots")
)
return False
def test_transfer_history_contract_is_typed_and_frozen() -> None:
"""整理历史必须使用冻结 DTO 和明确 Query/Write/Repository 端口。"""
tree = ast.parse(
HISTORY_APPLICATION_PATH.read_text(encoding="utf-8-sig"),
filename=str(HISTORY_APPLICATION_PATH),
)
classes = {
node.name: node
for node in tree.body
if isinstance(node, ast.ClassDef)
}
expected = {
"TransferHistorySnapshot",
"TransferHistoryWrite",
"TransferHistoryQueryPort",
"TransferHistoryWritePort",
"TransferHistoryRepository",
}
assert expected <= classes.keys()
assert _is_frozen_slotted_dataclass(classes["TransferHistorySnapshot"])
assert _is_frozen_slotted_dataclass(classes["TransferHistoryWrite"])
violations: list[str] = []
for class_name in expected:
class_node = classes[class_name]
for node in ast.walk(class_node):
if isinstance(node, ast.AnnAssign) and _annotation_contains_any(node.annotation):
violations.append(f"{class_name}:{node.lineno}:field")
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
annotations = [
argument.annotation
for argument in (
*node.args.posonlyargs,
*node.args.args,
*node.args.kwonlyargs,
)
if argument.arg not in {"self", "cls"}
]
if node.args.vararg is not None:
annotations.append(node.args.vararg.annotation)
if node.args.kwarg is not None:
annotations.append(node.args.kwarg.annotation)
annotations.append(node.returns)
if any(_annotation_contains_any(annotation) for annotation in annotations):
violations.append(f"{class_name}:{node.lineno}:{node.name}")
assert violations == []
def test_history_query_port_returns_explicit_snapshots() -> None:
"""插件查询端口也必须声明冻结历史快照,不能以 object 隐藏合同。"""
tree = ast.parse(
QUERY_APPLICATION_PATH.read_text(encoding="utf-8-sig"),
filename=str(QUERY_APPLICATION_PATH),
)
history_port = next(
node
for node in tree.body
if isinstance(node, ast.ClassDef) and node.name == "HistoryQueryPort"
)
expected = {
"list_download_history": "QueryRows[DownloadHistorySnapshot]",
"get_download_history": "DownloadHistorySnapshot | None",
"list_transfer_history": "QueryRows[TransferHistorySnapshot]",
"get_transfer_history": "TransferHistorySnapshot | None",
}
assert {
node.name: ast.unparse(node.returns)
for node in history_port.body
if isinstance(node, ast.FunctionDef)
} == expected
def test_transfer_history_retires_dynamic_writer_facade() -> None:
"""类型化仓储启用后不得保留动态 getter、Facade 或 add_force 合同。"""
tree = ast.parse(
HISTORY_APPLICATION_PATH.read_text(encoding="utf-8-sig"),
filename=str(HISTORY_APPLICATION_PATH),
)
retired_classes = {
"TransferHistoryRecord",
"TransferHistoryWriter",
"TransferHistoryPort",
"AsyncTransferHistoryQueryRepository",
}
retired_functions = {
"_get_transfer_history_writer",
"get_transfer_history_port",
}
violations: list[str] = []
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef) and node.name in retired_classes:
violations.append(f"class:{node.name}:{node.lineno}")
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
if node.name in retired_functions or node.name == "add_force":
violations.append(f"function:{node.name}:{node.lineno}")
if isinstance(node, ast.Attribute) and node.attr == "add_force":
violations.append(f"attribute:add_force:{node.lineno}")
assert violations == []
def test_canonical_consumers_do_not_import_raw_transfer_history_persistence() -> None:
"""宿主消费者不得导入整理历史 ORM、Oper 或旧动态 getter。"""
forbidden_from_imports = {
("app.db.models", "TransferHistory"),
("app.db.models.transferhistory", "TransferHistory"),
("app.db.oper", "TransferHistoryOper"),
("app.db.oper.transferhistory", "TransferHistoryOper"),
("app.application.history", "TransferHistoryPort"),
("app.application.history", "get_transfer_history_port"),
}
forbidden_modules = {
"app.db.models.transferhistory",
"app.db.oper.transferhistory",
}
violations: list[str] = []
for root in CANONICAL_CONSUMER_PATHS:
for path in _python_paths(root):
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
relative = path.relative_to(PROJECT_ROOT).as_posix()
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
for alias in node.names:
if (node.module, alias.name) in forbidden_from_imports:
violations.append(
f"{relative}:{node.lineno}:{node.module}.{alias.name}"
)
elif isinstance(node, ast.Import):
for alias in node.names:
if alias.name in forbidden_modules:
violations.append(f"{relative}:{node.lineno}:{alias.name}")
elif isinstance(node, ast.Attribute) and node.attr == "add_force":
violations.append(f"{relative}:{node.lineno}:add_force")
elif isinstance(node, ast.Name) and node.id == "get_transfer_history_port":
violations.append(
f"{relative}:{node.lineno}:get_transfer_history_port"
)
elif isinstance(node, (ast.Assign, ast.AnnAssign)):
targets = (
node.targets
if isinstance(node, ast.Assign)
else [node.target]
)
value = node.value
if (
any(
isinstance(target, ast.Name)
and target.id.startswith("TransferHistory")
for target in targets
)
and value is not None
and any(
isinstance(child, ast.Name) and child.id == "Any"
for child in ast.walk(value)
)
):
violations.append(f"{relative}:{node.lineno}:Any alias")
assert violations == []
def test_add_force_is_owned_only_by_legacy_sdk_facade() -> None:
"""旧无类型替换入口只能存在于私有 Legacy facade。"""
allowed = "app/sdk/_legacy/history.py"
violations: list[str] = []
for path in APP_ROOT.rglob("*.py"):
relative = path.relative_to(PROJECT_ROOT).as_posix()
if relative.startswith("app/plugins/") or relative == allowed:
continue
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
if node.name == "add_force":
violations.append(f"{relative}:{node.lineno}:definition")
elif isinstance(node, ast.Attribute) and node.attr == "add_force":
violations.append(f"{relative}:{node.lineno}:call")
assert violations == []
def test_transfer_history_adapter_uses_topic_package_without_root_exports() -> None:
"""整理历史适配器必须位于 history/transfer.py,包根不得复制宿主导出面。"""
adapter_path = HISTORY_ADAPTER_PACKAGE / "transfer.py"
package_path = HISTORY_ADAPTER_PACKAGE / "__init__.py"
assert adapter_path.is_file()
assert not (APP_ROOT / "db" / "adapters" / "transferhistory.py").exists()
assert not (APP_ROOT / "db" / "adapters" / "transfer_history.py").exists()
tree = ast.parse(
package_path.read_text(encoding="utf-8-sig"),
filename=str(package_path),
)
statements = [
node
for node in tree.body
if not (
isinstance(node, ast.Expr)
and isinstance(node.value, ast.Constant)
and isinstance(node.value.value, str)
)
]
assert statements == []

View File

@@ -0,0 +1,99 @@
"""旧整理历史 Oper 的精确插件 ABI 门禁。"""
import importlib
import inspect
from app.db.oper.transferhistory import TransferHistoryOper as CanonicalTransferHistoryOper
from app.runtime.compat.manifest import MODULE_ALIASES
def test_legacy_transfer_history_import_targets_private_sdk_facade() -> None:
"""旧 DB 路径必须精确路由到私有 SDK 门面。"""
alias = MODULE_ALIASES["app.db.transferhistory_oper"]
legacy = importlib.import_module("app.db.transferhistory_oper")
assert alias.target == "app.sdk._legacy.history"
assert alias.owner == "sdk"
assert alias.replacement == "app.application.history"
assert legacy is importlib.import_module(alias.target)
assert legacy.__all__ == ["TransferHistoryOper"]
assert issubclass(legacy.TransferHistoryOper, CanonicalTransferHistoryOper)
assert legacy.TransferHistoryOper is not CanonicalTransferHistoryOper
oper_package = importlib.import_module("app.db.oper")
assert oper_package.TransferHistoryOper is legacy.TransferHistoryOper
assert "TransferHistoryOper" not in oper_package.__all__
def test_legacy_transfer_history_oper_preserves_exact_write_abi() -> None:
"""兼容类冻结旧方法参数,并以脱离 Session 的快照替代 ORM 注解。"""
legacy = importlib.import_module("app.db.transferhistory_oper")
oper_type = legacy.TransferHistoryOper
public_methods = {
name
for name, value in oper_type.__dict__.items()
if not name.startswith("_") and callable(value)
}
assert public_methods == {"add_success", "add_fail", "add_force"}
assert str(inspect.signature(oper_type.add_force)) == (
"(self, **kwargs) -> app.application.history.TransferHistorySnapshot | None"
)
assert str(inspect.signature(oper_type.add_success)) == (
"(self, fileitem: app.schemas.file.FileItem, mode: str, "
"meta: app.domain.meta.metabase.MetaBase, "
"mediainfo: app.domain.context.MediaInfo | app.domain.context.MusicInfo, "
"transferinfo: app.schemas.transfer.TransferInfo, "
"downloader: str | None = None, download_hash: str | None = None) -> "
"Any | None"
)
assert str(inspect.signature(oper_type.add_fail)) == (
"(self, fileitem: app.schemas.file.FileItem, mode: str, "
"meta: app.domain.meta.metabase.MetaBase, "
"mediainfo: app.domain.context.MediaInfo | app.domain.context.MusicInfo | "
"None = None, transferinfo: app.schemas.transfer.TransferInfo | None = None, "
"downloader: str | None = None, download_hash: str | None = None) -> "
"Any | None"
)
def test_legacy_add_force_preserves_replace_behavior(db) -> None:
"""旧 add_force 继续按同源存储替换并返回可读记录。"""
legacy = importlib.import_module("app.db.transferhistory_oper")
oper = legacy.TransferHistoryOper(db.session)
first = oper.add_force(
src="/downloads/legacy-replace.mkv",
src_storage=None,
title="旧标题",
status=False,
)
second = oper.add_force(
src="/downloads/legacy-replace.mkv",
src_storage="local",
title="新标题",
status=True,
)
assert first is not None
assert second is not None
assert second.src == "/downloads/legacy-replace.mkv"
assert second.src_storage == "local"
assert second.title == "新标题"
assert second.status is True
records = oper.list_success_by_src(
"/downloads/legacy-replace.mkv",
"local",
)
assert [record.id for record in records] == [second.id]
def test_legacy_transfer_history_helper_keeps_exact_application_route() -> None:
"""旧 Helper 路径继续复用应用层实现,不创建第二份兼容模块。"""
alias = MODULE_ALIASES["app.helper.transferhistory"]
assert alias.target == "app.application.history"
assert alias.owner == "application"
assert importlib.import_module("app.helper.transferhistory") is importlib.import_module(
alias.target
)

View File

@@ -16,10 +16,11 @@ import pytest
from app import schemas
from app.application.history import add_transfer_fail, add_transfer_success
from app.db.adapters.history.transfer import TransactionalTransferHistoryRepository
from app.db.models.transferhistory import TransferHistory
from app.db.session import SessionFactory, async_session_scope
from app.domain.context import MediaInfo
from app.domain.metainfo import MetaInfo
from app.db.models.transferhistory import TransferHistory
from app.db.oper.transferhistory import TransferHistoryOper
from app.schemas.types import MediaSource, MediaType
@@ -35,6 +36,14 @@ def _fileitem(path: str, storage: str = "local") -> schemas.FileItem:
name=path.rsplit("/", 1)[-1], type="file")
def _repository() -> TransactionalTransferHistoryRepository:
"""构造类型化整理历史仓储。"""
return TransactionalTransferHistoryRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
)
def _transferinfo(dest: str = "/media/片名/Season 01/片名 - S01E02.mkv",
message: str = None, with_target: bool = True) -> schemas.TransferInfo:
"""构造整理结果。"""
@@ -72,7 +81,7 @@ def test_add_success_maps_every_field_onto_the_row(db):
源/目标各自带存储、季集来自识别结果、状态为成功——这些是整理查重与媒体库
溯源的全部依据,错一项就查不回来。
"""
oper = TransferHistoryOper(db=db.session)
oper = _repository()
meta = MetaInfo("片名.S01E02.2026.mkv")
add_transfer_success(transfer_history_oper=oper,
@@ -99,7 +108,7 @@ def test_add_success_persists_both_file_items(db):
整理链回滚、重新整理都要拿原始 FileItem 复原,只存路径字符串不够。
"""
oper = TransferHistoryOper(db=db.session)
oper = _repository()
add_transfer_success(transfer_history_oper=oper,
fileitem=_fileitem("/downloads/item.mkv"), mode="link",
meta=MetaInfo("item.mkv"), mediainfo=_mediainfo(),
@@ -116,7 +125,7 @@ def test_add_success_tolerates_missing_target_item(db):
某些存储的整理只回传结果不回传条目,硬取 target_item.path 会直接抛。
"""
oper = TransferHistoryOper(db=db.session)
oper = _repository()
add_transfer_success(transfer_history_oper=oper,
fileitem=_fileitem("/downloads/no-target.mkv"), mode="copy",
meta=MetaInfo("no-target.mkv"), mediainfo=_mediainfo(),
@@ -131,9 +140,9 @@ def test_add_success_replaces_the_previous_record_of_the_same_source(db):
"""
同一源路径重复整理只保留最新一条。
经 add_force 的先删后插实现;留下旧行会让查重命中过期的目标路径。
经同源替换的先删后插实现;留下旧行会让查重命中过期的目标路径。
"""
oper = TransferHistoryOper(db=db.session)
oper = _repository()
for title in ("第一次", "第二次"):
add_transfer_success(transfer_history_oper=oper,
fileitem=_fileitem("/downloads/dup.mkv"), mode="move",
@@ -151,7 +160,7 @@ def test_add_success_prefers_track_title_for_music(db):
音乐库按曲目组织,记成专辑名会让单曲在历史里全部重名、无法区分。
"""
oper = TransferHistoryOper(db=db.session)
oper = _repository()
music_meta = MetaInfo("周杰伦 - 晴天.flac")
add_transfer_success(transfer_history_oper=oper,
@@ -167,7 +176,7 @@ def test_add_success_falls_back_to_recognized_title(db):
"""
非音乐文件用识别标题,识别标题为空时退回文件名解析出的名字。
"""
oper = TransferHistoryOper(db=db.session)
oper = _repository()
meta = MetaInfo("片名.S01E02.2026.mkv")
add_transfer_success(transfer_history_oper=oper,
@@ -188,7 +197,7 @@ def test_add_fail_records_the_transfer_error_message(db):
失败重试与人工排障都只能看这条 errmsg。
"""
oper = TransferHistoryOper(db=db.session)
oper = _repository()
add_transfer_fail(transfer_history_oper=oper,
fileitem=_fileitem("/downloads/fail.mkv"), mode="move",
@@ -207,7 +216,7 @@ def test_add_fail_uses_a_default_message_when_none_given(db):
留空会让失败记录在界面上显示成「无错误」,与成功记录无从区分。
"""
oper = TransferHistoryOper(db=db.session)
oper = _repository()
add_transfer_fail(transfer_history_oper=oper,
fileitem=_fileitem("/downloads/nomsg.mkv"), mode="move",
@@ -221,7 +230,7 @@ def test_add_fail_persists_episode_group(db):
"""
失败记录要带上剧集组——重试时靠它还原到同一个剧集组,否则会整理错季。
"""
oper = TransferHistoryOper(db=db.session)
oper = _repository()
add_transfer_fail(transfer_history_oper=oper,
fileitem=_fileitem("/downloads/eg.mkv"), mode="move",
@@ -238,7 +247,7 @@ def test_add_fail_without_recognition_takes_the_unidentified_branch(db):
这条分支是「文件识别不出来」的唯一记录方式,丢了这些文件就彻底无迹可寻。
"""
oper = TransferHistoryOper(db=db.session)
oper = _repository()
meta = MetaInfo("无法识别的文件.S02E05.2020.mkv")
add_transfer_fail(transfer_history_oper=oper,
@@ -260,7 +269,7 @@ def test_add_fail_unidentified_music_is_marked_as_a_recording(db):
音乐订阅按单曲/专辑分别查重,实体类型为空会让这条历史两边都匹配不上。
"""
oper = TransferHistoryOper(db=db.session)
oper = _repository()
add_transfer_fail(transfer_history_oper=oper,
fileitem=_fileitem("/downloads/unknown.flac"), mode="move",
@@ -276,7 +285,7 @@ def test_add_fail_returns_the_persisted_row(db):
"""
两条分支都要把落库后的记录返回,调用方据此拿主键做后续关联。
"""
oper = TransferHistoryOper(db=db.session)
oper = _repository()
identified = add_transfer_fail(transfer_history_oper=oper,
fileitem=_fileitem("/downloads/r1.mkv"), mode="move",

View File

@@ -4,6 +4,7 @@ import threading
from types import SimpleNamespace
from unittest.mock import Mock, patch
from app.application.history import TransferHistorySnapshot
from app.application.transfer.execution import (
TransferExecutionCheckpoint,
TransferSettlementResult,
@@ -110,19 +111,20 @@ def _settlement_writer(*, status: bool, history_id: int = 41):
"""暂存历史后返回与当前结算修订一致的投影。"""
staging = Mock()
def add_force(**payload):
def replace_history(history):
"""保存历史 payload 并返回 writer 所需的最小记录。"""
payload = history.to_payload()
staged_payloads.append(payload)
return SimpleNamespace(
return TransferHistorySnapshot(
id=history_id,
status=bool(payload["status"]),
src=payload["src"],
src_storage=payload["src_storage"],
src_fileitem=payload["src_fileitem"],
status=history.status,
src=history.src,
src_storage=history.src_storage,
src_fileitem=history.src_fileitem,
)
staging.add_force.side_effect = add_force
staging.get_success_by_src.return_value = SimpleNamespace(
staging.replace.side_effect = replace_history
staging.get_success_by_src.return_value = TransferHistorySnapshot(
id=history_id,
status=True,
src="/downloads/Movie.2026.mkv",

View File

@@ -12,6 +12,7 @@ import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from app.application.history import TransferHistorySnapshot
from app.application.transfer import workflow as transfer_application
from app.application.transfer.execution import (
TransferExecutionCheckpoint,
@@ -1064,7 +1065,7 @@ def test_legacy_transfer_command_uses_durable_pipeline_and_settles_pending():
def settle_result(**kwargs):
"""执行历史暂存并模拟 writer 返回 task-aware 结算投影。"""
staging = Mock()
staging.add_force.return_value = SimpleNamespace(
staging.replace.return_value = TransferHistorySnapshot(
id=31,
status=True,
src=result.fileitem.path,

View File

@@ -10,6 +10,7 @@ from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from app.application.chain.events import TransferResultSettlement
from app.application.history import TransferHistoryWrite
from app.application.transfer.execution import (
TransferExecutionCheckpoint,
TransferExecutionCommand,
@@ -597,12 +598,12 @@ def test_failed_settlement_retry_replays_step_and_commits_new_receipt(
writer = TransactionalChainDurableEventWriter(factory)
first_settlement = writer.transfer_result(
topic=None,
stage_history=lambda repository: repository.add_force(
stage_history=lambda repository: repository.replace(TransferHistoryWrite(
src=path,
src_storage="local",
status=False,
errmsg="copy failed",
),
)),
event_payload={},
publish=None,
settlement=TransferResultSettlement(
@@ -654,12 +655,12 @@ def test_failed_settlement_retry_replays_step_and_commits_new_receipt(
assert new_checkpoint.fingerprint != exhausted.checkpoint.fingerprint
second_settlement = writer.transfer_result(
topic=None,
stage_history=lambda repository: repository.add_force(
stage_history=lambda repository: repository.replace(TransferHistoryWrite(
src=path,
src_storage="local",
status=True,
errmsg=None,
),
)),
event_payload={},
publish=None,
settlement=TransferResultSettlement(

View File

@@ -1,20 +1,29 @@
import importlib
from types import SimpleNamespace
from unittest.mock import Mock
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
from app.application.history import add_transfer_fail, add_transfer_success
from app.application.history import (
TransferHistorySnapshot,
add_transfer_fail,
add_transfer_success,
)
from app.domain.context import MUSIC_ENTITY_ALBUM, MusicInfo
from app.domain.meta.metabase import MetaBase
from app.domain.meta.metamusic import MetaMusic
from app.db.oper.transferhistory import TransferHistoryOper
from app.schemas.file import FileItem
from app.schemas.transfer import TransferInfo
def _history_repository(src: str) -> Mock:
"""构造接收类型化写入并返回冻结快照的测试仓储。"""
repository = Mock()
repository.replace.return_value = TransferHistorySnapshot(id=1, src=src)
return repository
def test_transferhistory_migration_backfills_existing_source_ids(monkeypatch) -> None:
"""迁移应把存量TMDB和豆瓣字段回填到统一来源字段。"""
migration = importlib.import_module(
@@ -64,8 +73,7 @@ def test_transferhistory_migration_backfills_existing_source_ids(monkeypatch) ->
def test_failed_transfer_history_preserves_explicit_media_source() -> None:
"""识别失败记录也应保存文件名中显式指定的数据源ID。"""
oper = object.__new__(TransferHistoryOper)
oper.add_force = Mock(return_value=SimpleNamespace(id=1))
repository = _history_repository("/downloads/Frieren.mkv")
meta = MetaBase("Frieren")
meta.cn_name = "Frieren"
meta.media_source = "anilist"
@@ -79,18 +87,17 @@ def test_failed_transfer_history_preserves_explicit_media_source() -> None:
),
mode="copy",
meta=meta,
transfer_history_oper=oper,
transfer_history_oper=repository,
)
call = oper.add_force.call_args
assert call.kwargs["media_source"] == "anilist"
assert call.kwargs["media_id"] == "154587"
history = repository.replace.call_args.args[0]
assert str(history.media_source) == "anilist"
assert history.media_id == "154587"
def test_failed_music_history_preserves_media_type_and_entity_namespace() -> None:
"""未识别音乐的失败记录也应保留音乐类型和单曲实体命名空间。"""
oper = object.__new__(TransferHistoryOper)
oper.add_force = Mock(return_value=SimpleNamespace(id=1))
repository = _history_repository("/downloads/周杰伦 - 晴天.flac")
meta = MetaMusic(
title="晴天",
artists=["周杰伦"],
@@ -107,12 +114,12 @@ def test_failed_music_history_preserves_media_type_and_entity_namespace() -> Non
),
mode="copy",
meta=meta,
transfer_history_oper=oper,
transfer_history_oper=repository,
)
call = oper.add_force.call_args
assert call.kwargs["type"] == "音乐"
assert call.kwargs["music_type"] == "recording"
history = repository.replace.call_args.args[0]
assert history.type == "音乐"
assert history.music_type == "recording"
def test_transferhistory_music_migration_is_idempotent(monkeypatch) -> None:
@@ -224,8 +231,7 @@ def test_music_audio_quality_migration_is_idempotent(monkeypatch) -> None:
def test_transfer_history_preserves_album_entity_context() -> None:
"""整理成功记录应保存整专实体和预期曲目数供 Agent 重试。"""
oper = object.__new__(TransferHistoryOper)
oper.add_force = Mock(return_value=SimpleNamespace(id=1))
repository = _history_repository("/downloads/叶惠美/01.flac")
media = MusicInfo(
media_source="musicbrainz",
media_id="release-group-1",
@@ -254,14 +260,14 @@ def test_transfer_history_preserves_album_entity_context() -> None:
type="file",
),
),
transfer_history_oper=oper,
transfer_history_oper=repository,
)
call = oper.add_force.call_args
assert call.kwargs["music_type"] == "album"
assert call.kwargs["total_tracks"] == 11
assert call.kwargs["audio_format"] == "FLAC"
assert call.kwargs["audio_lossless"] is True
assert call.kwargs["bit_depth"] == 24
assert call.kwargs["sample_rate"] == 96_000
assert call.kwargs["bitrate"] == 2_304_000
history = repository.replace.call_args.args[0]
assert history.music_type == "album"
assert history.total_tracks == 11
assert history.audio_format == "FLAC"
assert history.audio_lossless is True
assert history.bit_depth == 24
assert history.sample_rate == 96_000
assert history.bitrate == 2_304_000

View File

@@ -1,7 +1,9 @@
import unittest
from unittest.mock import patch
import pytest
from app import schemas
from app.application.history import TransferHistoryMonthlyStatistics
from app.modules.ugreen.ugreen import Ugreen
try:
@@ -67,143 +69,148 @@ class _PagedFolderApi:
}
class UgreenScanModeTest(unittest.TestCase):
def test_resolve_scan_type(self):
resolve = Ugreen._Ugreen__resolve_scan_type
def test_resolve_scan_type():
resolve = Ugreen._Ugreen__resolve_scan_type
self.assertEqual(resolve(scan_mode="new_and_modified"), 1)
self.assertEqual(resolve(scan_mode="supplement_missing"), 2)
self.assertEqual(resolve(scan_mode="full_override"), 3)
self.assertEqual(resolve(scan_mode="1"), 1)
self.assertEqual(resolve(scan_mode="2"), 2)
self.assertEqual(resolve(scan_mode="3"), 3)
self.assertEqual(resolve(scan_type=1), 1)
self.assertEqual(resolve(scan_type=2), 2)
self.assertEqual(resolve(scan_type=3), 3)
self.assertEqual(resolve(scan_mode="unknown"), 2)
self.assertEqual(resolve(), 2)
assert resolve(scan_mode="new_and_modified") == 1
assert resolve(scan_mode="supplement_missing") == 2
assert resolve(scan_mode="full_override") == 3
assert resolve(scan_mode="1") == 1
assert resolve(scan_mode="2") == 2
assert resolve(scan_mode="3") == 3
assert resolve(scan_type=1) == 1
assert resolve(scan_type=2) == 2
assert resolve(scan_type=3) == 3
assert resolve(scan_mode="unknown") == 2
assert resolve() == 2
class UgreenVerifySslTest(unittest.TestCase):
def test_resolve_verify_ssl(self):
resolve = Ugreen._Ugreen__resolve_verify_ssl
self.assertEqual(resolve(True), True)
self.assertEqual(resolve(False), False)
self.assertEqual(resolve("true"), True)
self.assertEqual(resolve("1"), True)
self.assertEqual(resolve("false"), False)
self.assertEqual(resolve("0"), False)
self.assertEqual(resolve(None), True)
def test_resolve_verify_ssl():
resolve = Ugreen._Ugreen__resolve_verify_ssl
assert resolve(True) is True
assert resolve(False) is False
assert resolve("true") is True
assert resolve("1") is True
assert resolve("false") is False
assert resolve("0") is False
assert resolve(None) is True
class UgreenStatisticTest(unittest.TestCase):
def test_get_medias_count_episode_is_none(self):
ugreen = Ugreen.__new__(Ugreen)
ugreen._host = "http://127.0.0.1:9999"
ugreen._username = "tester"
ugreen._password = "secret"
ugreen._userinfo = {"name": "tester"}
ugreen._api = _FakeUgreenApi()
def test_get_medias_count_episode_is_none():
ugreen = Ugreen.__new__(Ugreen)
ugreen._host = "http://127.0.0.1:9999"
ugreen._username = "tester"
ugreen._password = "secret"
ugreen._userinfo = {"name": "tester"}
ugreen._api = _FakeUgreenApi()
stat = ugreen.get_medias_count()
self.assertEqual(stat.movie_count, 12)
self.assertEqual(stat.tv_count, 34)
self.assertIsNone(stat.episode_count)
stat = ugreen.get_medias_count()
assert stat.movie_count == 12
assert stat.tv_count == 34
assert stat.episode_count is None
class UgreenReconnectTest(unittest.TestCase):
def test_reconnect_does_not_eagerly_load_libraries(self):
ugreen = Ugreen.__new__(Ugreen)
ugreen._host = "http://127.0.0.1:9999"
ugreen._username = "tester"
ugreen._password = "secret"
ugreen._verify_ssl = True
ugreen._libraries = {"old": {"id": "old"}}
ugreen._library_paths = {"old": "/old"}
ugreen._api = None
ugreen._userinfo = None
def test_reconnect_does_not_eagerly_load_libraries():
ugreen = Ugreen.__new__(Ugreen)
ugreen._host = "http://127.0.0.1:9999"
ugreen._username = "tester"
ugreen._password = "secret"
ugreen._verify_ssl = True
ugreen._libraries = {"old": {"id": "old"}}
ugreen._library_paths = {"old": "/old"}
ugreen._api = None
ugreen._userinfo = None
with patch.object(Ugreen, "_Ugreen__restore_persisted_session", return_value=False), patch(
"app.modules.ugreen.ugreen.Api", return_value=_FakeReconnectApi()
), patch.object(Ugreen, "_Ugreen__save_persisted_session", return_value=None), patch.object(
Ugreen, "disconnect", wraps=ugreen.disconnect
), patch.object(Ugreen, "get_librarys") as mocked_get_librarys:
self.assertTrue(ugreen.reconnect())
with patch.object(Ugreen, "_Ugreen__restore_persisted_session", return_value=False), patch(
"app.modules.ugreen.ugreen.Api", return_value=_FakeReconnectApi()
), patch.object(Ugreen, "_Ugreen__save_persisted_session", return_value=None), patch.object(
Ugreen, "disconnect", wraps=ugreen.disconnect
), patch.object(Ugreen, "get_librarys") as mocked_get_librarys:
assert ugreen.reconnect() is True
mocked_get_librarys.assert_not_called()
self.assertEqual(ugreen._libraries, {})
self.assertEqual(ugreen._library_paths, {})
mocked_get_librarys.assert_not_called()
assert ugreen._libraries == {}
assert ugreen._library_paths == {}
class UgreenLibraryPathLimitTest(unittest.TestCase):
def test_load_library_paths_stops_at_last_page(self):
ugreen = Ugreen.__new__(Ugreen)
ugreen._username = "tester"
ugreen._api = _PagedFolderApi(stop_after=3)
def test_load_library_paths_stops_at_last_page():
ugreen = Ugreen.__new__(Ugreen)
ugreen._username = "tester"
ugreen._api = _PagedFolderApi(stop_after=3)
paths = ugreen._Ugreen__load_library_paths()
paths = ugreen._Ugreen__load_library_paths()
self.assertEqual(ugreen._api.pages, [1, 2, 3])
self.assertEqual(paths["3"], "/library/3")
def test_load_library_paths_respects_page_limit(self):
ugreen = Ugreen.__new__(Ugreen)
ugreen._username = "tester"
ugreen._api = _PagedFolderApi()
paths = ugreen._Ugreen__load_library_paths()
self.assertEqual(ugreen._api.calls, Ugreen.LIBRARY_PATH_PAGE_LIMIT)
self.assertEqual(len(paths), Ugreen.LIBRARY_PATH_PAGE_LIMIT)
self.assertIn(str(Ugreen.LIBRARY_PATH_PAGE_LIMIT), paths)
assert ugreen._api.pages == [1, 2, 3]
assert paths["3"] == "/library/3"
class DashboardStatisticTest(unittest.TestCase):
class _Repository:
"""Dashboard 汇总测试使用的零增量历史仓储。"""
def test_load_library_paths_respects_page_limit():
ugreen = Ugreen.__new__(Ugreen)
ugreen._username = "tester"
ugreen._api = _PagedFolderApi()
@staticmethod
def monthly_media_statistics():
"""返回全零月度统计,隔离媒体服务汇总断言。"""
return 0, 0, 0, 0
paths = ugreen._Ugreen__load_library_paths()
@unittest.skipIf(dashboard_endpoint is None, "dashboard endpoint dependencies are missing")
def test_statistic_all_episode_missing(self):
mocked_stats = [
schemas.Statistic(movie_count=10, tv_count=20, episode_count=None, user_count=2),
schemas.Statistic(movie_count=1, tv_count=2, episode_count=None, user_count=1),
]
from app.application.dashboard import DashboardQueryService
assert ugreen._api.calls == Ugreen.LIBRARY_PATH_PAGE_LIMIT
assert len(paths) == Ugreen.LIBRARY_PATH_PAGE_LIMIT
assert str(Ugreen.LIBRARY_PATH_PAGE_LIMIT) in paths
service = DashboardQueryService(
repository=self._Repository(),
media_statistics=lambda _name: mocked_stats,
class _DashboardRepository:
"""Dashboard 汇总测试使用的零增量历史仓储。"""
@staticmethod
def monthly_media_statistics() -> TransferHistoryMonthlyStatistics:
"""返回全零月度统计,隔离媒体服务汇总断言。"""
return TransferHistoryMonthlyStatistics(
movies=0,
tv_shows=0,
episodes=0,
music=0,
)
ret = dashboard_endpoint.statistic(name="ugreen", service=service, _=None)
self.assertEqual(ret.movie_count, 11)
self.assertEqual(ret.tv_count, 22)
self.assertEqual(ret.user_count, 3)
self.assertIsNone(ret.episode_count)
@unittest.skipIf(dashboard_endpoint is None, "dashboard endpoint dependencies are missing")
def test_statistic_mixed_episode_count(self):
mocked_stats = [
schemas.Statistic(movie_count=10, tv_count=20, episode_count=None, user_count=2),
schemas.Statistic(movie_count=1, tv_count=2, episode_count=6, user_count=1),
]
from app.application.dashboard import DashboardQueryService
@pytest.mark.skipif(
dashboard_endpoint is None,
reason="dashboard endpoint dependencies are missing",
)
def test_statistic_all_episode_missing():
mocked_stats = [
schemas.Statistic(movie_count=10, tv_count=20, episode_count=None, user_count=2),
schemas.Statistic(movie_count=1, tv_count=2, episode_count=None, user_count=1),
]
from app.application.dashboard import DashboardQueryService
service = DashboardQueryService(
repository=self._Repository(),
media_statistics=lambda _name: mocked_stats,
)
ret = dashboard_endpoint.statistic(name="all", service=service, _=None)
service = DashboardQueryService(
repository=_DashboardRepository(),
media_statistics=lambda _name: mocked_stats,
)
ret = dashboard_endpoint.statistic(name="ugreen", service=service, _=None)
self.assertEqual(ret.movie_count, 11)
self.assertEqual(ret.tv_count, 22)
self.assertEqual(ret.user_count, 3)
self.assertEqual(ret.episode_count, 6)
assert ret.movie_count == 11
assert ret.tv_count == 22
assert ret.user_count == 3
assert ret.episode_count is None
@pytest.mark.skipif(
dashboard_endpoint is None,
reason="dashboard endpoint dependencies are missing",
)
def test_statistic_mixed_episode_count():
mocked_stats = [
schemas.Statistic(movie_count=10, tv_count=20, episode_count=None, user_count=2),
schemas.Statistic(movie_count=1, tv_count=2, episode_count=6, user_count=1),
]
from app.application.dashboard import DashboardQueryService
service = DashboardQueryService(
repository=_DashboardRepository(),
media_statistics=lambda _name: mocked_stats,
)
ret = dashboard_endpoint.statistic(name="all", service=service, _=None)
assert ret.movie_count == 11
assert ret.tv_count == 22
assert ret.user_count == 3
assert ret.episode_count == 6