mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-02 13:28:32 +08:00
refactor(history): close transfer history boundary
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 读取一条整理历史。"""
|
||||
...
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
"""拒绝结算终态与历史状态不一致的调用。"""
|
||||
|
||||
401
app/db/adapters/history/transfer.py
Normal file
401
app/db/adapters/history/transfer.py
Normal 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()
|
||||
@@ -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",
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
查询某时间之后的转移历史
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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。
|
||||
|
||||
**验收**
|
||||
|
||||
|
||||
@@ -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` 观察面) |
|
||||
|
||||
@@ -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 与最终收口
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
16
tests/fixtures/architecture/mypy-baseline.json
vendored
16
tests/fixtures/architecture/mypy-baseline.json
vendored
@@ -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,
|
||||
|
||||
16
tests/fixtures/architecture/ruff-baseline.json
vendored
16
tests/fixtures/architecture/ruff-baseline.json
vendored
@@ -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
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
"""模拟插件事件总线在业务提交后失败。"""
|
||||
|
||||
@@ -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")] == ["新记录"]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
236
tests/test_transactional_transfer_history.py
Normal file
236
tests/test_transactional_transfer_history.py
Normal 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
|
||||
265
tests/test_transfer_history_architecture.py
Normal file
265
tests/test_transfer_history_architecture.py
Normal 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 == []
|
||||
99
tests/test_transfer_history_legacy_compat.py
Normal file
99
tests/test_transfer_history_legacy_compat.py
Normal 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
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user