Files
MoviePilot/app/application/history/__init__.py
2026-09-16 07:17:07 +08:00

536 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import Any, Optional, Protocol, Union
from app.application.history.contracts import (
TransferHistoryMonthlyStatistics,
TransferHistoryQueryPort,
TransferHistoryReplacePort,
TransferHistoryRepository,
TransferHistorySnapshot,
TransferHistoryStagingPort,
TransferHistoryStatisticSnapshot,
TransferHistoryWrite,
TransferHistoryWritePort,
_freeze_json,
configure_transfer_history_repository,
get_transfer_history_repository,
reset_transfer_history_repository,
)
from app.application.history.mutation import (
DownloadFileMutationRepository as DownloadFileMutationRepository,
)
from app.application.history.mutation import (
DownloadHistoryMutationCommand as DownloadHistoryMutationCommand,
)
from app.application.history.mutation import (
DownloadHistoryMutationRepository as DownloadHistoryMutationRepository,
)
from app.application.history.mutation import (
HistoryMutationResult as HistoryMutationResult,
)
from app.application.history.mutation import (
HistoryUnitOfWork as HistoryUnitOfWork,
)
from app.application.history.mutation import (
TransferHistoryMutationCommand as TransferHistoryMutationCommand,
)
from app.application.history.mutation import (
TransferHistoryMutationRepository as TransferHistoryMutationRepository,
)
from app.application.transfer import history as history_projection
from app.domain.context import MediaInfo, MusicInfo
from app.domain.meta.metabase import MetaBase
from app.foundation.text import cut as jieba_cut
from app.schemas.common import JsonData
from app.schemas.file import FileItem
from app.schemas.history import (
DownloadHistory,
TransferHistory,
TransferHistoryPage,
)
from app.schemas.transfer import TransferInfo
from app.schemas.types import MediaSource
__all__ = [
"DownloadFileMutationRepository",
"DownloadFileSnapshot",
"DownloadFileWrite",
"DownloadHistory",
"DownloadHistoryMutationCommand",
"DownloadHistoryMutationRepository",
"DownloadHistoryQueryPort",
"DownloadHistoryRepository",
"DownloadHistorySnapshot",
"DownloadHistoryWrite",
"DownloadHistoryWritePort",
"HistoryMutationResult",
"HistoryQueryService",
"HistoryUnitOfWork",
"ManualTransferHistory",
"TransferHistory",
"TransferHistoryLookupRepository",
"TransferHistoryLookupService",
"TransferHistoryMonthlyStatistics",
"TransferHistoryMutationCommand",
"TransferHistoryPage",
"TransferHistoryQueryPort",
"TransferHistoryReplacePort",
"TransferHistoryRepository",
"TransferHistorySnapshot",
"TransferHistoryStagingPort",
"TransferHistoryStatisticSnapshot",
"TransferHistoryWrite",
"TransferHistoryWritePort",
"add_transfer_fail",
"add_transfer_success",
"configure_transfer_history_repository",
"get_transfer_history_repository",
"reset_transfer_history_repository",
]
@dataclass(frozen=True, slots=True)
class DownloadHistorySnapshot:
"""脱离数据库会话后供宿主下载、订阅和整理用例读取的历史快照。"""
id: int
path: str
type: str
title: str
year: Optional[str] = None
media_source: Optional[MediaSource] = None
media_id: Optional[str] = None
music_type: Optional[str] = None
seasons: Optional[str] = None
episodes: Optional[str] = None
image: Optional[str] = None
poster: Optional[str] = None
downloader: Optional[str] = None
download_hash: Optional[str] = None
torrent_name: Optional[str] = None
torrent_description: Optional[str] = None
torrent_site: Optional[str] = None
userid: Optional[str] = None
username: Optional[str] = None
channel: Optional[str] = None
date: Optional[str] = None
note: Optional[JsonData] = None
media_category_id: Optional[str] = None
media_category: Optional[str] = None
classification_rule_id: Optional[str] = None
classification_policy_revision: Optional[int] = None
classification_source: Optional[str] = None
episode_group: Optional[str] = None
custom_words: Optional[str] = None
def __post_init__(self) -> None:
"""递归冻结可变 JSON 字段,使 DTO 在所有层级都不可修改。"""
object.__setattr__(self, "note", _freeze_json(self.note))
@dataclass(frozen=True, slots=True)
class DownloadFileSnapshot:
"""脱离数据库会话的下载文件关联快照。"""
id: int
downloader: Optional[str]
download_hash: Optional[str]
fullpath: Optional[str]
savepath: Optional[str]
filepath: Optional[str]
torrentname: Optional[str]
state: int
@dataclass(frozen=True, slots=True)
class DownloadHistoryWrite:
"""一次下载成功后写入历史所需的完整稳定数据。"""
path: str
type: str
title: str
year: Optional[str] = None
media_source: Optional[MediaSource] = None
media_id: Optional[str] = None
music_type: Optional[str] = None
seasons: Optional[str] = None
episodes: Optional[str] = None
image: Optional[str] = None
poster: Optional[str] = None
downloader: Optional[str] = None
download_hash: Optional[str] = None
torrent_name: Optional[str] = None
torrent_description: Optional[str] = None
torrent_site: Optional[str] = None
userid: Optional[Union[str, int]] = None
username: Optional[str] = None
channel: Optional[str] = None
date: Optional[str] = None
note: Optional[JsonData] = None
media_category_id: Optional[str] = None
media_category: Optional[str] = None
classification_rule_id: Optional[str] = None
classification_policy_revision: Optional[int] = None
classification_source: Optional[str] = None
episode_group: Optional[str] = None
custom_words: Optional[str] = None
def to_payload(self) -> dict[str, Any]:
"""返回可交给持久化适配器的独立字段副本。"""
payload = asdict(self)
if self.media_source is not None:
payload["media_source"] = str(self.media_source)
if self.userid is not None:
payload["userid"] = str(self.userid)
return payload
@dataclass(frozen=True, slots=True)
class DownloadFileWrite:
"""下载任务关联文件的一次稳定写入。"""
downloader: Optional[str] = None
download_hash: Optional[str] = None
fullpath: Optional[str] = None
savepath: Optional[str] = None
filepath: Optional[str] = None
torrentname: Optional[str] = None
state: int = 1
def to_payload(self) -> dict[str, Any]:
"""返回可交给持久化适配器的独立字段副本。"""
return asdict(self)
class DownloadHistoryQueryPort(Protocol):
"""宿主下载、订阅、Agent 和整理用例所需的类型化查询端口。"""
def get_by_hash(
self,
download_hash: str,
) -> Optional[DownloadHistorySnapshot]:
"""按下载任务 Hash 返回最新历史快照。"""
...
def get_by_hashes(
self,
download_hashes: list[str],
) -> dict[str, DownloadHistorySnapshot]:
"""批量返回以下载任务 Hash 为键的最新历史快照。"""
...
def get_by_path(self, path: str) -> Optional[DownloadHistorySnapshot]:
"""按下载保存路径返回历史快照。"""
...
def get_by_media_identity(
self,
media_source: MediaSource,
media_id: str,
music_type: Optional[str] = None,
) -> list[DownloadHistorySnapshot]:
"""按规范媒体身份返回历史快照。"""
...
def get_file_by_fullpath(
self,
fullpath: str,
) -> Optional[DownloadFileSnapshot]:
"""按完整路径返回一条有效下载文件快照。"""
...
def get_files_by_hash(
self,
download_hash: str,
state: Optional[int] = None,
) -> list[DownloadFileSnapshot]:
"""按下载任务 Hash 返回文件快照。"""
...
def get_files_by_savepath(self, savepath: str) -> list[DownloadFileSnapshot]:
"""按保存目录返回下载文件快照。"""
...
async def async_list_by_page(
self,
page: int = 1,
count: int = 30,
) -> list[DownloadHistorySnapshot]:
"""异步按下载时间倒序分页返回历史快照。"""
...
class DownloadHistoryWritePort(Protocol):
"""下载历史新增和删除所需的类型化事务端口。"""
def add(
self,
history: DownloadHistoryWrite,
files: tuple[DownloadFileWrite, ...] = (),
) -> int:
"""在单一事务中新增历史与关联文件并返回历史 ID。"""
...
async def async_delete(self, history_id: int) -> None:
"""在独立异步事务中删除指定历史。"""
...
class DownloadHistoryRepository(DownloadHistoryQueryPort, DownloadHistoryWritePort, Protocol):
"""组合宿主所需全部下载历史查询和变更能力。"""
class AsyncDownloadHistoryQueryRepository(Protocol):
"""下载历史只读用例需要的最小异步持久化端口。"""
async def async_list_by_page(self, page: int = 1, count: int = 30) -> list[DownloadHistorySnapshot]:
"""按下载时间倒序分页读取历史记录。"""
...
async def async_count(self) -> int:
"""返回下载历史记录总数。"""
...
@dataclass(frozen=True, slots=True)
class ManualTransferHistory:
"""手动整理准备阶段需要的稳定历史投影。"""
id: int
status: bool
mode: Optional[str]
src_fileitem: Optional[dict[str, JsonData]]
dest_fileitem: Optional[dict[str, JsonData]]
downloader: Optional[str]
download_hash: Optional[str]
type: Optional[str]
media_source: Optional[MediaSource]
media_id: Optional[str]
music_type: Optional[str]
seasons: Optional[str]
episodes: Optional[str]
episode_group: Optional[str]
class TransferHistoryLookupRepository(Protocol):
"""手动整理历史投影所需的同步查询端口。"""
def get(self, history_id: int) -> Optional[TransferHistorySnapshot]:
"""按主键读取整理历史。"""
...
class TransferHistoryLookupService:
"""向同步整理用例提供脱离 ORM 会话的历史投影。"""
def __init__(self, repository: TransferHistoryLookupRepository) -> None:
"""保存整理历史只读端口。"""
self._repository = repository
def get(self, history_id: int) -> Optional[ManualTransferHistory]:
"""按主键读取手动整理所需字段。"""
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=src_fileitem,
dest_fileitem=dest_fileitem,
downloader=record.downloader,
download_hash=record.download_hash,
type=record.type,
media_source=record.media_source,
media_id=record.media_id,
music_type=getattr(record, "music_type", None),
seasons=record.seasons,
episodes=record.episodes,
episode_group=record.episode_group,
)
class HistoryQueryService:
"""提供历史列表和详情 DTO,隔离 API 与数据库模型。"""
def __init__(
self,
*,
download_repository: AsyncDownloadHistoryQueryRepository,
transfer_repository: TransferHistoryQueryPort,
) -> None:
"""保存下载历史和整理历史的只读端口。"""
self._download_repository = download_repository
self._transfer_repository = transfer_repository
async def list_download(
self,
*,
page: int = 1,
count: int = 30,
) -> list[DownloadHistory]:
"""分页读取下载历史并转换为稳定的接口 DTO。"""
records = await self._download_repository.async_list_by_page(page, count)
return [DownloadHistory.model_validate(record) for record in records]
async def count_download(self) -> int:
"""返回下载历史精确总数,供分页 API 通过附加元数据报告。"""
return await self._download_repository.async_count()
async def list_transfer(
self,
*,
title: Optional[str] = None,
page: int = 1,
count: int = 30,
status: Optional[bool] = None,
) -> TransferHistoryPage:
"""应用历史筛选规则并返回整理历史分页 DTO。"""
if title:
wildcard = "*" in title or "?" in title
if wildcard:
pattern = self._glob_to_like(title)
else:
pattern = "%".join(jieba_cut(title, HMM=False))
total = await self._transfer_repository.async_count_by_title(
pattern,
status=status,
wildcard=wildcard,
)
records = await self._transfer_repository.async_list_by_title(
pattern,
page=page,
count=count,
status=status,
wildcard=wildcard,
)
else:
records = await self._transfer_repository.async_list_by_page(
page=page,
count=count,
status=status,
)
total = await self._transfer_repository.async_count(status=status)
return TransferHistoryPage(
list=[TransferHistory.model_validate(record) for record in records],
total=int(total or 0),
)
async def get_transfer(self, history_id: int) -> Optional[TransferHistory]:
"""读取单条整理历史 DTO,不向调用方泄漏 ORM 实例。"""
record = await self._transfer_repository.async_get(history_id)
if record is None:
return None
return TransferHistory.model_validate(record)
async def get_transfers(
self,
history_ids: list[int],
) -> tuple[list[TransferHistory], list[int]]:
"""按输入顺序读取多条整理历史,并同时返回缺失 ID。"""
records: list[TransferHistory] = []
missing_ids: list[int] = []
for history_id in history_ids:
record = await self.get_transfer(history_id)
if record is None:
missing_ids.append(history_id)
else:
records.append(record)
return records, missing_ids
@staticmethod
def _glob_to_like(pattern: str) -> str:
"""将 glob 通配符转换为使用反斜杠转义的 SQL LIKE 模式。"""
result = pattern.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
return result.replace("*", "%").replace("?", "_")
# 整理历史写入口保留仓储事务契约;领域对象的字段映射由 transfer.history 统一维护。
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: 源文件项
:param mode: 整理方式
:param meta: 文件名识别结果
:param mediainfo: 媒体识别结果
:param transferinfo: 整理结果
:param downloader: 下载器
:param download_hash: 种子 hash
:param transfer_history_oper: 兼容旧关键字的暂存端口,未传时使用组合根仓储
:return: 落库后的整理记录
"""
repository = transfer_history_oper or get_transfer_history_repository()
fields = history_projection.success_fields(
fileitem=fileitem,
mode=mode,
meta=meta,
mediainfo=mediainfo,
transferinfo=transferinfo,
downloader=downloader,
download_hash=download_hash,
)
return repository.replace(TransferHistoryWrite(**fields))
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,
retry_count: Optional[int] = None,
auto_paused: bool = False,
transfer_history_oper: Optional[TransferHistoryReplacePort] = None,
) -> TransferHistorySnapshot:
"""
新增转移失败历史记录。
识别结果与整理结果齐备时按完整字段落库;缺任一项则走「未识别到媒体信息」分支,
此时只有文件名解析出的元数据可用,不写目标路径。
:param fileitem: 源文件项
:param mode: 整理方式
:param meta: 文件名识别结果
:param mediainfo: 媒体识别结果,未识别时为 None
:param transferinfo: 整理结果,未进入整理时为 None
:param downloader: 下载器
:param download_hash: 种子 hash
:param retry_count: 当前文件版本累计失败次数
:param auto_paused: 是否已达到自动整理暂停阈值
:param transfer_history_oper: 兼容旧关键字的暂存端口,未传时使用组合根仓储
:return: 落库后的整理记录
"""
repository = transfer_history_oper or get_transfer_history_repository()
fields = history_projection.failure_fields(
fileitem=fileitem,
mode=mode,
meta=meta,
mediainfo=mediainfo,
transferinfo=transferinfo,
downloader=downloader,
download_hash=download_hash,
retry_count=retry_count,
auto_paused=auto_paused,
)
return repository.replace(TransferHistoryWrite(**fields))