Files
MoviePilot/app/application/transfer/workflow.py
T

2091 lines
84 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.
"""
整理任务:整理链的进程内工作项。
TransferTask 此前住在 app/schemas/transfer.py,但它不是出网的 DTO——meta 装的是领域侧
的 MetaBase 子类,mediainfo 装的是领域侧的 MediaInfo / MusicInfo,都带行为而非纯数据。
放在 app.schemas 的代价是它没法命名自己真正装的类型:app.schemas 一旦 import 领域类型,
app.schemas -> app.schemas.transfer -> app.domain.* -> app.schemas.types -> app.schemas
就闭环,仓库自己的 test_migrated_modules_are_not_in_import_cycles 会红(已实测)。于是
两个字段只能标成 Optional[Any],把「这里到底能放什么」这件事整个交给了口头约定。
搬到应用层就没有这个约束:app.application 允许依赖 app.domain 与 app.schemas,两个
字段因此能标出真实类型。它面向前端的投影仍是 app/schemas/transfer.py 里的
TransferJob / TransferJobTask,那两个用 app.schemas 的同名 DTO——一个是工作项,一个是
视图,分开表达之后两边都不必再迁就对方。
"""
import asyncio
import hashlib
import json
import threading
from copy import deepcopy
from dataclasses import dataclass, field
from pathlib import Path
from time import monotonic
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
List,
Optional,
Protocol,
Tuple,
TypeAlias,
Union,
cast,
)
from pydantic import BaseModel, ConfigDict, PrivateAttr
from app.adapters.system.host import SystemUtils
from app.application.agent import get_prompt_manager, get_running_agent_manager
from app.application.transfer.execution import TransferExecutionCheckpoint
from app.domain.context import MediaInfo, MusicInfo
from app.domain.media import normalize_music_type
from app.domain.meta.metabase import MetaBase
from app.domain.meta.metamusic import MetaMusic
from app.foundation import text as text_tools
from app.runtime.log import logger
from app.schemas.context import MediaInfo as _SchemaMediaInfo
from app.schemas.context import MetaInfo as _SchemaMetaInfo
from app.schemas.file import FileItem
from app.schemas.history import DownloadHistory
from app.schemas.media import OptionalMediaIdentityMixin, resolve_media_identity
from app.schemas.music import MusicInfo as _SchemaMusicInfo
from app.schemas.music import MusicMeta as _SchemaMusicMeta
from app.schemas.system import TransferDirectoryConf
from app.schemas.tmdb import TmdbEpisode
from app.schemas.transfer import TransferInfo, TransferJob, TransferJobTask
from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_RECORDING,
MediaSource,
MediaType,
ReplyMode,
)
if TYPE_CHECKING:
class _ApplicationModel:
"""描述当前模块依赖的最小 Pydantic 模型类型形状。"""
def __init__(self, **data: Any) -> None:
"""接受模型字段关键字参数。"""
raise NotImplementedError
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
"""返回模型字段字典。"""
raise NotImplementedError
else:
_ApplicationModel = BaseModel
JSONValue: TypeAlias = Union[None, bool, int, float, str, list["JSONValue"], dict[str, "JSONValue"]]
JobId: TypeAlias = tuple[object, ...]
FileKey: TypeAlias = tuple[str, str]
class _DictionarySerializable(Protocol):
"""描述领域对象沿用的字典投影能力。"""
def to_dict(self) -> dict[str, Any]:
"""返回领域对象的字典投影。"""
def _domain_to_dict(value: object) -> dict[str, Any]:
"""按领域对象既有 ``to_dict`` 合同生成字典投影。"""
return cast(_DictionarySerializable, value).to_dict()
def _job_tasks(job: TransferJob) -> list[TransferJobTask]:
"""声明进程内作业始终使用已初始化的任务列表。"""
return cast(list[TransferJobTask], job.tasks)
def _job_task_fileitem(task: TransferJobTask) -> FileItem:
"""声明进程内作业任务始终绑定源文件。"""
return cast(FileItem, task.fileitem)
def _job_task_size(task: TransferJobTask) -> int:
"""按既有本地目录回退规则返回已完成任务的文件大小。"""
fileitem = _job_task_fileitem(task)
if fileitem.size is not None:
return fileitem.size
if fileitem.storage == "local":
return SystemUtils.get_directory_size(Path(cast(str, fileitem.path)))
return 0
def _transfer_task_meta(task: "TransferTask") -> MetaBase:
"""声明进入作业管理器的整理任务已经完成元数据解析。"""
return cast(MetaBase, task.meta)
TRANSFER_ADMISSION_ACCEPTED = "accepted"
TRANSFER_ADMISSION_PROVIDER_PENDING = "provider_pending"
TRANSFER_ADMISSION_PLANNED = "planned"
TRANSFER_PLANNING_INPUT_VERSION = 1
TRANSFER_PLAN_CHECKPOINT_VERSION = 1
TRANSFER_PROVIDER_INVOCATION_VERSION = 1
def _copy_json_mapping(value: Optional[dict[str, JSONValue]]) -> Optional[dict[str, JSONValue]]:
if value is None:
return None
return deepcopy(value)
def _read_json_mapping(payload: dict[str, Any], key: str) -> Optional[dict[str, JSONValue]]:
value = payload.get(key)
if value is None:
return None
if not isinstance(value, dict):
raise ValueError(f"整理计划字段 {key} 必须是 JSON 对象")
return deepcopy(value)
def _read_json_tuple(payload: dict[str, Any], key: str) -> tuple[dict[str, JSONValue], ...]:
value = payload.get(key, [])
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
raise ValueError(f"整理计划字段 {key} 必须是 JSON 对象数组")
return tuple(deepcopy(item) for item in value)
def _canonical_json(payload: dict[str, JSONValue]) -> str:
try:
return json.dumps(
payload,
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
)
except (TypeError, ValueError) as error:
raise ValueError("整理计划只能包含有限 JSON 值") from error
@dataclass(frozen=True, slots=True)
class TransferPlanningInput:
"""保存可跨重启重放的版本化整理规划输入。"""
source_fileitem: dict[str, JSONValue]
meta: Optional[dict[str, JSONValue]] = None
mediainfo: Optional[dict[str, JSONValue]] = None
target_directory: Optional[dict[str, JSONValue]] = None
target_storage: Optional[str] = None
target_path: Optional[str] = None
requested_transfer_type: Optional[str] = None
media_source: Optional[str] = None
media_id: Optional[str] = None
media_type: Optional[str] = None
need_scrape: bool = False
need_rename: bool = True
need_notify: bool = True
overwrite_mode: Optional[str] = None
episodes_info: tuple[dict[str, JSONValue], ...] = ()
preview: bool = False
options: dict[str, JSONValue] = field(default_factory=dict)
schema_version: int = TRANSFER_PLANNING_INPUT_VERSION
def __post_init__(self) -> None:
"""拒绝不可恢复的输入版本或缺少源文件身份的快照。"""
if self.schema_version != TRANSFER_PLANNING_INPUT_VERSION:
raise ValueError(f"不支持的整理规划输入版本: {self.schema_version}")
object.__setattr__(self, "source_fileitem", deepcopy(self.source_fileitem))
object.__setattr__(self, "meta", _copy_json_mapping(self.meta))
object.__setattr__(self, "mediainfo", _copy_json_mapping(self.mediainfo))
object.__setattr__(
self,
"target_directory",
_copy_json_mapping(self.target_directory),
)
object.__setattr__(
self,
"episodes_info",
tuple(deepcopy(item) for item in self.episodes_info),
)
object.__setattr__(self, "options", deepcopy(self.options))
storage = self.source_fileitem.get("storage")
path = self.source_fileitem.get("path")
if not isinstance(storage, str) or not storage or not isinstance(path, str) or not path:
raise ValueError("整理规划输入缺少源文件存储或路径")
_canonical_json(self.to_payload())
@classmethod
def legacy(cls, *, storage: str, src_path: str) -> "TransferPlanningInput":
"""为升级前只有存储与路径的登记构造保守重规划输入。"""
return cls(
source_fileitem={"storage": storage, "path": src_path},
options={"legacy_replan": True},
)
@classmethod
def from_payload(cls, payload: dict[str, Any]) -> "TransferPlanningInput":
"""从受版本约束的 JSON 对象恢复规划输入。"""
if not isinstance(payload, dict):
raise ValueError("整理规划输入必须是 JSON 对象")
source_fileitem = _read_json_mapping(payload, "source_fileitem")
if source_fileitem is None:
raise ValueError("整理规划输入缺少 source_fileitem")
return cls(
source_fileitem=source_fileitem,
meta=_read_json_mapping(payload, "meta"),
mediainfo=_read_json_mapping(payload, "mediainfo"),
target_directory=_read_json_mapping(payload, "target_directory"),
target_storage=payload.get("target_storage"),
target_path=payload.get("target_path"),
requested_transfer_type=payload.get("requested_transfer_type"),
media_source=payload.get("media_source"),
media_id=payload.get("media_id"),
media_type=payload.get("media_type"),
need_scrape=payload.get("need_scrape", False),
need_rename=payload.get("need_rename", True),
need_notify=payload.get("need_notify", True),
overwrite_mode=payload.get("overwrite_mode"),
episodes_info=_read_json_tuple(payload, "episodes_info"),
preview=payload.get("preview", False),
options=_read_json_mapping(payload, "options") or {},
schema_version=payload.get("schema_version", 0),
)
def to_payload(self) -> dict[str, JSONValue]:
"""生成仅含 JSON 值且字段稳定的规划输入投影。"""
return {
"schema_version": self.schema_version,
"source_fileitem": deepcopy(self.source_fileitem),
"meta": _copy_json_mapping(self.meta),
"mediainfo": _copy_json_mapping(self.mediainfo),
"target_directory": _copy_json_mapping(self.target_directory),
"target_storage": self.target_storage,
"target_path": self.target_path,
"requested_transfer_type": self.requested_transfer_type,
"media_source": self.media_source,
"media_id": self.media_id,
"media_type": self.media_type,
"need_scrape": self.need_scrape,
"need_rename": self.need_rename,
"need_notify": self.need_notify,
"overwrite_mode": self.overwrite_mode,
"episodes_info": [deepcopy(item) for item in self.episodes_info],
"preview": self.preview,
"options": deepcopy(self.options),
}
@property
def fingerprint(self) -> str:
"""返回规范 JSON 的稳定 SHA-256 指纹。"""
return hashlib.sha256(_canonical_json(self.to_payload()).encode("utf-8")).hexdigest()
@dataclass(frozen=True, slots=True)
class TransferPlanItem:
"""描述规划后按序执行的一条叶子文件操作。"""
sequence: int
source_fileitem: dict[str, JSONValue]
target_storage: str
target_path: str
action: str = "transfer"
def __post_init__(self) -> None:
"""拒绝无法定位源文件或目标文件的计划项。"""
object.__setattr__(self, "source_fileitem", deepcopy(self.source_fileitem))
if self.sequence < 0:
raise ValueError("整理计划项序号不能小于零")
if not self.target_storage or not self.target_path or not self.action:
raise ValueError("整理计划项缺少目标身份或动作")
if not self.source_fileitem.get("storage") or not self.source_fileitem.get("path"):
raise ValueError("整理计划项缺少源文件身份")
_canonical_json(self.to_payload())
@classmethod
def from_payload(cls, payload: dict[str, Any]) -> "TransferPlanItem":
"""从 JSON 对象恢复单条叶子文件操作。"""
source_fileitem = _read_json_mapping(payload, "source_fileitem")
if source_fileitem is None:
raise ValueError("整理计划项缺少 source_fileitem")
return cls(
sequence=payload.get("sequence", -1),
source_fileitem=source_fileitem,
target_storage=payload.get("target_storage", ""),
target_path=payload.get("target_path", ""),
action=payload.get("action", "transfer"),
)
def to_payload(self) -> dict[str, JSONValue]:
"""生成单条叶子文件操作的 JSON 投影。"""
return {
"sequence": self.sequence,
"source_fileitem": deepcopy(self.source_fileitem),
"target_storage": self.target_storage,
"target_path": self.target_path,
"action": self.action,
}
@dataclass(frozen=True, slots=True)
class TransferProviderReference:
"""保存无需依赖运行时 dispatcher 即可持久化的旧 transfer provider 引用。"""
plugin_id: str
plugin_name: str
method: str = "transfer"
def __post_init__(self) -> None:
"""拒绝无法稳定定位插件或指向非 transfer 方法的引用。"""
if not isinstance(self.plugin_id, str) or not self.plugin_id.strip():
raise ValueError("旧 transfer provider 的 plugin_id 必须是非空字符串")
if not isinstance(self.plugin_name, str) or not self.plugin_name.strip():
raise ValueError("旧 transfer provider 的 plugin_name 必须是非空字符串")
if self.method != "transfer":
raise ValueError("旧 transfer provider 的 method 必须是 transfer")
_canonical_json(self.to_payload())
@classmethod
def from_payload(cls, payload: dict[str, Any]) -> "TransferProviderReference":
"""从 JSON 对象恢复旧 transfer provider 引用。"""
if not isinstance(payload, dict):
raise ValueError("旧 transfer provider 引用必须是 JSON 对象")
return cls(
plugin_id=payload.get("plugin_id", ""),
plugin_name=payload.get("plugin_name", ""),
method=payload.get("method", "transfer"),
)
def to_payload(self) -> dict[str, JSONValue]:
"""生成仅包含稳定插件身份与方法名的 JSON 投影。"""
return {
"plugin_id": self.plugin_id,
"plugin_name": self.plugin_name,
"method": self.method,
}
@dataclass(frozen=True, slots=True)
class TransferProviderInvocationSnapshot:
"""冻结旧 transfer ABI 中可跨重启恢复的精确参数值。"""
fileitem: dict[str, JSONValue]
meta: Optional[dict[str, JSONValue]]
meta_kind: Optional[str]
mediainfo: Optional[dict[str, JSONValue]]
mediainfo_kind: Optional[str]
target_directory: Optional[dict[str, JSONValue]] = None
target_storage: Optional[str] = None
target_path: Optional[str] = None
transfer_type: Optional[str] = None
scrape: Optional[bool] = None
library_type_folder: Optional[bool] = None
library_category_folder: Optional[bool] = None
episodes_info: tuple[dict[str, JSONValue], ...] = ()
preview: bool = False
schema_version: int = TRANSFER_PROVIDER_INVOCATION_VERSION
def __post_init__(self) -> None:
"""拒绝缺少源身份、类型不稳定或版本未知的 provider 快照。"""
if self.schema_version != TRANSFER_PROVIDER_INVOCATION_VERSION:
raise ValueError(
f"不支持的旧 transfer provider 调用快照版本: {self.schema_version}"
)
object.__setattr__(self, "fileitem", deepcopy(self.fileitem))
object.__setattr__(self, "meta", _copy_json_mapping(self.meta))
object.__setattr__(self, "mediainfo", _copy_json_mapping(self.mediainfo))
object.__setattr__(
self,
"target_directory",
_copy_json_mapping(self.target_directory),
)
object.__setattr__(
self,
"episodes_info",
tuple(deepcopy(item) for item in self.episodes_info),
)
if not self.fileitem.get("storage") or not self.fileitem.get("path"):
raise ValueError("旧 transfer provider 调用快照缺少源文件身份")
for kind in (self.meta_kind, self.mediainfo_kind):
if kind is not None and (not isinstance(kind, str) or not kind):
raise ValueError("旧 transfer provider 调用快照类型必须是非空字符串")
for value in (
self.target_storage,
self.target_path,
self.transfer_type,
):
if value is not None and not isinstance(value, str):
raise ValueError("旧 transfer provider 可选文本参数必须是字符串或 None")
for bool_value in (
self.scrape,
self.library_type_folder,
self.library_category_folder,
):
if bool_value is not None and not isinstance(bool_value, bool):
raise ValueError("旧 transfer provider 可选布尔参数必须是 bool 或 None")
if not isinstance(self.preview, bool):
raise ValueError("旧 transfer provider preview 参数必须是 bool")
_canonical_json(self.to_payload())
@classmethod
def from_payload(
cls,
payload: dict[str, Any],
) -> "TransferProviderInvocationSnapshot":
"""从版本化 JSON 对象严格恢复旧 transfer ABI 调用快照。"""
if not isinstance(payload, dict):
raise ValueError("旧 transfer provider 调用快照必须是 JSON 对象")
fileitem = _read_json_mapping(payload, "fileitem")
if fileitem is None:
raise ValueError("旧 transfer provider 调用快照缺少 fileitem")
return cls(
fileitem=fileitem,
meta=_read_json_mapping(payload, "meta"),
meta_kind=payload.get("meta_kind"),
mediainfo=_read_json_mapping(payload, "mediainfo"),
mediainfo_kind=payload.get("mediainfo_kind"),
target_directory=_read_json_mapping(payload, "target_directory"),
target_storage=payload.get("target_storage"),
target_path=payload.get("target_path"),
transfer_type=payload.get("transfer_type"),
scrape=payload.get("scrape"),
library_type_folder=payload.get("library_type_folder"),
library_category_folder=payload.get("library_category_folder"),
episodes_info=_read_json_tuple(payload, "episodes_info"),
preview=payload.get("preview", False),
schema_version=payload.get("schema_version", 0),
)
def to_payload(self) -> dict[str, JSONValue]:
"""生成仅含 JSON 值且保留 None 与 False 差异的调用投影。"""
return {
"schema_version": self.schema_version,
"fileitem": deepcopy(self.fileitem),
"meta": _copy_json_mapping(self.meta),
"meta_kind": self.meta_kind,
"mediainfo": _copy_json_mapping(self.mediainfo),
"mediainfo_kind": self.mediainfo_kind,
"target_directory": _copy_json_mapping(self.target_directory),
"target_storage": self.target_storage,
"target_path": self.target_path,
"transfer_type": self.transfer_type,
"scrape": self.scrape,
"library_type_folder": self.library_type_folder,
"library_category_folder": self.library_category_folder,
"episodes_info": [deepcopy(item) for item in self.episodes_info],
"preview": self.preview,
}
@dataclass(frozen=True, slots=True)
class TransferPlanCheckpoint:
"""保存无需重触发识别或重命名即可执行的完整有序计划。"""
planning_input: TransferPlanningInput
target_storage: str
root_target_path: str
final_target_path: str
resolved_transfer_type: str
items: tuple[TransferPlanItem, ...]
resolved_meta: Optional[dict[str, JSONValue]] = None
resolved_meta_kind: Optional[str] = None
resolved_mediainfo: Optional[dict[str, JSONValue]] = None
resolved_mediainfo_kind: Optional[str] = None
resolved_episodes_info: tuple[dict[str, JSONValue], ...] = ()
legacy_transfer_providers: tuple[TransferProviderReference, ...] = ()
provider_invocation: Optional[TransferProviderInvocationSnapshot] = None
pre_execution_cleanup_completed: bool = False
need_scrape: bool = False
need_rename: bool = False
need_notify: bool = True
overwrite_mode: Optional[str] = None
preview: bool = False
skip_reason: Optional[str] = None
schema_version: int = TRANSFER_PLAN_CHECKPOINT_VERSION
def __post_init__(self) -> None:
"""验证版本、目标身份和计划项顺序组成完整检查点。"""
if self.schema_version != TRANSFER_PLAN_CHECKPOINT_VERSION:
raise ValueError(f"不支持的整理计划检查点版本: {self.schema_version}")
if not isinstance(self.pre_execution_cleanup_completed, bool):
raise ValueError("整理计划预执行 cleanup 完成标记必须是 bool")
object.__setattr__(self, "resolved_meta", _copy_json_mapping(self.resolved_meta))
object.__setattr__(
self,
"resolved_mediainfo",
_copy_json_mapping(self.resolved_mediainfo),
)
object.__setattr__(
self,
"resolved_episodes_info",
tuple(deepcopy(item) for item in self.resolved_episodes_info),
)
if not isinstance(self.legacy_transfer_providers, tuple) or any(
not isinstance(provider, TransferProviderReference)
for provider in self.legacy_transfer_providers
):
raise ValueError("整理计划的旧 transfer provider 引用必须是类型化元组")
provider_ids = tuple(
provider.plugin_id for provider in self.legacy_transfer_providers
)
if len(set(provider_ids)) != len(provider_ids):
raise ValueError("整理计划的旧 transfer provider plugin_id 不能重复")
for resolved_kind in (
self.resolved_meta_kind,
self.resolved_mediainfo_kind,
):
if resolved_kind is not None and (
not isinstance(resolved_kind, str) or not resolved_kind
):
raise ValueError("整理计划的已解析上下文类型必须是非空字符串")
if self.provider_invocation is not None:
if not self.legacy_transfer_providers:
raise ValueError("provider_pending 检查点缺少冻结 provider")
if self.pre_execution_cleanup_completed:
raise ValueError("provider_pending 提交时 cleanup 尚未执行")
if (
not self.provider_invocation.meta
or not self.provider_invocation.meta_kind
or not self.provider_invocation.mediainfo
or not self.provider_invocation.mediainfo_kind
):
raise ValueError("provider_pending 检查点缺少可重放的媒体上下文")
if (
self.target_storage
or self.root_target_path
or self.final_target_path
or self.resolved_transfer_type
or self.items
or self.skip_reason
):
raise ValueError("provider_pending 检查点不得包含宿主执行计划")
else:
if not self.target_storage or not self.root_target_path or not self.final_target_path:
raise ValueError("整理计划检查点缺少目标身份")
if not self.resolved_transfer_type:
raise ValueError("整理计划检查点缺少已解析的整理方式")
if tuple(item.sequence for item in self.items) != tuple(range(len(self.items))):
raise ValueError("整理计划项必须按从零开始的连续序号保存")
if (
self.provider_invocation is None
and not self.items
and not self.preview
and not self.skip_reason
):
raise ValueError("非预览空计划必须记录合法跳过原因")
_canonical_json(self.to_payload())
@property
def is_provider_pending(self) -> bool:
"""返回该检查点是否只冻结 provider 调用、尚未生成宿主计划。"""
return self.provider_invocation is not None
@classmethod
def from_payload(cls, payload: dict[str, Any]) -> "TransferPlanCheckpoint":
"""从受版本约束的 JSON 对象恢复完整执行检查点。"""
if not isinstance(payload, dict):
raise ValueError("整理计划检查点必须是 JSON 对象")
planning_input_payload = _read_json_mapping(payload, "planning_input")
if planning_input_payload is None:
raise ValueError("整理计划检查点缺少 planning_input")
item_payloads = payload.get("items", [])
if not isinstance(item_payloads, list) or not all(
isinstance(item, dict) for item in item_payloads
):
raise ValueError("整理计划检查点 items 必须是 JSON 对象数组")
legacy_provider_payloads = payload.get("legacy_transfer_providers", [])
if not isinstance(legacy_provider_payloads, list):
raise ValueError(
"整理计划检查点 legacy_transfer_providers 必须是 JSON 对象数组"
)
provider_invocation_payload = payload.get("provider_invocation")
if provider_invocation_payload is not None and not isinstance(
provider_invocation_payload,
dict,
):
raise ValueError("整理计划检查点 provider_invocation 必须是 JSON 对象")
return cls(
planning_input=TransferPlanningInput.from_payload(planning_input_payload),
target_storage=payload.get("target_storage", ""),
root_target_path=payload.get("root_target_path", ""),
final_target_path=payload.get("final_target_path", ""),
resolved_transfer_type=payload.get("resolved_transfer_type", ""),
items=tuple(TransferPlanItem.from_payload(item) for item in item_payloads),
resolved_meta=_read_json_mapping(payload, "resolved_meta"),
resolved_meta_kind=payload.get("resolved_meta_kind"),
resolved_mediainfo=_read_json_mapping(payload, "resolved_mediainfo"),
resolved_mediainfo_kind=payload.get("resolved_mediainfo_kind"),
resolved_episodes_info=_read_json_tuple(
payload,
"resolved_episodes_info",
),
legacy_transfer_providers=tuple(
TransferProviderReference.from_payload(provider)
for provider in legacy_provider_payloads
),
provider_invocation=(
TransferProviderInvocationSnapshot.from_payload(
provider_invocation_payload
)
if provider_invocation_payload is not None
else None
),
pre_execution_cleanup_completed=payload.get(
"pre_execution_cleanup_completed",
False,
),
need_scrape=payload.get("need_scrape", False),
need_rename=payload.get("need_rename", False),
need_notify=payload.get("need_notify", True),
overwrite_mode=payload.get("overwrite_mode"),
preview=payload.get("preview", False),
skip_reason=payload.get("skip_reason"),
schema_version=payload.get("schema_version", 0),
)
def to_payload(self) -> dict[str, JSONValue]:
"""生成可原子落库的完整版本化 JSON 检查点。"""
return {
"schema_version": self.schema_version,
"planning_input": self.planning_input.to_payload(),
"target_storage": self.target_storage,
"root_target_path": self.root_target_path,
"final_target_path": self.final_target_path,
"resolved_transfer_type": self.resolved_transfer_type,
"items": [item.to_payload() for item in self.items],
"resolved_meta": _copy_json_mapping(self.resolved_meta),
"resolved_meta_kind": self.resolved_meta_kind,
"resolved_mediainfo": _copy_json_mapping(self.resolved_mediainfo),
"resolved_mediainfo_kind": self.resolved_mediainfo_kind,
"resolved_episodes_info": [
deepcopy(item) for item in self.resolved_episodes_info
],
"legacy_transfer_providers": [
provider.to_payload()
for provider in self.legacy_transfer_providers
],
"provider_invocation": (
self.provider_invocation.to_payload()
if self.provider_invocation
else None
),
"pre_execution_cleanup_completed": (
self.pre_execution_cleanup_completed
),
"need_scrape": self.need_scrape,
"need_rename": self.need_rename,
"need_notify": self.need_notify,
"overwrite_mode": self.overwrite_mode,
"preview": self.preview,
"skip_reason": self.skip_reason,
}
class TransferAdmissionConflictError(ValueError):
"""同一源文件以不同规划输入重复准入时抛出的冲突错误。"""
class TransferPlanningStateError(RuntimeError):
"""计划检查点无法从当前持久状态推进时抛出的状态错误。"""
class TransferAdmissionProjectionError(RuntimeError):
"""持久登记无法安全恢复为应用层整理准入投影时抛出的错误。"""
class TransferLeaseLostError(TransferPlanningStateError):
"""整理 worker 已失去持久租约、不得继续推进任务时抛出的错误。"""
class TransferTask(OptionalMediaIdentityMixin, _ApplicationModel):
"""
文件整理任务。
"""
# MetaBase 与 MediaInfo / MusicInfo 都是普通类而非 BaseModelpydantic 需要显式放行
model_config = ConfigDict(arbitrary_types_allowed=True)
fileitem: FileItem
meta: Optional[MetaBase] = None
mediainfo: Optional[Union[MusicInfo, MediaInfo]] = None
media_source: Optional[MediaSource] = None
media_id: Optional[str] = None
mtype: Optional[MediaType] = None
target_directory: Optional[TransferDirectoryConf] = None
target_storage: Optional[str] = None
target_path: Optional[Path] = None
transfer_type: Optional[str] = None
scrape: Optional[bool] = False
library_type_folder: Optional[bool] = False
library_category_folder: Optional[bool] = False
episodes_info: Optional[List[TmdbEpisode]] = None
username: Optional[str] = None
downloader: Optional[str] = None
download_hash: Optional[str] = None
download_history: Optional[DownloadHistory] = None
transfer_batch_id: Optional[str] = None
manual: Optional[bool] = False
background: Optional[bool] = True
preview: Optional[bool] = False
_admission_task_id: Optional[str] = PrivateAttr(default=None)
_planning_input: Optional[TransferPlanningInput] = PrivateAttr(default=None)
_plan_checkpoint: Optional[TransferPlanCheckpoint] = PrivateAttr(default=None)
_execution_checkpoint: Optional[TransferExecutionCheckpoint] = PrivateAttr(default=None)
_terminal_settled: bool = PrivateAttr(default=False)
_planning_context_restored: bool = PrivateAttr(default=False)
_lease_owner: Optional[str] = PrivateAttr(default=None)
_lease_token: Optional[str] = PrivateAttr(default=None)
@property
def admission_task_id(self) -> Optional[str]:
"""返回仅供宿主持久准入和终态结算使用的内部任务标识。"""
return self._admission_task_id
def bind_admission_task_id(self, task_id: str) -> None:
"""绑定持久准入生成的稳定身份,不改变插件可见序列化字段。"""
self._admission_task_id = task_id
@property
def planning_input(self) -> Optional[TransferPlanningInput]:
"""返回宿主恢复规划使用的内部输入快照。"""
return self._planning_input
@property
def plan_checkpoint(self) -> Optional[TransferPlanCheckpoint]:
"""返回宿主直接执行已规划任务使用的内部检查点。"""
return self._plan_checkpoint
def bind_planning_input(self, planning_input: TransferPlanningInput) -> None:
"""绑定持久规划输入,不改变插件可见序列化字段。"""
self._planning_input = planning_input
def bind_plan_checkpoint(self, checkpoint: TransferPlanCheckpoint) -> None:
"""绑定持久执行检查点,不改变插件可见序列化字段。"""
self._plan_checkpoint = checkpoint
@property
def execution_checkpoint(self) -> Optional[TransferExecutionCheckpoint]:
"""返回仅供宿主终态结算使用的内部执行检查点。"""
return self._execution_checkpoint
def bind_execution_checkpoint(
self, checkpoint: TransferExecutionCheckpoint
) -> None:
"""绑定执行结果检查点,不改变插件可见的旧任务序列化字段。"""
self._execution_checkpoint = checkpoint
@property
def terminal_settled(self) -> bool:
"""返回历史、事件与 pending 是否已由同一 UoW 提交。"""
return self._terminal_settled
def mark_terminal_settled(self) -> None:
"""仅在 task-aware writer 成功返回后标记终态已经提交。"""
self._terminal_settled = True
@property
def planning_context_restored(self) -> bool:
"""返回当前领域上下文是否来自持久快照。"""
return self._planning_context_restored
def mark_planning_context_restored(self) -> None:
"""标记领域上下文已离线恢复,禁止旧流程再次在线补充。"""
self._planning_context_restored = True
@property
def lease_owner(self) -> Optional[str]:
"""返回当前任务绑定的进程级 worker owner。"""
return self._lease_owner
@property
def lease_token(self) -> Optional[str]:
"""返回当前任务绑定的持久租约令牌。"""
return self._lease_token
def bind_execution_lease(self, *, owner_id: str, lease_token: str) -> None:
"""绑定持久 claim 结果,且不改变插件可见的旧任务序列化字段。"""
if not owner_id or not lease_token:
raise ValueError("整理执行租约缺少 owner 或 token")
self._lease_owner = owner_id
self._lease_token = lease_token
def to_dict(self) -> dict[str, Any]:
"""
返回字典。
meta 与 mediainfo 用 to_dict() 而非 model_dump():它们是领域对象,没有
model_dump。此前这里写的是 model_dump(),仓内无人调用才一直没炸——字段类型
标成 Any 时,这种错配静态检查也看不出来。
"""
dicts = vars(self).copy()
dicts["fileitem"] = self.fileitem.model_dump() if self.fileitem else None
dicts["meta"] = _domain_to_dict(self.meta) if self.meta else None
dicts["mediainfo"] = _domain_to_dict(self.mediainfo) if self.mediainfo else None
dicts["target_directory"] = self.target_directory.model_dump() if self.target_directory else None
return dicts
TransferCallback: TypeAlias = Callable[[TransferTask, TransferInfo], tuple[bool, str]]
class TransferQueue(_ApplicationModel):
"""
异步整理队列信息。
和 TransferTask 一起从 app/schemas 搬来:它装着一个 TransferTask 和一个回调函数,
回调根本不可序列化,因此从来就不是 DTO,只是恰好和视图模型住在同一个文件里。
"""
# 任务信息
task: Optional[TransferTask] = None
# 回调函数
callback: Optional[TransferCallback] = None
# 整理结果
result: Optional[TransferInfo] = None
@dataclass(frozen=True, slots=True)
class TransferAdmission:
"""描述已经持久化、可在进程退出后恢复的整理任务准入事实。"""
task_id: str
storage: str
src_path: str
state: str
created_at: str
updated_at: str
last_error: Optional[str] = None
input_fingerprint: Optional[str] = None
planning_input: Optional[TransferPlanningInput] = None
checkpoint: Optional[TransferPlanCheckpoint] = None
lease_owner: Optional[str] = None
lease_token: Optional[str] = None
lease_expires_at: Optional[str] = None
heartbeat_at: Optional[str] = None
attempt_count: int = 0
class TransferAdmissionRepository(Protocol):
"""整理任务 durable admission 所需的类型化持久化端口。"""
def admit(
self,
*,
storage: str,
src_path: str,
planning_input: Optional[TransferPlanningInput] = None,
) -> TransferAdmission:
"""按规划输入幂等登记源文件并返回稳定任务身份。"""
...
def record_enqueue_failure(self, *, task_id: str, error: str) -> None:
"""记录内存队列接收失败,保留任务供后续恢复。"""
...
def checkpoint_plan(
self,
*,
task_id: str,
lease_token: str,
input_fingerprint: str,
checkpoint: TransferPlanCheckpoint,
) -> TransferAdmission:
"""按有效租约原子保存完整计划并推进匹配输入的任务。"""
...
def record_planning_failure(
self, *, task_id: str, lease_token: str, error: str
) -> None:
"""按有效租约记录规划失败,保留业务状态供恢复重试。"""
...
def claim_task(
self,
*,
task_id: str,
owner_id: str,
lease_seconds: int,
) -> Optional[TransferAdmission]:
"""为指定任务取得唯一执行租约;已有有效租约时返回 None。"""
...
def claim_recoverable(
self,
*,
owner_id: str,
limit: int,
lease_seconds: int,
) -> list[TransferAdmission]:
"""按登记顺序原子取得可恢复任务的唯一执行租约。"""
...
def heartbeat(
self,
*,
task_id: str,
lease_token: str,
lease_seconds: int,
) -> Optional[TransferAdmission]:
"""仅为当前且未过期的 token 延长租约;过期 token 不得复活。"""
...
def release_claim(
self,
*,
task_id: str,
lease_token: str,
error: Optional[str] = None,
) -> bool:
"""按 token 释放当前 claim,并可记录可恢复错误。"""
...
def discard_claimed(self, *, task_id: str, lease_token: str) -> int:
"""仅允许当前 lease owner 删除已经到达终态的登记。"""
...
class TransferQueueService:
"""协调整理任务登记、入队、移除和队列视图查询。"""
def __init__(
self,
*,
register_task: Callable[[TransferTask], bool],
admit_task: Callable[[TransferTask], TransferAdmission],
enqueue: Callable[[TransferQueue], None],
before_enqueue: Callable[[TransferTask], None],
enqueue_failed: Callable[[TransferTask, Exception], None],
remove_task: Callable[[FileItem], None],
list_tasks: Callable[[], List[TransferJob]],
expire_tasks: Callable[[], None],
) -> None:
"""保存队列用例依赖,避免 Application 服务绑定具体线程队列实现。"""
self._register_task = register_task
self._admit_task = admit_task
self._enqueue = enqueue
self._before_enqueue = before_enqueue
self._enqueue_failed = enqueue_failed
self._remove_task = remove_task
self._list_tasks = list_tasks
self._expire_tasks = expire_tasks
def put(self, task: TransferTask, callback: TransferCallback) -> bool:
"""先持久化准入事实再入队;任何前置失败都撤销内存作业视图。"""
if not task or not self._register_task(task):
return False
try:
admission = self._admit_task(task)
task.bind_admission_task_id(admission.task_id)
except Exception:
self._remove_task(task.fileitem)
raise
try:
self._before_enqueue(task)
self._enqueue(TransferQueue(task=task, callback=callback))
except Exception as err:
try:
self._enqueue_failed(task, err)
finally:
self._remove_task(task.fileitem)
raise
return True
def remove(self, fileitem: FileItem) -> None:
"""从整理任务视图移除指定文件。"""
if fileitem:
self._remove_task(fileitem)
def list(self) -> List[TransferJob]:
"""先处理失活任务,再返回当前整理作业视图。"""
self._expire_tasks()
return self._list_tasks()
@dataclass(frozen=True, slots=True)
class TransferFailureNotification:
"""整理失败聚合器保存的单条通知快照。"""
media_title: str
season_episode: str
reason: str
history_id: Optional[int]
image: Optional[str]
username: Optional[str]
manual_identity: bool = False
def build_transfer_failure_group_key(task: TransferTask) -> str:
"""构造主程序和第三方整理路径可共同使用的失败通知分组键。"""
media_source, media_id = resolve_media_identity(media=task.mediainfo)
if not media_source or not media_id:
media_source, media_id = resolve_media_identity(media=task)
season = getattr(task.meta, "begin_season", None) if task.meta else None
username = task.username or ""
if media_source and media_id:
return f"media:{media_source}:{media_id}:season:{season}:user:{username}"
if task.download_hash:
return f"download:{task.download_hash}:user:{username}"
source_path = str(task.fileitem.path) if task.fileitem else ""
parent_path = str(Path(source_path).parent) if source_path else ""
return f"path:{parent_path or source_path}:user:{username}"
class TransferFailureNotificationAggregator:
"""在短暂静默窗口内按媒体合并整理失败通知。"""
NOTIFICATION_DEBOUNCE_SECONDS = 30
def __init__(self) -> None:
"""初始化分组缓冲、回调、定时器与关闭状态。"""
self._buffers: dict[str, list[TransferFailureNotification]] = {}
self._callbacks: dict[
str,
Callable[[list[TransferFailureNotification]], None],
] = {}
self._timers: dict[str, asyncio.TimerHandle] = {}
self._generations: dict[str, int] = {}
self._lock = threading.Lock()
self._closed = False
def schedule(
self,
*,
group_key: str,
notification: TransferFailureNotification,
callback: Callable[[list[TransferFailureNotification]], None],
loop: asyncio.AbstractEventLoop,
) -> None:
"""从整理线程安全地把失败快照加入事件循环中的聚合缓冲。"""
# 先在调用线程登记快照,关闭流程才能覆盖已接收但尚未进入事件循环的通知。
with self._lock:
if self._closed:
raise RuntimeError("整理失败通知聚合器正在关闭,不能再接收通知")
self._buffers.setdefault(group_key, []).append(notification)
self._callbacks[group_key] = callback
generation = self._generations.get(group_key, 0) + 1
self._generations[group_key] = generation
try:
loop.call_soon_threadsafe(
self._schedule_on_loop,
group_key,
generation,
callback,
loop,
)
except Exception as err:
logger.error(
f"创建整理失败通知聚合定时器失败,将立即发送 "
f"(group={group_key}): {err}"
)
self.flush(group_key, generation, callback)
def _schedule_on_loop(
self,
group_key: str,
generation: int,
callback: Callable[[list[TransferFailureNotification]], None],
loop: asyncio.AbstractEventLoop,
) -> None:
"""在所属事件循环中为已登记缓冲重置静默窗口。"""
schedule_error: Optional[Exception] = None
with self._lock:
if (
self._closed
or group_key not in self._buffers
or self._generations.get(group_key) != generation
):
return
timer = self._timers.pop(group_key, None)
if timer:
timer.cancel()
try:
self._timers[group_key] = loop.call_later(
self.NOTIFICATION_DEBOUNCE_SECONDS,
self.flush,
group_key,
generation,
callback,
)
except Exception as err:
schedule_error = err
if schedule_error is not None:
logger.error(
f"创建整理失败通知聚合定时器失败,将立即发送 "
f"(group={group_key}): {schedule_error}"
)
self.flush(group_key, generation, callback)
def flush(
self,
group_key: str,
generation: int,
callback: Callable[[list[TransferFailureNotification]], None],
) -> None:
"""发送一个分组内的聚合结果并释放缓冲。"""
with self._lock:
# 新通知已登记但 timer 重置回调尚未执行时,旧代不得提前发送新批次。
if self._generations.get(group_key) != generation:
return
notifications = self._buffers.pop(group_key, [])
self._callbacks.pop(group_key, None)
timer = self._timers.pop(group_key, None)
self._generations.pop(group_key, None)
if timer:
timer.cancel()
if not notifications:
return
self._deliver(group_key, notifications, callback)
@staticmethod
def _deliver(
group_key: str,
notifications: list[TransferFailureNotification],
callback: Callable[[list[TransferFailureNotification]], None],
) -> None:
"""调用聚合通知回调,并统一观察发送异常。"""
try:
callback(notifications)
except Exception as err:
logger.error(f"发送整理失败聚合通知失败 (group={group_key}): {err}")
def close(self) -> None:
"""停止接收新通知,取消定时器并同步发送全部已缓冲通知。"""
with self._lock:
if self._closed and not self._buffers and not self._timers:
return
self._closed = True
timers = list(self._timers.values())
pending = []
orphaned = []
for group_key, notifications in self._buffers.items():
callback = self._callbacks.get(group_key)
if callback is None:
orphaned.append((group_key, len(notifications)))
continue
pending.append((group_key, notifications, callback))
self._timers.clear()
self._generations.clear()
self._buffers.clear()
self._callbacks.clear()
for timer in timers:
timer.cancel()
for group_key, notification_count in orphaned:
logger.error(
f"整理失败通知聚合缓冲缺少发送回调,无法刷新 "
f"(group={group_key}, count={notification_count})"
)
for group_key, notifications, callback in pending:
self._deliver(group_key, notifications, callback)
# 作业锁:JobManager 与 TransferChain 共享,保护整理作业视图。
job_lock = threading.Lock()
class JobManager:
"""
作业管理器
task任务负责一个文件的整理,job作业负责一个媒体的整理
"""
# 整理中的作业
_job_view: Dict[JobId, TransferJob] = {}
# 汇总季集清单
_season_episodes: Dict[JobId, List[int]] = {}
# 记录从 meta 作业迁移到 media 作业的关系,用于清理提前失败后残留的 media 作业
_meta_to_media_ids: Dict[JobId, set[JobId]] = {}
# 记录任务最近一次状态心跳,供外部异步接管任务的失活检测使用
_task_state_changed_at: Dict[FileKey, float] = {}
# 记录仍由主程序整理线程直接执行的任务,避免把阻塞中的本地任务误判为失活
_active_executions: set[FileKey] = set()
def __init__(self) -> None:
"""初始化当前进程内的整理作业状态。"""
self._job_view = {}
self._season_episodes = {}
self._meta_to_media_ids = {}
self._task_state_changed_at = {}
self._active_executions = set()
@staticmethod
def __get_meta_id(
meta: Optional[MetaBase] = None,
season: Optional[int] = None,
) -> JobId:
"""
获取元数据ID
"""
return cast(MetaBase, meta).name, season
@staticmethod
def __get_media_id(media: Optional[Union[MediaInfo, MusicInfo]] = None,
season: Optional[int] = None) -> JobId:
"""
获取媒体ID;音乐额外区分实体类型,并为无远端ID的曲目构造稳定身份。
"""
if not media:
return None, season
source, media_id = resolve_media_identity(media=media)
if getattr(media, "type", None) == MediaType.MUSIC:
music_type = normalize_music_type(
getattr(media, "music_type", None),
) or MUSIC_ENTITY_RECORDING
if source and media_id:
return "music", source, media_id, music_type
artists = tuple(
text_tools.normalize_upper(artist)
for artist in (getattr(media, "artists", None) or [])
if text_tools.normalize_upper(artist)
)
if music_type == MUSIC_ENTITY_ALBUM:
album_artist = text_tools.normalize_upper(
getattr(media, "album_artist", None)
or (artists[0] if artists else "")
)
album = text_tools.normalize_upper(
getattr(media, "album", None) or getattr(media, "title", None) or ""
)
return "music", "local", music_type, album_artist, album, getattr(media, "year", None)
return (
"music",
"local",
music_type,
artists,
text_tools.normalize_upper(getattr(media, "title", None) or ""),
text_tools.normalize_upper(getattr(media, "album", None) or ""),
getattr(media, "disc_number", None),
getattr(media, "track_number", None),
)
return (source, media_id), season
@staticmethod
def __get_file_key(fileitem: FileItem) -> Optional[Tuple[str, str]]:
"""
获取源文件唯一键,用于跨媒体作业识别同一个整理任务。
"""
if not fileitem or not fileitem.path:
return None
normalized_path = (
Path(str(fileitem.path).replace("\\", "/")).as_posix().rstrip("/") or "/"
)
return fileitem.storage or "local", normalized_path
def __get_id(self, task: Optional[TransferTask] = None) -> JobId:
"""
获取作业ID
"""
resolved_task = cast(TransferTask, task)
meta = _transfer_task_meta(resolved_task)
if resolved_task.mediainfo:
return self.__get_media_id(
media=resolved_task.mediainfo, season=meta.begin_season
)
return self.__get_meta_id(meta=meta, season=meta.begin_season)
def get_job_id(self, task: TransferTask) -> JobId:
"""返回任务当前所属的稳定作业身份,供作业级附加状态隔离使用。"""
return self.__get_id(task)
@staticmethod
def __get_media(task: TransferTask) -> Union[_SchemaMediaInfo, _SchemaMusicInfo]:
"""
获取媒体信息
"""
if task.mediainfo:
# 有媒体信息
mediainfo = deepcopy(task.mediainfo)
mediainfo.clear()
if isinstance(mediainfo, MusicInfo):
return _SchemaMusicInfo(**_domain_to_dict(mediainfo))
return _SchemaMediaInfo(**_domain_to_dict(mediainfo))
else:
# 没有媒体信息
meta = _transfer_task_meta(task)
if isinstance(meta, MetaMusic):
# 未识别的音乐按已解析元数据兜底展示;音乐年份为 int,
# 不能复用 MediaInfoyear 为 str),否则触发 pydantic 校验异常
return _SchemaMusicInfo(
title=meta.name,
artists=list(meta.artists or []),
artist=meta.artist,
album=meta.album,
album_artist=meta.album_artist,
year=meta.year,
title_year=f"{meta.name} ({meta.year})" if meta.year else meta.name,
media_source=meta.media_source,
media_id=meta.media_id,
)
return _SchemaMediaInfo(
title=meta.name,
year=meta.year,
title_year=f"{meta.name} ({meta.year})",
type=meta.type.value if meta.type else None,
)
@staticmethod
def __get_meta(task: TransferTask) -> _SchemaMetaInfo:
"""
获取元数据
"""
if isinstance(task.meta, MetaMusic):
return _SchemaMusicMeta(**task.meta.to_dict())
return _SchemaMetaInfo(**_domain_to_dict(_transfer_task_meta(task)))
def add_task(self, task: TransferTask, state: Optional[str] = "waiting") -> bool:
"""
添加整理任务,自动分组到对应的作业中
:return: True表示任务已添加,False表示任务无效或已存在(重复)
"""
if not all([task, task.meta, task.fileitem]):
return False
file_key = self.__get_file_key(task.fileitem)
if not file_key:
return False
with job_lock:
__mediaid__ = self.__get_id(task)
# 同一个源文件可能在识别前后落入不同作业,必须跨作业去重。
if any(
self.__get_file_key(_job_task_fileitem(t)) == file_key
for job in self._job_view.values()
for t in _job_tasks(job)
):
logger.debug(f"任务 {task.fileitem.name} 已存在,跳过重复添加")
return False
if __mediaid__ not in self._job_view:
self._job_view[__mediaid__] = TransferJob(
media=self.__get_media(task),
season=_transfer_task_meta(task).begin_season,
tasks=[
TransferJobTask(
fileitem=task.fileitem,
meta=self.__get_meta(task),
downloader=task.downloader,
download_hash=task.download_hash,
state=state,
)
],
)
else:
# 不重复添加任务
if any(
[
self.__get_file_key(_job_task_fileitem(t)) == file_key
for t in _job_tasks(self._job_view[__mediaid__])
]
):
logger.debug(f"任务 {task.fileitem.name} 已存在,跳过重复添加")
return False
_job_tasks(self._job_view[__mediaid__]).append(
TransferJobTask(
fileitem=task.fileitem,
meta=self.__get_meta(task),
downloader=task.downloader,
download_hash=task.download_hash,
state=state,
)
)
self._task_state_changed_at[file_key] = monotonic()
# 添加季集信息
if self._season_episodes.get(__mediaid__):
self._season_episodes[__mediaid__].extend(
_transfer_task_meta(task).episode_list
)
self._season_episodes[__mediaid__] = list(
set(self._season_episodes[__mediaid__])
)
else:
self._season_episodes[__mediaid__] = _transfer_task_meta(task).episode_list
return True
def migrate_task(self, task: TransferTask) -> bool:
"""
将任务从 meta 作业迁移到 media 作业
"""
curr_task, source_job_id = self.__remove_task_with_job_id(
task.fileitem, preserve_execution=True
)
if not self.add_task(task, state=curr_task.state if curr_task else "waiting"):
return False
if curr_task and task.mediainfo:
meta = _transfer_task_meta(task)
metaid = self.__get_meta_id(meta=meta, season=meta.begin_season)
mediaid = self.__get_id(task)
if source_job_id == metaid and mediaid != metaid:
with job_lock:
self._meta_to_media_ids.setdefault(metaid, set()).add(mediaid)
return True
def __is_job_done(self, job_id: JobId) -> bool:
"""
检查指定作业是否已完成
"""
if job_id not in self._job_view:
return True
return all(
task.state in ["completed", "failed"]
for task in _job_tasks(self._job_view[job_id])
)
def __pop_job(self, job_id: JobId) -> None:
"""
移除指定作业和对应季集缓存
"""
job = self._job_view.pop(job_id, None)
self._season_episodes.pop(job_id, None)
if not job:
return
for task in _job_tasks(job):
file_key = self.__get_file_key(_job_task_fileitem(task))
if file_key:
self._task_state_changed_at.pop(file_key, None)
self._active_executions.discard(file_key)
def __remove_done_job_groups(self, job_ids: set[JobId]) -> None:
"""
清理已进入终态的独立作业或关联作业组。
"""
candidates = set(job_ids)
for metaid, mediaids in list(self._meta_to_media_ids.items()):
related_ids = {metaid, *mediaids}
if not related_ids.intersection(candidates):
continue
if all(self.__is_job_done(job_id) for job_id in related_ids):
for job_id in related_ids:
self.__pop_job(job_id)
self._meta_to_media_ids.pop(metaid, None)
candidates.difference_update(related_ids)
referenced_ids = {
job_id
for metaid, mediaids in self._meta_to_media_ids.items()
for job_id in {metaid, *mediaids}
}
for job_id in candidates - referenced_ids:
if self.__is_job_done(job_id):
self.__pop_job(job_id)
def start_execution(self, task: TransferTask) -> None:
"""
标记任务仍由主程序整理线程直接执行。
:param task: 整理任务
"""
if not task or not task.fileitem:
return
file_key = self.__get_file_key(task.fileitem)
if not file_key:
return
with job_lock:
self._active_executions.add(file_key)
def finish_execution(self, task: TransferTask) -> None:
"""
结束主程序整理线程对任务的直接执行标记。
:param task: 整理任务
"""
if not task or not task.fileitem:
return
file_key = self.__get_file_key(task.fileitem)
if not file_key:
return
with job_lock:
self._active_executions.discard(file_key)
def expire_stale_running_tasks(
self, timeout_seconds: int
) -> List[tuple[FileItem, int]]:
"""
将外部接管后长期无心跳的运行中任务标记失败并清理作业视图。
主程序整理线程仍在直接执行的任务不会被清理,以免把阻塞中的真实任务
误报为已终止。外部接管方可重复调用 ``running_task`` 刷新状态心跳。
:param timeout_seconds: 失活超时秒数,小于等于 0 时禁用
:return: 已失活任务及其无心跳秒数
"""
if timeout_seconds <= 0:
return []
current_time = monotonic()
expired: List[tuple[FileItem, int]] = []
affected_job_ids: set[JobId] = set()
with job_lock:
for mediaid, job in self._job_view.items():
for task in _job_tasks(job):
fileitem = _job_task_fileitem(task)
file_key = self.__get_file_key(fileitem)
if (
not file_key
or task.state != "running"
or file_key in self._active_executions
):
continue
updated_at = self._task_state_changed_at.get(file_key, current_time)
inactive_seconds = current_time - updated_at
if inactive_seconds < timeout_seconds:
continue
task.state = "failed"
self._task_state_changed_at[file_key] = current_time
episodes = getattr(task.meta, "episode_list", None) or []
if mediaid in self._season_episodes:
self._season_episodes[mediaid] = list(
set(self._season_episodes[mediaid]) - set(episodes)
)
expired.append((fileitem, int(inactive_seconds)))
affected_job_ids.add(mediaid)
self.__remove_done_job_groups(affected_job_ids)
return expired
def running_task(self, task: TransferTask) -> None:
"""
设置任务为运行中,并刷新外部异步任务的状态心跳。
"""
with job_lock:
__mediaid__ = self.__get_id(task)
if __mediaid__ not in self._job_view:
return
# 更新状态
for t in _job_tasks(self._job_view[__mediaid__]):
if t.fileitem == task.fileitem:
t.state = "running"
file_key = self.__get_file_key(_job_task_fileitem(t))
if file_key:
self._task_state_changed_at[file_key] = monotonic()
break
def finish_task(self, task: TransferTask) -> None:
"""
设置任务为完成/成功
"""
with job_lock:
__mediaid__ = self.__get_id(task)
if __mediaid__ not in self._job_view:
return
# 更新状态
for t in _job_tasks(self._job_view[__mediaid__]):
if t.fileitem == task.fileitem:
t.state = "completed"
file_key = self.__get_file_key(_job_task_fileitem(t))
if file_key:
self._task_state_changed_at[file_key] = monotonic()
break
def fail_task(self, task: TransferTask) -> None:
"""
设置任务为失败
"""
with job_lock:
__mediaid__ = self.__get_id(task)
if __mediaid__ not in self._job_view:
return
# 更新状态
for t in _job_tasks(self._job_view[__mediaid__]):
if t.fileitem == task.fileitem:
t.state = "failed"
file_key = self.__get_file_key(_job_task_fileitem(t))
if file_key:
self._task_state_changed_at[file_key] = monotonic()
break
# 移除剧集信息
if __mediaid__ in self._season_episodes:
self._season_episodes[__mediaid__] = list(
set(self._season_episodes[__mediaid__])
- set(_transfer_task_meta(task).episode_list)
)
def fail_unfinished_task(self, task: TransferTask) -> None:
"""
将指定任务视图中的非终态任务标记为失败
"""
if not task or not task.fileitem:
return
file_key = self.__get_file_key(task.fileitem)
if not file_key:
return
with job_lock:
for mediaid, job in self._job_view.items():
for job_task in _job_tasks(job):
if self.__get_file_key(_job_task_fileitem(job_task)) != file_key:
continue
if job_task.state not in ["completed", "failed"]:
job_task.state = "failed"
self._task_state_changed_at[file_key] = monotonic()
if mediaid in self._season_episodes:
self._season_episodes[mediaid] = list(
set(self._season_episodes[mediaid])
- set(_transfer_task_meta(task).episode_list)
)
return
def remove_task(self, fileitem: FileItem) -> Optional[TransferJobTask]:
"""
根据文件项移除任务
"""
task, _ = self.__remove_task_with_job_id(fileitem)
return task
def __remove_task_with_job_id(
self,
fileitem: FileItem,
preserve_execution: bool = False,
) -> tuple[Optional[TransferJobTask], Optional[JobId]]:
"""
根据文件项移除任务,并返回任务所在的作业ID
"""
file_key = self.__get_file_key(fileitem)
if not file_key:
return None, None
with job_lock:
for mediaid in list(self._job_view):
job = self._job_view[mediaid]
for task in _job_tasks(job):
if self.__get_file_key(_job_task_fileitem(task)) == file_key:
_job_tasks(job).remove(task)
self._task_state_changed_at.pop(file_key, None)
if not preserve_execution:
self._active_executions.discard(file_key)
# 如果没有作业了,则移除作业
if not _job_tasks(job):
self._job_view.pop(mediaid)
# 移除季集信息
if mediaid in self._season_episodes:
episodes = getattr(task.meta, "episode_list", None) or []
self._season_episodes[mediaid] = list(
set(self._season_episodes[mediaid])
- set(episodes)
)
return task, mediaid
return None, None
def remove_job(self, task: TransferTask) -> Optional[TransferJob]:
"""
移除任务对应的作业(强制,线程不安全)
"""
with job_lock:
__mediaid__ = self.__get_id(task)
if __mediaid__ in self._job_view:
job = self._job_view[__mediaid__]
self.__pop_job(__mediaid__)
return job
return None
def try_remove_job(self, task: TransferTask) -> None:
"""
尝试移除任务对应的作业(严格检查未完成作业,线程安全)
"""
with job_lock:
meta = _transfer_task_meta(task)
__metaid__ = self.__get_meta_id(meta=meta, season=meta.begin_season)
__mediaid__ = self.__get_media_id(
media=task.mediainfo, season=meta.begin_season
)
related_media_ids = set(self._meta_to_media_ids.get(__metaid__, set()))
if task.mediainfo:
related_media_ids.add(__mediaid__)
meta_done = self.__is_job_done(__metaid__)
media_done = all(
self.__is_job_done(mediaid) for mediaid in related_media_ids
)
if meta_done and media_done:
remove_ids = {__metaid__, self.__get_id(task), *related_media_ids}
for job_id in remove_ids:
self.__pop_job(job_id)
self._meta_to_media_ids.pop(__metaid__, None)
def is_done(self, task: TransferTask) -> bool:
"""
检查任务对应的作业是否整理完成(不管成功还是失败)
"""
with job_lock:
meta = _transfer_task_meta(task)
__metaid__ = self.__get_meta_id(meta=meta, season=meta.begin_season)
__mediaid__ = self.__get_media_id(
media=task.mediainfo, season=meta.begin_season
)
if __metaid__ in self._job_view:
meta_done = all(
task.state in ["completed", "failed"]
for task in _job_tasks(self._job_view[__metaid__])
)
else:
meta_done = True
if __mediaid__ in self._job_view:
media_done = all(
task.state in ["completed", "failed"]
for task in _job_tasks(self._job_view[__mediaid__])
)
else:
media_done = True
return meta_done and media_done
def is_finished(self, task: TransferTask) -> bool:
"""
检查任务对应的作业是否已完成且有成功的记录
"""
with job_lock:
meta = _transfer_task_meta(task)
__metaid__ = self.__get_meta_id(meta=meta, season=meta.begin_season)
__mediaid__ = self.__get_media_id(
media=task.mediainfo, season=meta.begin_season
)
if __metaid__ in self._job_view:
meta_finished = all(
task.state in ["completed", "failed"]
for task in _job_tasks(self._job_view[__metaid__])
)
else:
meta_finished = True
if __mediaid__ in self._job_view:
tasks = _job_tasks(self._job_view[__mediaid__])
media_finished = all(
task.state in ["completed", "failed"] for task in tasks
) and any(task.state == "completed" for task in tasks)
else:
media_finished = True
return meta_finished and media_finished
def is_success(self, task: TransferTask) -> bool:
"""
检查任务对应的作业是否全部成功
"""
with job_lock:
meta = _transfer_task_meta(task)
__metaid__ = self.__get_meta_id(meta=meta, season=meta.begin_season)
__mediaid__ = self.__get_media_id(
media=task.mediainfo, season=meta.begin_season
)
if __metaid__ in self._job_view:
meta_success = all(
task.state in ["completed"]
for task in _job_tasks(self._job_view[__metaid__])
)
else:
meta_success = True
if __mediaid__ in self._job_view:
media_success = all(
task.state in ["completed"]
for task in _job_tasks(self._job_view[__mediaid__])
)
else:
media_success = True
return meta_success and media_success
def get_all_torrent_hashes(self) -> set[str]:
"""
获取所有种子的哈希值集合
"""
with job_lock:
return {
cast(str, task.download_hash)
for job in self._job_view.values()
for task in _job_tasks(job)
}
def is_torrent_done(self, download_hash: str) -> bool:
"""
检查指定种子的所有任务是否都已完成
"""
with job_lock:
if any(
task.state not in {"completed", "failed"}
for job in self._job_view.values()
for task in _job_tasks(job)
if task.download_hash == download_hash
):
return False
return True
def is_torrent_success(self, download_hash: str) -> bool:
"""
检查指定种子的所有任务是否都已成功
"""
with job_lock:
if any(
task.state != "completed"
for job in self._job_view.values()
for task in _job_tasks(job)
if task.download_hash == download_hash
):
return False
return True
def has_tasks(
self,
meta: MetaBase,
mediainfo: Optional[MediaInfo] = None,
season: Optional[int] = None,
) -> bool:
"""
判断作业是否还有任务正在处理
"""
with job_lock:
if mediainfo:
__mediaid__ = self.__get_media_id(media=mediainfo, season=season)
if __mediaid__ in self._job_view:
return True
__metaid__ = self.__get_meta_id(meta=meta, season=season)
return (
__metaid__ in self._job_view
and len(_job_tasks(self._job_view[__metaid__])) > 0
)
def success_tasks(
self, media: Union[MediaInfo, MusicInfo], season: Optional[int] = None
) -> List[TransferJobTask]:
"""
获取作业中所有成功的任务
"""
with job_lock:
__mediaid__ = self.__get_media_id(media=media, season=season)
if __mediaid__ not in self._job_view:
return []
return [
task
for task in _job_tasks(self._job_view[__mediaid__])
if task.state == "completed"
]
def all_tasks(
self, media: MediaInfo, season: Optional[int] = None
) -> List[TransferJobTask]:
"""
获取作业中全部任务
"""
with job_lock:
__mediaid__ = self.__get_media_id(media=media, season=season)
if __mediaid__ not in self._job_view:
return []
return _job_tasks(self._job_view[__mediaid__])
def count(self, media: Union[MediaInfo, MusicInfo], season: Optional[int] = None) -> int:
"""
获取作业中成功总数
"""
with job_lock:
__mediaid__ = self.__get_media_id(media=media, season=season)
if __mediaid__ not in self._job_view:
return 0
return len(
[
task
for task in _job_tasks(self._job_view[__mediaid__])
if task.state == "completed"
]
)
def size(self, media: Union[MediaInfo, MusicInfo], season: Optional[int] = None) -> int:
"""
获取作业中所有成功文件总大小
"""
with job_lock:
__mediaid__ = self.__get_media_id(media=media, season=season)
if __mediaid__ not in self._job_view:
return 0
return sum(
[
_job_task_size(task)
for task in _job_tasks(self._job_view[__mediaid__])
if task.state == "completed"
]
)
def total(self) -> int:
"""
获取所有任务总数
"""
with job_lock:
return sum([len(_job_tasks(job)) for job in self._job_view.values()])
def pending_total(self) -> int:
"""
获取未到终态的任务总数。
作业要等关联任务全部终态才整体移除,追更/分批场景下已完成任务会
跨批次残留在视图中;批次统计若用全量 total() 会把历史任务计入
「当前共 N 个文件」并压低进度百分比,因此只数未终态任务。
"""
with job_lock:
return sum(
1
for job in self._job_view.values()
for task in _job_tasks(job)
if task.state not in ("completed", "failed")
)
def list_jobs(self) -> List[TransferJob]:
"""
获取所有作业的任务列表
"""
with job_lock:
return list(self._job_view.values())
def season_episodes(
self, media: Union[MediaInfo, MusicInfo], season: Optional[int] = None
) -> List[int]:
"""
获取作业的季集清单
"""
with job_lock:
__mediaid__ = self.__get_media_id(media=media, season=season)
return self._season_episodes.get(__mediaid__) or []
class FailedRetryScheduler:
"""
负责失败整理记录的进程内 debounce 聚合与 AI 重试调度。
缓冲不提供持久化保证;关闭时会取消尚未触发的记录,由上层 durable
工作流在后续阶段承接需要跨进程保证的重试意图。
"""
RETRY_TRANSFER_DEBOUNCE_SECONDS = 300
def __init__(self) -> None:
"""初始化重试缓冲、定时器、活跃任务集合与关闭状态。"""
super().__init__()
self._retry_transfer_buffer: dict[str, list[int]] = {}
self._retry_transfer_timers: dict[str, asyncio.TimerHandle] = {}
self._retry_transfer_generations: dict[str, int] = {}
self._retry_transfer_tasks: set[asyncio.Task[None]] = set()
self._retry_transfer_lock = asyncio.Lock()
self._closed = False
async def close(self) -> None:
"""停止接收重试,取消定时器,并等待活跃 flush 任务完成取消。"""
async with self._retry_transfer_lock:
self._closed = True
timers = list(self._retry_transfer_timers.values())
buffered_count = sum(
len(history_ids)
for history_ids in self._retry_transfer_buffer.values()
)
self._retry_transfer_timers.clear()
self._retry_transfer_generations.clear()
self._retry_transfer_buffer.clear()
tasks = tuple(self._retry_transfer_tasks)
for timer in timers:
timer.cancel()
if buffered_count:
logger.warning(
f"智能体重试整理调度器关闭,取消 {buffered_count} 条未持久化缓冲记录"
)
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
def _start_retry_transfer_task(self, group_key: str, generation: int) -> None:
"""把定时器到期后的 flush 建为具名且受本调度器管理的任务。"""
if self._closed:
return
task = asyncio.create_task(
self._flush_retry_transfer(group_key, generation),
name="transfer.failed_retry.flush",
)
self._retry_transfer_tasks.add(task)
task.add_done_callback(self._observe_retry_transfer_task)
def _observe_retry_transfer_task(self, task: asyncio.Task[None]) -> None:
"""移除已结束任务,并观察未被 flush 逻辑处理的异常。"""
self._retry_transfer_tasks.discard(task)
if task.cancelled():
return
exception = task.exception()
if exception is not None:
logger.error(f"智能体重试整理后台任务异常: {exception}")
@staticmethod
def _build_retry_transfer_template_context(
history_ids: list[int],
) -> tuple[str, dict[str, int | str]]:
"""仅负责把失败重试任务的动态数据映射成模板变量。"""
is_batch = len(history_ids) > 1
task_type = "batch_transfer_failed_retry" if is_batch else "transfer_failed_retry"
template_context: dict[str, int | str] = {
"history_ids_csv": ", ".join(str(item) for item in history_ids),
"history_count": len(history_ids),
}
if not is_batch:
template_context["history_id"] = history_ids[0]
return task_type, template_context
def _build_retry_transfer_prompt(self, history_ids: list[int]) -> str:
"""根据失败记录数量构建统一的重试整理后台任务提示词。"""
task_type, template_context = self._build_retry_transfer_template_context(history_ids)
return cast(str, get_prompt_manager().render_system_task_message(
task_type,
template_context=template_context,
))
async def schedule_retry(self, history_id: int, group_key: str = "") -> None:
"""
同一 group_key 的失败记录会在缓冲期内合并为一次 agent 调用。
"""
if not group_key:
group_key = f"_default_{history_id}"
async with self._retry_transfer_lock:
if self._closed:
raise RuntimeError("智能体重试整理调度器正在关闭,不能再接收任务")
if group_key not in self._retry_transfer_buffer:
self._retry_transfer_buffer[group_key] = []
if history_id not in self._retry_transfer_buffer[group_key]:
self._retry_transfer_buffer[group_key].append(history_id)
logger.info(
f"智能体重试整理:记录 ID={history_id} 已加入缓冲区 "
f"(group={group_key}, 当前{len(self._retry_transfer_buffer[group_key])}条)"
)
if group_key in self._retry_transfer_timers:
self._retry_transfer_timers[group_key].cancel()
loop = asyncio.get_running_loop()
generation = self._retry_transfer_generations.get(group_key, 0) + 1
self._retry_transfer_generations[group_key] = generation
self._retry_transfer_timers[group_key] = loop.call_later(
self.RETRY_TRANSFER_DEBOUNCE_SECONDS,
self._start_retry_transfer_task,
group_key,
generation,
)
async def _flush_retry_transfer(self, group_key: str, generation: int) -> None:
"""
延迟定时器到期后,取出该分组的所有 history_id 并合并为一次 agent 调用。
"""
async with self._retry_transfer_lock:
# callback 到期与真正取得锁之间可能有新记录续期;旧代不能提前取走新批次。
if self._retry_transfer_generations.get(group_key) != generation:
return
history_ids = self._retry_transfer_buffer.pop(group_key, [])
self._retry_transfer_timers.pop(group_key, None)
self._retry_transfer_generations.pop(group_key, None)
if not history_ids:
return
ids_str = ", ".join(str(item) for item in history_ids)
logger.info(
f"智能体重试整理:开始批量处理失败记录 IDs=[{ids_str}] (group={group_key})"
)
try:
manager = get_running_agent_manager()
if manager is None:
logger.warning("智能助手服务未运行,跳过整理失败自动重试")
return
await manager.run_background_prompt(
message=self._build_retry_transfer_prompt(history_ids),
session_prefix="__agent_retry_transfer_batch",
reply_mode=ReplyMode.DISPATCH,
)
logger.info(
f"智能体重试整理:批量处理完成 IDs=[{ids_str}] (group={group_key})"
)
except Exception as err:
logger.error(
f"智能体重试整理失败 (IDs=[{ids_str}], group={group_key}): {err}"
)