mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 00:16:57 +08:00
refactor(chain): type download and media server ports
This commit is contained in:
@@ -10,10 +10,14 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from 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()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""下载失败冷却记录的类型化应用契约。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Protocol, Union
|
||||
|
||||
from app.schemas.types import MediaSource
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DownloadFailureWrite:
|
||||
"""一次下载失败冷却写入所需的完整稳定数据。"""
|
||||
|
||||
fingerprint: str
|
||||
failed_at: str
|
||||
next_retry_at: str
|
||||
media_type: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
year: Optional[str] = None
|
||||
media_source: Optional[MediaSource] = None
|
||||
media_id: Optional[str] = None
|
||||
seasons: Optional[str] = None
|
||||
episodes: Optional[str] = None
|
||||
site: Optional[int] = None
|
||||
site_name: Optional[str] = None
|
||||
torrent_id: Optional[str] = None
|
||||
torrent_name: Optional[str] = None
|
||||
torrent_size: Optional[Union[float, int]] = None
|
||||
downloader: Optional[str] = None
|
||||
source: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DownloadFailureSnapshot:
|
||||
"""脱离数据库会话后供资源冷却判断使用的只读快照。"""
|
||||
|
||||
fingerprint: str
|
||||
error_message: Optional[str]
|
||||
next_retry_at: Optional[str]
|
||||
|
||||
|
||||
class DownloadFailureRepository(Protocol):
|
||||
"""下载链读写失败冷却状态所需的最小持久化端口。"""
|
||||
|
||||
def get_active_by_fingerprints(
|
||||
self,
|
||||
fingerprints: list[str],
|
||||
now_time: str,
|
||||
) -> dict[str, DownloadFailureSnapshot]:
|
||||
"""返回仍处于冷却期的不可变失败快照。"""
|
||||
...
|
||||
|
||||
def record_failure(self, failure: DownloadFailureWrite) -> None:
|
||||
"""持久化一次失败事实,完成提交后不暴露数据库记录。"""
|
||||
...
|
||||
+102
-12
@@ -1,16 +1,17 @@
|
||||
import json
|
||||
import re
|
||||
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:
|
||||
|
||||
+54
-35
@@ -6,7 +6,7 @@ import re
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple, Union
|
||||
from typing import Dict, List, Optional, Set, Tuple, Union
|
||||
from urllib.parse import parse_qs, urlencode, urljoin, urlparse
|
||||
|
||||
from app.adapters.network.http import RequestUtils
|
||||
@@ -19,6 +19,11 @@ from app.application.chain.data import (
|
||||
from app.application.configuration import get_chain_runtime_config_snapshot
|
||||
from app.application.directory import DirectoryHelper, validate_download_save_path
|
||||
from app.application.download import selection as _selection
|
||||
from app.application.download.failures import (
|
||||
DownloadFailureRepository,
|
||||
DownloadFailureSnapshot,
|
||||
DownloadFailureWrite,
|
||||
)
|
||||
from app.application.download.tasks import DownloadTaskService
|
||||
from app.application.torrent import TorrentHelper
|
||||
from app.chain import ChainBase
|
||||
@@ -63,12 +68,6 @@ from app.schemas.types import (
|
||||
)
|
||||
from app.schemas.workflow import FileItem as _SchemaFileItem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
DownloadFailure = Any
|
||||
|
||||
|
||||
DOWNLOAD_FAILURE_RESOURCE_TTL_SECONDS = 24 * 60 * 60
|
||||
DOWNLOAD_FAILURE_TRANSIENT_TTL_SECONDS = 60 * 60
|
||||
DOWNLOAD_FAILURE_RESOURCE_ERROR_KEYWORDS = (
|
||||
@@ -880,25 +879,40 @@ class DownloadChain(ChainBase):
|
||||
torrent = context.torrent_info
|
||||
site = getattr(torrent, "site", None)
|
||||
try:
|
||||
get_chain_download_failure_port().record_failure(
|
||||
fingerprint=fingerprint,
|
||||
now_time=now_time,
|
||||
next_retry_at=next_retry_at,
|
||||
type=getattr(getattr(media, "type", None), "value", getattr(media, "type", None)),
|
||||
title=getattr(media, "title", None),
|
||||
year=getattr(media, "year", None),
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
seasons=getattr(meta, "season", None),
|
||||
episodes=episode_rules.format_ranges(list(episodes)) if episodes else self._format_failure_episodes(meta),
|
||||
site=site if isinstance(site, int) else None,
|
||||
site_name=getattr(torrent, "site_name", None),
|
||||
torrent_id=self._torrent_resource_key(torrent),
|
||||
torrent_name=getattr(torrent, "title", None),
|
||||
torrent_size=getattr(torrent, "size", None),
|
||||
downloader=downloader,
|
||||
source=str(source)[:1000] if source else None,
|
||||
error_message=str(error_msg or "")[:1000],
|
||||
repository: DownloadFailureRepository = get_chain_download_failure_port()
|
||||
repository.record_failure(
|
||||
DownloadFailureWrite(
|
||||
fingerprint=fingerprint,
|
||||
failed_at=now_time,
|
||||
next_retry_at=next_retry_at,
|
||||
media_type=getattr(
|
||||
getattr(media, "type", None),
|
||||
"value",
|
||||
getattr(media, "type", None),
|
||||
),
|
||||
title=getattr(media, "title", None),
|
||||
year=getattr(media, "year", None),
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
seasons=(
|
||||
str(getattr(meta, "season", None))
|
||||
if getattr(meta, "season", None) is not None
|
||||
else None
|
||||
),
|
||||
episodes=(
|
||||
episode_rules.format_ranges(list(episodes))
|
||||
if episodes
|
||||
else self._format_failure_episodes(meta)
|
||||
),
|
||||
site=site if isinstance(site, int) else None,
|
||||
site_name=getattr(torrent, "site_name", None),
|
||||
torrent_id=self._torrent_resource_key(torrent),
|
||||
torrent_name=getattr(torrent, "title", None),
|
||||
torrent_size=getattr(torrent, "size", None),
|
||||
downloader=downloader,
|
||||
source=str(source)[:1000] if source else None,
|
||||
error_message=str(error_msg or "")[:1000],
|
||||
)
|
||||
)
|
||||
except Exception as err:
|
||||
logger.error(f"记录下载失败冷却失败:{str(err)}")
|
||||
@@ -908,7 +922,7 @@ class DownloadChain(ChainBase):
|
||||
self,
|
||||
contexts: List[Context],
|
||||
source: Optional[str],
|
||||
) -> Dict[str, "DownloadFailure"]:
|
||||
) -> Dict[str, DownloadFailureSnapshot]:
|
||||
"""
|
||||
查询当前订阅候选中仍处于冷却期的失败记录,返回指纹到失败记录的映射。
|
||||
"""
|
||||
@@ -926,7 +940,8 @@ class DownloadChain(ChainBase):
|
||||
return {}
|
||||
now_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||
try:
|
||||
return get_chain_download_failure_port().get_active_by_fingerprints(
|
||||
repository: DownloadFailureRepository = get_chain_download_failure_port()
|
||||
return repository.get_active_by_fingerprints(
|
||||
fingerprints=fingerprints, now_time=now_time,
|
||||
)
|
||||
except Exception as err:
|
||||
@@ -936,7 +951,7 @@ class DownloadChain(ChainBase):
|
||||
@staticmethod
|
||||
def _log_download_failure_cooldown(
|
||||
context: Context,
|
||||
failure: Optional["DownloadFailure"],
|
||||
failure: Optional[DownloadFailureSnapshot],
|
||||
) -> None:
|
||||
"""记录候选资源处于失败冷却期时的跳过原因和下次重试时间。"""
|
||||
reason = getattr(failure, "error_message", None) or "未知原因"
|
||||
@@ -957,7 +972,7 @@ class DownloadChain(ChainBase):
|
||||
contexts: List[Context],
|
||||
downloader: Optional[str],
|
||||
source: Optional[str],
|
||||
) -> Tuple[List[Context], Dict[str, "DownloadFailure"]]:
|
||||
) -> Tuple[List[Context], Dict[str, Optional[DownloadFailureSnapshot]]]:
|
||||
"""
|
||||
执行批量下载前的资源选择、排序和失败冷却准备。
|
||||
|
||||
@@ -979,16 +994,20 @@ class DownloadChain(ChainBase):
|
||||
)
|
||||
contexts = event_data.updated_contexts
|
||||
contexts = TorrentHelper().sort_torrents(contexts)
|
||||
return contexts, self._active_download_failure_fingerprints(
|
||||
contexts=contexts,
|
||||
source=source,
|
||||
)
|
||||
active_failures: Dict[str, Optional[DownloadFailureSnapshot]] = {
|
||||
fingerprint: failure
|
||||
for fingerprint, failure in self._active_download_failure_fingerprints(
|
||||
contexts=contexts,
|
||||
source=source,
|
||||
).items()
|
||||
}
|
||||
return contexts, active_failures
|
||||
|
||||
def _download_movie_music_candidates(
|
||||
self,
|
||||
contexts: List[Context],
|
||||
downloaded_list: List[Context],
|
||||
active_failure_records: Dict[str, "DownloadFailure"],
|
||||
active_failure_records: Dict[str, Optional[DownloadFailureSnapshot]],
|
||||
save_path: Optional[str],
|
||||
channel: Optional[NotificationChannel],
|
||||
source: Optional[str],
|
||||
|
||||
+26
-16
@@ -3,7 +3,11 @@ from datetime import datetime
|
||||
from typing import Any, Callable, Dict, Generator, List, Optional, Tuple, Union
|
||||
|
||||
from app.application.chain.data import get_chain_media_server_port
|
||||
from app.application.mediaserver import get_mediaserver_configs
|
||||
from app.application.mediaserver import (
|
||||
MediaServerRepository,
|
||||
MediaServerSyncItem,
|
||||
get_mediaserver_configs,
|
||||
)
|
||||
from app.application.security.url import SecurityUtils
|
||||
from app.chain import ChainBase
|
||||
from app.runtime.log import logger
|
||||
@@ -271,6 +275,7 @@ class MediaServerChain(ChainBase):
|
||||
self,
|
||||
mediaservers: List[Any],
|
||||
server: Optional[str],
|
||||
repository: MediaServerRepository,
|
||||
) -> Tuple[List[Any], int, Dict[str, Any], Optional[int]]:
|
||||
"""
|
||||
准备启用媒体服务器的同步库和进度总量。
|
||||
@@ -281,8 +286,7 @@ class MediaServerChain(ChainBase):
|
||||
item.name for item in mediaservers
|
||||
if item and item.enabled and item.name
|
||||
]
|
||||
dboper = get_chain_media_server_port()
|
||||
dboper.delete_excluded_servers(enabled_servers)
|
||||
repository.delete_excluded_servers(enabled_servers)
|
||||
selected_servers = [
|
||||
item for item in mediaservers
|
||||
if item and item.enabled and (not server or item.name == server)
|
||||
@@ -332,7 +336,7 @@ class MediaServerChain(ChainBase):
|
||||
server_name: str,
|
||||
selected_libraries: List[Any],
|
||||
library_media_counts: Dict[str, Optional[int]],
|
||||
dboper: Any,
|
||||
repository: MediaServerRepository,
|
||||
sync_time: str,
|
||||
progress_callback: Optional[Callable[..., None]],
|
||||
server_index: int,
|
||||
@@ -363,14 +367,17 @@ class MediaServerChain(ChainBase):
|
||||
item_type = self._normalize_item_type(item.item_type)
|
||||
if item_type == MediaType.TV.value:
|
||||
for episode in self.episodes(server_name, item.item_id) or []:
|
||||
seasoninfo[episode.season] = episode.episodes
|
||||
item_dict = item.model_dump()
|
||||
item_dict.update({
|
||||
"seasoninfo": seasoninfo,
|
||||
"item_type": item_type,
|
||||
"lst_mod_date": sync_time,
|
||||
})
|
||||
dboper.upsert(**item_dict)
|
||||
if episode.season is None:
|
||||
continue
|
||||
seasoninfo[episode.season] = episode.episodes or []
|
||||
repository.upsert(
|
||||
MediaServerSyncItem.from_item(
|
||||
item,
|
||||
item_type=item_type,
|
||||
seasoninfo=seasoninfo,
|
||||
sync_time=sync_time,
|
||||
)
|
||||
)
|
||||
if progress_callback:
|
||||
if global_media_total:
|
||||
progress_value = min(global_media_finished / global_media_total, 1) * 100
|
||||
@@ -423,7 +430,10 @@ class MediaServerChain(ChainBase):
|
||||
"media_finished": global_media_finished,
|
||||
},
|
||||
)
|
||||
stale_count = dboper.delete_stale(server=server_name, sync_time=sync_time)
|
||||
stale_count = repository.delete_stale(
|
||||
server=server_name,
|
||||
sync_time=sync_time,
|
||||
)
|
||||
logger.info(f"媒体服务器 {server_name} 清理陈旧数据完成,删除数量:{stale_count}")
|
||||
return total_count, global_media_finished
|
||||
|
||||
@@ -463,9 +473,9 @@ class MediaServerChain(ChainBase):
|
||||
with lock:
|
||||
# 汇总统计
|
||||
total_count = 0
|
||||
dboper = get_chain_media_server_port()
|
||||
repository = get_chain_media_server_port()
|
||||
mediaservers, total_servers, server_sync_contexts, global_media_total = (
|
||||
self._prepare_sync_contexts(mediaservers, server)
|
||||
self._prepare_sync_contexts(mediaservers, server, repository)
|
||||
)
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
@@ -543,7 +553,7 @@ class MediaServerChain(ChainBase):
|
||||
server_name=server_name,
|
||||
selected_libraries=selected_libraries,
|
||||
library_media_counts=library_media_counts,
|
||||
dboper=dboper,
|
||||
repository=repository,
|
||||
sync_time=sync_time,
|
||||
progress_callback=progress_callback,
|
||||
server_index=server_index,
|
||||
|
||||
+39
-22
@@ -3,8 +3,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, cast
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.application.download.failures import (
|
||||
DownloadFailureSnapshot,
|
||||
DownloadFailureWrite,
|
||||
)
|
||||
from app.db.oper.downloadfailure import DownloadFailureOper
|
||||
from app.db.uow import SqlAlchemyUnitOfWork
|
||||
|
||||
@@ -12,7 +17,7 @@ from app.db.uow import SqlAlchemyUnitOfWork
|
||||
class TransactionalDownloadFailureRepository:
|
||||
"""为 Chain 下载失败读写创建短生命周期会话并显式收口事务。"""
|
||||
|
||||
def __init__(self, session_factory: Callable[[], Any]) -> None:
|
||||
def __init__(self, session_factory: Callable[[], Session]) -> None:
|
||||
"""保存由启动组合根提供的同步会话工厂。"""
|
||||
self._session_factory = session_factory
|
||||
|
||||
@@ -20,36 +25,48 @@ class TransactionalDownloadFailureRepository:
|
||||
self,
|
||||
fingerprints: list[str],
|
||||
now_time: str,
|
||||
) -> dict[str, Any]:
|
||||
) -> dict[str, DownloadFailureSnapshot]:
|
||||
"""在独立只读会话中查询仍处于冷却期的失败记录。"""
|
||||
with self._session_factory() as session:
|
||||
return cast(
|
||||
dict[str, Any],
|
||||
DownloadFailureOper(db=session).get_active_by_fingerprints(
|
||||
fingerprints=fingerprints,
|
||||
now_time=now_time,
|
||||
),
|
||||
records = DownloadFailureOper(db=session).get_active_by_fingerprints(
|
||||
fingerprints=fingerprints,
|
||||
now_time=now_time,
|
||||
)
|
||||
return {
|
||||
fingerprint: DownloadFailureSnapshot(
|
||||
fingerprint=record.fingerprint,
|
||||
error_message=record.error_message,
|
||||
next_retry_at=record.next_retry_at,
|
||||
)
|
||||
for fingerprint, record in records.items()
|
||||
}
|
||||
|
||||
def record_failure(
|
||||
self,
|
||||
fingerprint: str,
|
||||
now_time: str,
|
||||
next_retry_at: str,
|
||||
**kwargs: object,
|
||||
) -> Any:
|
||||
def record_failure(self, failure: DownloadFailureWrite) -> None:
|
||||
"""在一个显式 UoW 中新增或更新下载失败记录。"""
|
||||
with self._session_factory() as session:
|
||||
transaction = SqlAlchemyUnitOfWork(session)
|
||||
try:
|
||||
failure = DownloadFailureOper(db=session).record_failure(
|
||||
fingerprint=fingerprint,
|
||||
now_time=now_time,
|
||||
next_retry_at=next_retry_at,
|
||||
**kwargs,
|
||||
DownloadFailureOper(db=session).record_failure(
|
||||
fingerprint=failure.fingerprint,
|
||||
now_time=failure.failed_at,
|
||||
next_retry_at=failure.next_retry_at,
|
||||
type=failure.media_type,
|
||||
title=failure.title,
|
||||
year=failure.year,
|
||||
media_source=failure.media_source,
|
||||
media_id=failure.media_id,
|
||||
seasons=failure.seasons,
|
||||
episodes=failure.episodes,
|
||||
site=failure.site,
|
||||
site_name=failure.site_name,
|
||||
torrent_id=failure.torrent_id,
|
||||
torrent_name=failure.torrent_name,
|
||||
torrent_size=failure.torrent_size,
|
||||
downloader=failure.downloader,
|
||||
source=failure.source,
|
||||
error_message=failure.error_message,
|
||||
)
|
||||
transaction.commit()
|
||||
return failure
|
||||
except Exception:
|
||||
transaction.rollback()
|
||||
raise
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""媒体服务器本地缓存的显式短会话与事务适配器。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from typing import Optional, TypeVar, Union
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.application.mediaserver import MediaServerSyncItem
|
||||
from app.db.oper.mediaserver import MediaServerOper
|
||||
from app.db.uow import SqlAlchemyUnitOfWork
|
||||
from app.schemas.types import MediaSource
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class TransactionalMediaServerRepository:
|
||||
"""让每次媒体库缓存查询或变更各自拥有一个短生命周期 Session。"""
|
||||
|
||||
def __init__(self, session_factory: Callable[[], Session]) -> None:
|
||||
"""保存由启动组合根提供的同步会话工厂。"""
|
||||
self._session_factory = session_factory
|
||||
|
||||
def _read(self, operation: Callable[[MediaServerOper], T]) -> T:
|
||||
"""在独立只读会话中执行媒体库缓存查询。"""
|
||||
with self._session_factory() as session:
|
||||
return operation(MediaServerOper(db=session))
|
||||
|
||||
def _write(self, operation: Callable[[MediaServerOper], T]) -> T:
|
||||
"""在独立 UoW 中执行并提交一项媒体库缓存变更。"""
|
||||
with self._session_factory() as session:
|
||||
unit_of_work = SqlAlchemyUnitOfWork(session)
|
||||
try:
|
||||
result = operation(MediaServerOper(db=session))
|
||||
unit_of_work.commit()
|
||||
return result
|
||||
except Exception:
|
||||
unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
def get_item_id(
|
||||
self,
|
||||
*,
|
||||
title: Optional[str] = None,
|
||||
year: Optional[Union[str, int]] = None,
|
||||
mtype: Optional[str] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
season: Optional[int] = None,
|
||||
) -> Optional[str]:
|
||||
"""在短会话中返回匹配条目的服务器 item_id。"""
|
||||
item_id: Optional[str] = self._read(
|
||||
lambda repository: repository.get_item_id(
|
||||
title=title,
|
||||
year=year,
|
||||
mtype=mtype,
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
season=season,
|
||||
)
|
||||
)
|
||||
return item_id
|
||||
|
||||
def upsert(self, item: MediaServerSyncItem) -> bool:
|
||||
"""在单个短事务中新增或更新一个媒体库同步条目。"""
|
||||
payload = {
|
||||
"server": item.server,
|
||||
"library": item.library,
|
||||
"item_id": item.item_id,
|
||||
"item_type": item.item_type,
|
||||
"title": item.title,
|
||||
"original_title": item.original_title,
|
||||
"year": item.year,
|
||||
"media_source": item.media_source,
|
||||
"media_id": item.media_id,
|
||||
"path": item.path,
|
||||
"seasoninfo": {
|
||||
season: list(episodes)
|
||||
for season, episodes in item.seasoninfo
|
||||
},
|
||||
"note": (
|
||||
json.loads(item.note_json)
|
||||
if item.note_json is not None
|
||||
else None
|
||||
),
|
||||
"lst_mod_date": item.lst_mod_date,
|
||||
}
|
||||
created: bool = self._write(
|
||||
lambda repository: repository.upsert(**payload)
|
||||
)
|
||||
return created
|
||||
|
||||
def delete_stale(self, *, server: str, sync_time: str) -> int:
|
||||
"""在短事务中删除指定服务器本轮未更新的条目。"""
|
||||
deleted: int = self._write(
|
||||
lambda repository: repository.delete_stale(
|
||||
server=server,
|
||||
sync_time=sync_time,
|
||||
),
|
||||
)
|
||||
return deleted
|
||||
|
||||
def delete_excluded_servers(self, servers: list[str]) -> int:
|
||||
"""在短事务中删除已停用或已移除服务器的条目。"""
|
||||
deleted: int = self._write(
|
||||
lambda repository: repository.delete_excluded_servers(servers)
|
||||
)
|
||||
return deleted
|
||||
@@ -29,6 +29,12 @@ class MediaServerOper(DbOper):
|
||||
if hasattr(MediaServerItem, k) and k != "id"
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _contains_season(item: MediaServerItem, season: int) -> bool:
|
||||
"""兼容 JSON 往返后变为字符串的 seasoninfo 键。"""
|
||||
seasoninfo = item.seasoninfo or {}
|
||||
return season in seasoninfo or str(season) in seasoninfo
|
||||
|
||||
def add(self, **kwargs) -> bool:
|
||||
"""
|
||||
新增媒体服务器数据
|
||||
@@ -132,10 +138,7 @@ class MediaServerOper(DbOper):
|
||||
|
||||
if kwargs.get("season") is not None:
|
||||
# 判断季是否存在
|
||||
if not item.seasoninfo:
|
||||
return None
|
||||
seasoninfo = item.seasoninfo or {}
|
||||
if kwargs.get("season") not in seasoninfo.keys():
|
||||
if not self._contains_season(item, kwargs["season"]):
|
||||
return None
|
||||
return item
|
||||
|
||||
@@ -169,10 +172,7 @@ class MediaServerOper(DbOper):
|
||||
|
||||
if kwargs.get("season") is not None:
|
||||
# 判断季是否存在
|
||||
if not item.seasoninfo:
|
||||
return None
|
||||
seasoninfo = item.seasoninfo or {}
|
||||
if kwargs.get("season") not in seasoninfo.keys():
|
||||
if not self._contains_season(item, kwargs["season"]):
|
||||
return None
|
||||
return item
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ from app.application.workflow import (
|
||||
from app.command import CommandChain
|
||||
from app.db.adapters.chain import TransactionalChainDurableEventWriter
|
||||
from app.db.adapters.download import TransactionalDownloadFailureRepository
|
||||
from app.db.adapters.mediaserver import TransactionalMediaServerRepository
|
||||
from app.db.adapters.outbox import SqlAlchemyAsyncOutboxStager, SqlAlchemyOutboxRepository
|
||||
from app.db.adapters.query import SqlAlchemyDataQueryAdapter
|
||||
from app.db.adapters.site import TransactionalSiteRepository
|
||||
@@ -892,7 +893,7 @@ async def init_modules() -> HostRuntime:
|
||||
transfer_execution=lambda: TransactionalTransferExecutionRepository(
|
||||
SessionFactory
|
||||
),
|
||||
media_server=lambda: MediaServerOper(),
|
||||
media_server=lambda: TransactionalMediaServerRepository(SessionFactory),
|
||||
download_failure=lambda: TransactionalDownloadFailureRepository(
|
||||
SessionFactory
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user