refactor(chain): type download and media server ports

This commit is contained in:
jxxghp
2026-08-28 07:47:37 +08:00
parent c3f115d4fb
commit 5fb62108ab
25 changed files with 1262 additions and 331 deletions
+12 -8
View File
@@ -10,10 +10,14 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, Optional
from app.application.download.failures import DownloadFailureRepository
from app.application.mediaserver import MediaServerRepository
from app.application.transfer.execution import TransferExecutionRepository
from app.application.transfer.workflow import TransferAdmissionRepository
OperFactory = Callable[[], Any]
DownloadFailureRepositoryFactory = Callable[[], DownloadFailureRepository]
MediaServerRepositoryFactory = Callable[[], MediaServerRepository]
TransferAdmissionRepositoryFactory = Callable[[], TransferAdmissionRepository]
TransferExecutionRepositoryFactory = Callable[[], TransferExecutionRepository]
@@ -28,8 +32,8 @@ class ChainDataPorts:
transfer_history: OperFactory
transfer_pending: TransferAdmissionRepositoryFactory
transfer_execution: TransferExecutionRepositoryFactory
media_server: OperFactory
download_failure: OperFactory
media_server: MediaServerRepositoryFactory
download_failure: DownloadFailureRepositoryFactory
user: OperFactory
@@ -44,8 +48,8 @@ def configure_chain_data_ports(
transfer_history: OperFactory,
transfer_pending: TransferAdmissionRepositoryFactory,
transfer_execution: TransferExecutionRepositoryFactory,
media_server: OperFactory,
download_failure: OperFactory,
media_server: MediaServerRepositoryFactory,
download_failure: DownloadFailureRepositoryFactory,
user: OperFactory,
) -> None:
"""由启动组合根登记显式命名的 Chain 数据端口实现。"""
@@ -100,13 +104,13 @@ def get_chain_transfer_execution_port() -> TransferExecutionRepository:
return get_chain_data_ports().transfer_execution()
def get_chain_media_server_port() -> Any:
"""创建媒体服务器数据端口实例。"""
def get_chain_media_server_port() -> MediaServerRepository:
"""创建类型化的媒体服务器本地缓存端口实例。"""
return get_chain_data_ports().media_server()
def get_chain_download_failure_port() -> Any:
"""创建下载失败数据端口实例。"""
def get_chain_download_failure_port() -> DownloadFailureRepository:
"""创建类型化的下载失败冷却持久化端口实例。"""
return get_chain_data_ports().download_failure()
+57
View File
@@ -0,0 +1,57 @@
"""下载失败冷却记录的类型化应用契约。"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional, Protocol, Union
from app.schemas.types import MediaSource
@dataclass(frozen=True, slots=True)
class DownloadFailureWrite:
"""一次下载失败冷却写入所需的完整稳定数据。"""
fingerprint: str
failed_at: str
next_retry_at: str
media_type: Optional[str] = None
title: Optional[str] = None
year: Optional[str] = None
media_source: Optional[MediaSource] = None
media_id: Optional[str] = None
seasons: Optional[str] = None
episodes: Optional[str] = None
site: Optional[int] = None
site_name: Optional[str] = None
torrent_id: Optional[str] = None
torrent_name: Optional[str] = None
torrent_size: Optional[Union[float, int]] = None
downloader: Optional[str] = None
source: Optional[str] = None
error_message: Optional[str] = None
@dataclass(frozen=True, slots=True)
class DownloadFailureSnapshot:
"""脱离数据库会话后供资源冷却判断使用的只读快照。"""
fingerprint: str
error_message: Optional[str]
next_retry_at: Optional[str]
class DownloadFailureRepository(Protocol):
"""下载链读写失败冷却状态所需的最小持久化端口。"""
def get_active_by_fingerprints(
self,
fingerprints: list[str],
now_time: str,
) -> dict[str, DownloadFailureSnapshot]:
"""返回仍处于冷却期的不可变失败快照。"""
...
def record_failure(self, failure: DownloadFailureWrite) -> None:
"""持久化一次失败事实,完成提交后不暴露数据库记录。"""
...
+102 -12
View File
@@ -1,16 +1,17 @@
import json
import re
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Optional, Protocol
from typing import Any, Optional, Protocol, Union
from app.schemas.mediaserver import MediaServerItem as _SchemaMediaServerItem
from app.schemas.mediaserver import MediaServerItemUserState as _SchemaMediaServerItemUserState
from app.application.service import ServiceBaseHelper
from app.domain.context import MusicInfo
from app.runtime.log import logger
from app.schemas.media import normalize_media_source, resolve_media_identity
from app.application.service import ServiceBaseHelper
from app.schemas.system import MediaServerConf
from app.schemas.system import ServiceInfo
from app.schemas.mediaserver import MediaServerItem as _SchemaMediaServerItem
from app.schemas.mediaserver import MediaServerItemUserState as _SchemaMediaServerItemUserState
from app.schemas.system import MediaServerConf, ServiceInfo
from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
MediaSource,
@@ -19,16 +20,106 @@ from app.schemas.types import (
)
@dataclass(frozen=True, slots=True)
class MediaServerSyncItem:
"""媒体库同步写端使用的脱离远端响应与数据库会话的冻结条目。"""
server: str
library: Optional[str]
item_id: str
item_type: Optional[str]
title: Optional[str]
original_title: Optional[str]
year: Optional[Union[str, int]]
media_source: Optional[MediaSource]
media_id: Optional[str]
path: Optional[str]
seasoninfo: tuple[tuple[int, tuple[int, ...]], ...]
note_json: Optional[str]
lst_mod_date: str
@classmethod
def from_item(
cls,
item: _SchemaMediaServerItem,
*,
item_type: Optional[str],
seasoninfo: Mapping[int, Optional[list[int]]],
sync_time: str,
) -> "MediaServerSyncItem":
"""冻结远端媒体条目和本轮剧集快照,供短事务持久化。"""
return cls(
server=str(item.server or ""),
library=str(item.library) if item.library is not None else None,
item_id=str(item.item_id or ""),
item_type=item_type,
title=item.title,
original_title=item.original_title,
year=item.year,
media_source=item.media_source,
media_id=item.media_id,
path=item.path,
seasoninfo=tuple(
(season, tuple(episodes or ()))
for season, episodes in seasoninfo.items()
),
note_json=(
json.dumps(item.note, ensure_ascii=False)
if item.note is not None
else None
),
lst_mod_date=sync_time,
)
class MediaServerRepository(Protocol):
"""Chain 查询与同步媒体服务器本地缓存所需的最小持久化端口。"""
def get_item_id(
self,
*,
title: Optional[str] = None,
year: Optional[Union[str, int]] = None,
mtype: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
season: Optional[int] = None,
) -> Optional[str]:
"""返回匹配条目的服务器 item_id,未命中时返回 None。"""
...
def upsert(self, item: MediaServerSyncItem) -> bool:
"""在短事务中新增或更新一个冻结媒体条目。"""
...
def delete_stale(self, *, server: str, sync_time: str) -> int:
"""删除指定服务器本轮同步未更新的本地条目。"""
...
def delete_excluded_servers(self, servers: list[str]) -> int:
"""删除已停用或已移除服务器的本地条目。"""
...
class AsyncMediaServerQueryRepository(Protocol):
"""媒体服务器本地条目查询所需的异步持久化端口。"""
async def async_exists(self, **kwargs: Any) -> Any | None:
"""按标题或统一媒体身份查找已同步条目。"""
async def async_get_item_id(
self,
*,
title: Optional[str] = None,
year: Optional[Union[str, int]] = None,
mtype: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
season: Optional[int] = None,
) -> Optional[str]:
"""按标题或统一媒体身份返回已同步条目的标量 ID。"""
...
class MediaServerQueryService:
"""封装媒体服务器本地存在性查询与 ORM 投影"""
"""封装媒体服务器本地存在性标量查询。"""
def __init__(self, repository: AsyncMediaServerQueryRepository):
"""使用显式媒体服务器查询端口初始化服务。"""
@@ -38,14 +129,14 @@ class MediaServerQueryService:
self,
*,
title: Optional[str] = None,
year: Optional[str] = None,
year: Optional[Union[str, int]] = None,
mtype: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
season: Optional[int] = None,
) -> Optional[str]:
"""返回匹配条目的服务器 item_id,未命中时返回 None。"""
item = await self._repository.async_exists(
return await self._repository.async_get_item_id(
title=title,
year=year,
mtype=mtype,
@@ -53,7 +144,6 @@ class MediaServerQueryService:
media_id=media_id,
season=season,
)
return item.item_id if item else None
class MediaServerIdentityHelper: