mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-31 20:34:48 +08:00
714 lines
30 KiB
Python
714 lines
30 KiB
Python
"""
|
||
持久整理步骤执行、探测与检查点推进。
|
||
"""
|
||
|
||
import time
|
||
import traceback
|
||
from datetime import datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
from typing import Any, Callable, Mapping, Optional, Tuple, Union, cast
|
||
|
||
from app.application.audio import use_music_cue
|
||
from app.application.directory import DirectoryHelper
|
||
from app.application.history.retry import (
|
||
max_failed_retries,
|
||
)
|
||
from app.application.transfer.execution import (
|
||
TransferExecutionCheckpoint,
|
||
TransferExecutionCommand,
|
||
TransferExecutionConflictError,
|
||
TransferExecutionLeaseLostError,
|
||
TransferExecutionRepository,
|
||
TransferExecutionSnapshot,
|
||
TransferOperationObservation,
|
||
TransferOperationObservationState,
|
||
TransferStepIntent,
|
||
TransferStepResult,
|
||
TransferStepState,
|
||
)
|
||
from app.application.transfer.workflow import (
|
||
TransferAdmission,
|
||
TransferAdmissionConflictError,
|
||
TransferLeaseLostError,
|
||
TransferTask,
|
||
)
|
||
from app.chain.media import MediaChain
|
||
from app.chain.tmdb import TmdbChain
|
||
from app.chain.transfer.contract import _TransferOwnerBase
|
||
from app.chain.transfer.music import defer_music_recognition, refresh_music_retry_context
|
||
from app.chain.transfer.records import apply_download_history_classification
|
||
from app.domain.context import MediaInfo, MusicInfo
|
||
from app.domain.media import is_music_media_source
|
||
from app.domain.meta.metamusic import MetaMusic
|
||
from app.runtime.log import logger
|
||
from app.schemas.transfer import TransferInfo
|
||
from app.schemas.types import (
|
||
MediaSource,
|
||
MediaType,
|
||
)
|
||
|
||
|
||
class _TransferRetryDeferred(RuntimeError):
|
||
"""
|
||
表示已把确定失败交回唯一持久重试调度器。
|
||
"""
|
||
|
||
|
||
class _TransferManualReviewRequired(RuntimeError):
|
||
"""
|
||
表示外部结果无法严格判定,任务已隔离等待人工复核。
|
||
"""
|
||
|
||
|
||
class _TransferRetryExhausted(RuntimeError):
|
||
"""
|
||
表示步骤重试预算耗尽,任务可进入原子失败结算。
|
||
"""
|
||
|
||
def __init__(self, message: str, snapshot: TransferExecutionSnapshot) -> None:
|
||
"""
|
||
保存失败原因和数据库建立的结算检查点。
|
||
"""
|
||
super().__init__(message)
|
||
self.snapshot = snapshot
|
||
|
||
|
||
def _download_history_media_type(
|
||
history_type: Optional[str],
|
||
fallback: Optional[MediaType],
|
||
) -> Optional[MediaType]:
|
||
"""
|
||
使用下载历史身份类型;缺少有效类型时回退到整理调用提示。
|
||
"""
|
||
try:
|
||
media_type = MediaType(history_type)
|
||
except (TypeError, ValueError):
|
||
return fallback
|
||
return fallback if media_type == MediaType.UNKNOWN else media_type
|
||
|
||
|
||
class _DurableTransferStepRunner:
|
||
"""
|
||
以稳定顺序、lease 和 attempt fencing 执行整理外部步骤。
|
||
"""
|
||
|
||
_RETRY_DELAY_SECONDS = 30
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
task_id: str,
|
||
lease_token: str,
|
||
checkpoint_fingerprint: str,
|
||
repository: TransferExecutionRepository,
|
||
) -> None:
|
||
"""
|
||
绑定单个任务的持久执行命令与全局步骤序号。
|
||
"""
|
||
self._task_id = task_id
|
||
self._lease_token = lease_token
|
||
self._checkpoint_fingerprint = checkpoint_fingerprint
|
||
self._repository = repository
|
||
self._command = TransferExecutionCommand(self._repository)
|
||
snapshot = self._repository.get_snapshot(task_id=task_id)
|
||
existing_steps = list(snapshot.steps) if snapshot else []
|
||
matching_ordinals = [
|
||
step.ordinal
|
||
for step in existing_steps
|
||
if step.checkpoint_fingerprint == checkpoint_fingerprint
|
||
]
|
||
self._ordinal = (
|
||
min(matching_ordinals)
|
||
if matching_ordinals
|
||
else max((step.ordinal for step in existing_steps), default=-1) + 1
|
||
)
|
||
self._operation_ids = [
|
||
step.operation_id
|
||
for step in existing_steps
|
||
if step.ordinal < self._ordinal
|
||
]
|
||
|
||
@staticmethod
|
||
def _retry_due_at() -> str:
|
||
"""
|
||
生成固定退避后的 UTC 调度时间。
|
||
"""
|
||
return (
|
||
datetime.now(timezone.utc)
|
||
+ timedelta(seconds=_DurableTransferStepRunner._RETRY_DELAY_SECONDS)
|
||
).strftime("%Y-%m-%d %H:%M:%S.%f")
|
||
|
||
def _execute_attempt(
|
||
self,
|
||
*,
|
||
step: Any,
|
||
execute: Callable[[], TransferStepResult],
|
||
observe: Callable[[], TransferOperationObservation],
|
||
) -> TransferStepResult:
|
||
"""
|
||
在事务外执行副作用,异常后先探测结果再决定重试或人工复核。
|
||
"""
|
||
try:
|
||
result = execute()
|
||
except Exception as error:
|
||
try:
|
||
observation = observe()
|
||
except Exception as observe_error:
|
||
observation = TransferOperationObservation(
|
||
state=TransferOperationObservationState.UNKNOWN,
|
||
evidence=TransferStepResult(payload={
|
||
"execute_error": str(error),
|
||
"observe_error": str(observe_error),
|
||
}),
|
||
)
|
||
if observation.state is TransferOperationObservationState.APPLIED:
|
||
completed = self._command.complete(
|
||
task_id=self._task_id,
|
||
lease_token=self._lease_token,
|
||
step=step,
|
||
result=observation.evidence,
|
||
)
|
||
if completed.result is None:
|
||
raise RuntimeError("探测完成的整理步骤缺少结果证据") from error
|
||
return completed.result
|
||
if observation.state is not TransferOperationObservationState.NOT_APPLIED:
|
||
reason = (
|
||
f"整理步骤 {step.operation_id} 执行异常后外部结果为 "
|
||
f"{observation.state.value},禁止自动重放:{error}"
|
||
)
|
||
self._command.manual_review(
|
||
task_id=self._task_id,
|
||
lease_token=self._lease_token,
|
||
step=step,
|
||
error=reason,
|
||
evidence=observation.evidence,
|
||
)
|
||
raise _TransferManualReviewRequired(reason) from error
|
||
snapshot = self._repository.get_snapshot(task_id=self._task_id)
|
||
if snapshot is None:
|
||
raise RuntimeError("整理执行快照在步骤失败后丢失") from error
|
||
if snapshot.retry_count >= max_failed_retries():
|
||
exhausted = self._command.exhaust(
|
||
task_id=self._task_id,
|
||
lease_token=self._lease_token,
|
||
step=step,
|
||
error=str(error),
|
||
evidence=observation.evidence,
|
||
)
|
||
raise _TransferRetryExhausted(str(error), exhausted) from error
|
||
self._command.defer(
|
||
task_id=self._task_id,
|
||
lease_token=self._lease_token,
|
||
step=step,
|
||
error=str(error),
|
||
retry_due_at=self._retry_due_at(),
|
||
evidence=observation.evidence,
|
||
)
|
||
raise _TransferRetryDeferred(str(error)) from error
|
||
completed = self._command.complete(
|
||
task_id=self._task_id,
|
||
lease_token=self._lease_token,
|
||
step=step,
|
||
result=result,
|
||
)
|
||
if completed.result is None:
|
||
raise RuntimeError("整理步骤完成后缺少持久结果")
|
||
return completed.result
|
||
|
||
def run(
|
||
self,
|
||
*,
|
||
phase: str,
|
||
kind: str,
|
||
payload: Mapping[str, Any],
|
||
execute: Callable[[], TransferStepResult],
|
||
observe: Callable[[], TransferOperationObservation],
|
||
) -> TransferStepResult:
|
||
"""
|
||
幂等消费一个步骤,遗留 STARTED 必须先取得严格观察结论。
|
||
"""
|
||
intent = TransferStepIntent.create(
|
||
task_id=self._task_id,
|
||
checkpoint_fingerprint=self._checkpoint_fingerprint,
|
||
ordinal=self._ordinal,
|
||
phase=phase,
|
||
kind=kind,
|
||
payload=payload,
|
||
)
|
||
self._ordinal += 1
|
||
self._operation_ids.append(intent.operation_id)
|
||
step = self._command.prepare(
|
||
task_id=self._task_id,
|
||
lease_token=self._lease_token,
|
||
intent=intent,
|
||
)
|
||
if step.state is TransferStepState.SUCCEEDED:
|
||
if step.result is None:
|
||
raise RuntimeError("已成功整理步骤缺少结果证据")
|
||
return step.result
|
||
if step.state is TransferStepState.MANUAL_REVIEW:
|
||
raise _TransferManualReviewRequired(
|
||
step.last_error or "整理步骤正在等待人工复核"
|
||
)
|
||
if step.state is TransferStepState.PREPARED:
|
||
step = self._command.begin(
|
||
task_id=self._task_id,
|
||
lease_token=self._lease_token,
|
||
operation_id=step.operation_id,
|
||
)
|
||
elif step.state is TransferStepState.FAILED:
|
||
step = self._command.resume_failed(
|
||
task_id=self._task_id,
|
||
lease_token=self._lease_token,
|
||
step=step,
|
||
)
|
||
elif step.state is TransferStepState.STARTED:
|
||
observation = observe()
|
||
if observation.state is TransferOperationObservationState.APPLIED:
|
||
completed = self._command.complete(
|
||
task_id=self._task_id,
|
||
lease_token=self._lease_token,
|
||
step=step,
|
||
result=observation.evidence,
|
||
)
|
||
if completed.result is None:
|
||
raise RuntimeError("探测完成的整理步骤缺少结果证据")
|
||
return completed.result
|
||
if observation.state is TransferOperationObservationState.NOT_APPLIED:
|
||
step = self._command.restart_after_not_applied(
|
||
task_id=self._task_id,
|
||
lease_token=self._lease_token,
|
||
step=step,
|
||
evidence=observation.evidence,
|
||
)
|
||
else:
|
||
reason = (
|
||
f"遗留步骤 {step.operation_id} 外部结果为 "
|
||
f"{observation.state.value},禁止自动重放"
|
||
)
|
||
self._command.manual_review(
|
||
task_id=self._task_id,
|
||
lease_token=self._lease_token,
|
||
step=step,
|
||
error=reason,
|
||
evidence=observation.evidence,
|
||
)
|
||
raise _TransferManualReviewRequired(reason)
|
||
else:
|
||
raise RuntimeError(f"不支持的整理步骤状态:{step.state}")
|
||
return self._execute_attempt(step=step, execute=execute, observe=observe)
|
||
|
||
def checkpoint(self, transferinfo: TransferInfo) -> TransferExecutionCheckpoint:
|
||
"""
|
||
提交足以独立重放终态结算的聚合执行结果。
|
||
"""
|
||
outcome = (
|
||
"overwrite_skipped"
|
||
if transferinfo.overwrite_skipped
|
||
else "succeeded" if transferinfo.success else "failed"
|
||
)
|
||
checkpoint = TransferExecutionCheckpoint.create(
|
||
payload={
|
||
"outcome": outcome,
|
||
"transferinfo": transferinfo.model_dump(mode="json"),
|
||
},
|
||
operation_ids=tuple(self._operation_ids),
|
||
skip_reason=(
|
||
(transferinfo.message or "zero-side-effect transfer")
|
||
if not self._operation_ids
|
||
else None
|
||
),
|
||
)
|
||
snapshot = self._command.checkpoint(
|
||
task_id=self._task_id,
|
||
lease_token=self._lease_token,
|
||
checkpoint=checkpoint,
|
||
)
|
||
if snapshot.checkpoint is None:
|
||
raise RuntimeError("整理执行检查点提交后无法回读")
|
||
return snapshot.checkpoint
|
||
|
||
|
||
class TransferExecutionOwner(_TransferOwnerBase):
|
||
"""
|
||
持有整理准入租约、外部步骤执行与结果检查点。
|
||
"""
|
||
|
||
def _TransferChain__handle_transfer_execution_error(
|
||
self,
|
||
task: TransferTask,
|
||
error: Exception,
|
||
*,
|
||
preview: bool,
|
||
) -> Tuple[bool, str]:
|
||
"""
|
||
在插件可替换执行入口之外收口异常,防止未记账失败无限回放。
|
||
"""
|
||
if (not preview and not task.terminal_settled and task.admission_task_id and task.lease_token
|
||
and not isinstance(error, (TransferAdmissionConflictError, TransferLeaseLostError,
|
||
TransferExecutionConflictError, TransferExecutionLeaseLostError,
|
||
_TransferRetryDeferred, _TransferRetryExhausted,
|
||
_TransferManualReviewRequired))):
|
||
try:
|
||
isolated = self.transfer_execution_repository.isolate_unhandled_failure(
|
||
task_id=task.admission_task_id, lease_token=task.lease_token, error=str(error),
|
||
)
|
||
if isolated:
|
||
error = _TransferManualReviewRequired(str(error))
|
||
except Exception as record_error:
|
||
logger.error(f"记录整理执行异常失败:{record_error}")
|
||
if isinstance(error, _TransferManualReviewRequired):
|
||
logger.warning(
|
||
f"{task.fileitem.name} 已转入人工复核,未自动重放;"
|
||
f"请打开整理队列查看详情,确认外部操作状态后再决定是否重试:{error}"
|
||
)
|
||
message = "整理任务已转入人工复核,请打开整理队列查看详情"
|
||
else:
|
||
logger.error(
|
||
f"{task.fileitem.name} 整理任务处理出现错误:{error} - {traceback.format_exc()}"
|
||
)
|
||
message = (
|
||
str(error) if isinstance(error, TransferAdmissionConflictError)
|
||
else "整理任务处理失败,请稍后重试"
|
||
)
|
||
if not preview:
|
||
self._TransferChain__fail_transfer_task(task, error)
|
||
return False, message
|
||
|
||
def _TransferChain__register_claimed_admission(
|
||
self,
|
||
admission: TransferAdmission,
|
||
) -> None:
|
||
"""
|
||
把仓储 claim 加入续期集合,覆盖恢复构造阶段的同步 I/O。
|
||
"""
|
||
if (
|
||
admission.lease_owner != self._worker_owner_id
|
||
or not admission.lease_token
|
||
):
|
||
raise TransferLeaseLostError(
|
||
f"整理任务 claim 投影无效:{admission.task_id}"
|
||
)
|
||
with self._worker_state_lock:
|
||
self._owned_leases[admission.task_id] = (
|
||
admission.lease_token,
|
||
time.monotonic() + self._WORKER_LEASE_SECONDS,
|
||
)
|
||
self._TransferChain__ensure_lease_heartbeat_owner()
|
||
|
||
def _TransferChain__bind_claimed_admission(
|
||
self,
|
||
task: TransferTask,
|
||
admission: TransferAdmission,
|
||
) -> None:
|
||
"""
|
||
校验仓储 claim 投影并把 token 私有绑定到执行任务。
|
||
"""
|
||
if admission.task_id != task.admission_task_id:
|
||
raise TransferLeaseLostError(
|
||
f"整理任务 claim 投影无效:{admission.task_id}"
|
||
)
|
||
self._TransferChain__register_claimed_admission(admission)
|
||
assert admission.lease_owner is not None
|
||
assert admission.lease_token is not None
|
||
task.bind_execution_lease(
|
||
owner_id=admission.lease_owner,
|
||
lease_token=admission.lease_token,
|
||
)
|
||
|
||
def _TransferChain__claim_admitted_task(
|
||
self,
|
||
task: TransferTask,
|
||
task_id: str,
|
||
) -> None:
|
||
"""
|
||
取得已准入任务的唯一租约,并绑定到内存任务。
|
||
"""
|
||
claimed = cast(
|
||
Optional[TransferAdmission],
|
||
self._transfer_admissions.claim_task(
|
||
task_id=task_id,
|
||
owner_id=self._worker_owner_id,
|
||
lease_seconds=self._WORKER_LEASE_SECONDS,
|
||
),
|
||
)
|
||
if claimed is None:
|
||
raise TransferLeaseLostError(f"整理任务已由其他 worker claim:{task_id}")
|
||
try:
|
||
self._TransferChain__bind_claimed_admission(task, claimed)
|
||
except Exception as err:
|
||
self._TransferChain__release_admission_claim(claimed, error=str(err))
|
||
raise
|
||
|
||
def _TransferChain__admit_transfer(self, task: TransferTask) -> TransferAdmission:
|
||
"""
|
||
持久化源文件并在内存入队前取得执行租约。
|
||
"""
|
||
fileitem = task.fileitem if task else None
|
||
if not fileitem or not fileitem.storage or not fileitem.path:
|
||
raise ValueError("整理任务缺少源文件身份")
|
||
planning_input = task.planning_input or self._TransferChain__build_planning_input(task)
|
||
task.bind_planning_input(planning_input)
|
||
admission = cast(
|
||
TransferAdmission,
|
||
self._transfer_admissions.admit(
|
||
storage=fileitem.storage,
|
||
src_path=fileitem.path,
|
||
planning_input=planning_input,
|
||
replace_inactive=bool(task.manual),
|
||
),
|
||
)
|
||
task.bind_admission_task_id(admission.task_id)
|
||
self._TransferChain__claim_admitted_task(task, admission.task_id)
|
||
return admission
|
||
|
||
def _TransferChain__claim_task_for_execution(self, task: TransferTask) -> None:
|
||
"""
|
||
校验队列任务既有租约,并为兼容旧队列项补取唯一租约。
|
||
"""
|
||
if task.preview:
|
||
return
|
||
self._TransferChain__ensure_lease_runtime_state()
|
||
if task.lease_token:
|
||
self._TransferChain__assert_owned_lease(task)
|
||
return
|
||
if not task.admission_task_id:
|
||
self._TransferChain__admit_transfer(task)
|
||
return
|
||
self._TransferChain__claim_admitted_task(task, task.admission_task_id)
|
||
|
||
def _TransferChain__handle_transfer(
|
||
self,
|
||
task: TransferTask,
|
||
callback: Optional[Callable[[TransferTask, TransferInfo], Tuple[bool, str]]] = None,
|
||
) -> Optional[Tuple[bool, str]]:
|
||
"""
|
||
恢复准入时的 CUE 策略并执行,统一记录 checkpoint 前的失败或异常。
|
||
"""
|
||
try:
|
||
cue_enabled = cast(Optional[bool], task.planning_input.options.get("music_cue_enable")) if task.planning_input else None
|
||
with use_music_cue(cue_enabled):
|
||
result = self._TransferChain__perform_transfer(task, callback)
|
||
except Exception as error:
|
||
if not getattr(error, "_transfer_planning_failure_recorded", False):
|
||
self._TransferChain__record_uncheckpointed_failure(task, error)
|
||
raise
|
||
if result and not result[0]:
|
||
self._TransferChain__record_uncheckpointed_failure(task, result[1])
|
||
return result
|
||
|
||
@staticmethod
|
||
def _recognize_task_meta(task: TransferTask, *, restore_history: bool = True) -> Optional[Union[MediaInfo, MusicInfo]]:
|
||
"""
|
||
按任务限定的来源和类型识别元数据,仅在历史语义兼容时恢复其分类。
|
||
"""
|
||
options: dict[str, Any] = {"obtain_images": True}
|
||
if task.media_source:
|
||
options["media_source"] = task.media_source
|
||
if task.mtype:
|
||
options["mtype"] = task.mtype
|
||
info = MediaChain().recognize_by_meta(task.meta, **options)
|
||
if info and restore_history and task.download_history:
|
||
info = apply_download_history_classification(info, task.download_history)
|
||
return info
|
||
|
||
def _TransferChain__perform_transfer(
|
||
self,
|
||
task: TransferTask,
|
||
callback: Optional[Callable[[TransferTask, TransferInfo], Tuple[bool, str]]] = None,
|
||
) -> Optional[Tuple[bool, str]]:
|
||
"""
|
||
执行冻结计划,或在元数据完整时识别并规划新任务。
|
||
|
||
未规划任务缺少文件名元数据时走可结算的规划拒绝,不猜测季集或执行文件操作。
|
||
"""
|
||
try:
|
||
if task.plan_checkpoint is not None:
|
||
return cast(
|
||
Optional[Tuple[bool, str]],
|
||
self._TransferChain__handle_planned_transfer(task, callback),
|
||
)
|
||
refresh_music_retry_context(self, task)
|
||
if task.meta is None:
|
||
transferinfo = self._TransferChain__checkpoint_planning_rejection(
|
||
task,
|
||
"整理规划输入缺少文件名元数据,请重新预览并确认季集信息后重新整理",
|
||
)
|
||
return callback(task, transferinfo) if callback else (
|
||
transferinfo.success, transferinfo.message or ""
|
||
)
|
||
transferhis = self.transfer_history_repository
|
||
# 显式标注联合:下面既会赋回音乐识别结果(MusicInfo),也会赋回影视识别
|
||
# 结果(MediaInfo),不标注时会被推断成其中一种,另一种就成了假错误
|
||
mediainfo: Optional[Union[MediaInfo, MusicInfo]] = task.mediainfo
|
||
mediainfo_changed = False
|
||
need_obtain_images = False
|
||
if not mediainfo:
|
||
download_history = task.download_history
|
||
if download_history:
|
||
task.username = download_history.username
|
||
history_year_conflict = self._is_movie_year_conflict(
|
||
task.meta, download_history
|
||
)
|
||
# 本次明确按影视整理时,旧音乐身份不能抢占随片音轨的归属。
|
||
history_music_conflict = task.mtype in (MediaType.MOVIE, MediaType.TV) and (
|
||
download_history.type == MediaType.MUSIC.value
|
||
or is_music_media_source(download_history.media_source)
|
||
or self._download_history_music_type(download_history) is not None
|
||
)
|
||
if (
|
||
download_history.media_source
|
||
and download_history.media_id
|
||
and not history_year_conflict
|
||
and not history_music_conflict
|
||
):
|
||
# 下载记录中已存在识别信息。这里不再重复标注类型:函数开头
|
||
# 已把 mediainfo 声明为 MediaInfo | MusicInfo | None,重复
|
||
# 声明会遮蔽它,把音乐识别结果判成类型错误
|
||
mediainfo = MediaChain().recognize_media(
|
||
mtype=_download_history_media_type(download_history.type, task.mtype),
|
||
media_source=download_history.media_source,
|
||
media_id=download_history.media_id,
|
||
music_type=self._download_history_music_type(download_history),
|
||
episode_group=download_history.episode_group,
|
||
)
|
||
need_obtain_images = True
|
||
if mediainfo:
|
||
mediainfo = apply_download_history_classification(mediainfo, download_history)
|
||
else:
|
||
if history_year_conflict:
|
||
logger.info(
|
||
f"{task.fileitem.name} 文件年份 {task.meta.year if task.meta else None} 与下载记录年份 "
|
||
f"{download_history.year} 不一致,按文件名重新识别"
|
||
)
|
||
mediainfo = self._recognize_task_meta(task, restore_history=not history_music_conflict)
|
||
else:
|
||
mediainfo = self._recognize_task_meta(task)
|
||
|
||
# 音乐必须先经过音乐元数据模块识别;远端不可用时再保留本地标签结果,
|
||
# 避免因离线兜底提前赋值而跳过音乐识别链。
|
||
if not mediainfo and isinstance(task.meta, MetaMusic):
|
||
mediainfo = self._music_info_from_meta(task.meta)
|
||
|
||
# 按名称识别时已在识别链路补图,这里只补齐显式ID识别的场景。
|
||
if mediainfo and need_obtain_images:
|
||
self.obtain_images(mediainfo=mediainfo)
|
||
|
||
if mediainfo and task.media_source:
|
||
mediainfo.scrape_source = task.media_source
|
||
|
||
if not mediainfo:
|
||
if task.preview:
|
||
return False, "未识别到媒体信息"
|
||
transferinfo = self._TransferChain__checkpoint_planning_rejection(
|
||
task,
|
||
"未识别到媒体信息",
|
||
)
|
||
if callback:
|
||
return callback(task, transferinfo)
|
||
return transferinfo.success, transferinfo.message or ""
|
||
|
||
mediainfo_changed = True
|
||
|
||
# accepted/planned 恢复使用持久快照;在线补充会让首次执行与重放产生漂移。
|
||
if not task.planning_context_restored:
|
||
supplemented = MediaChain().supplement_tmdb_info(mediainfo, task.meta)
|
||
mediainfo = cast(Union[MediaInfo, MusicInfo], supplemented)
|
||
task.mediainfo = mediainfo
|
||
if deferred := defer_music_recognition(self, task):
|
||
return deferred
|
||
|
||
# 只有 TMDB 主源沿用历史 TMDB 标题,避免辅助 ID 改写其它识别源标题。
|
||
if (
|
||
not self.runtime_config.scrape_follow_tmdb
|
||
and mediainfo.media_source == MediaSource.TMDB
|
||
):
|
||
transfer_history = transferhis.get_by_media_identity(
|
||
media_source=mediainfo.media_source.value,
|
||
media_id=mediainfo.media_id,
|
||
mtype=cast(MediaType, mediainfo.type).value,
|
||
)
|
||
if transfer_history and mediainfo.title != transfer_history.title:
|
||
mediainfo.title = transfer_history.title
|
||
mediainfo_changed = True
|
||
|
||
if mediainfo_changed:
|
||
task.mediainfo = mediainfo
|
||
if not self.jobview.migrate_task(task):
|
||
logger.info(f"{task.fileitem.name} 已存在整理任务,跳过重复处理")
|
||
return False, f"{task.fileitem.name} 已在整理队列中"
|
||
|
||
if (
|
||
mediainfo.type == MediaType.TV
|
||
and mediainfo.tmdb_id
|
||
and not task.episodes_info
|
||
and not task.planning_context_restored
|
||
):
|
||
# 第零季表示特别篇,不能按空值回退为第一季。
|
||
season_num = cast(MediaInfo, mediainfo).season
|
||
if season_num is None:
|
||
season_seq = task.meta.season_seq
|
||
season_num = int(season_seq) if season_seq.isdigit() else 1
|
||
task.episodes_info = TmdbChain().tmdb_episodes(
|
||
tmdbid=mediainfo.tmdb_id,
|
||
season=season_num,
|
||
episode_group=mediainfo.episode_group,
|
||
)
|
||
|
||
if not task.target_directory:
|
||
if task.target_path:
|
||
# 指定目标路径,`手动整理`场景下使用,忽略源目录匹配,使用指定目录匹配
|
||
task.target_directory = DirectoryHelper().get_dir(
|
||
media=task.mediainfo,
|
||
dest_path=task.target_path,
|
||
target_storage=task.target_storage,
|
||
)
|
||
else:
|
||
# 启用源目录匹配时,根据源目录匹配下载目录,否则按源目录同盘优先原则,如无源目录,则根据媒体信息获取目标目录
|
||
task.target_directory = DirectoryHelper().get_dir(
|
||
media=task.mediainfo,
|
||
storage=task.fileitem.storage,
|
||
src_path=Path(cast(str, task.fileitem.path)),
|
||
target_storage=task.target_storage,
|
||
)
|
||
if not task.target_storage and task.target_directory:
|
||
task.target_storage = task.target_directory.library_storage
|
||
|
||
if error_message := self._transfer_validation_error(task):
|
||
logger.error(f"{task.fileitem.name} {error_message}")
|
||
if task.preview:
|
||
return False, error_message
|
||
transferinfo = self._TransferChain__checkpoint_planning_rejection(
|
||
task,
|
||
error_message,
|
||
)
|
||
if callback:
|
||
return callback(task, transferinfo)
|
||
return transferinfo.success, transferinfo.message or ""
|
||
|
||
self.jobview.running_task(task)
|
||
|
||
# 广播事件,请示额外的源、目标存储支持。
|
||
source_oper = self._TransferChain__select_storage_oper(task.fileitem.storage)
|
||
target_oper = self._TransferChain__select_storage_oper(task.target_storage)
|
||
|
||
# 纯规划先提交 持久执行检查点,任何文件副作用只能发生在提交之后。
|
||
transferinfo = self._plan_checkpoint_and_execute(
|
||
task,
|
||
source_oper=source_oper,
|
||
target_oper=target_oper,
|
||
)
|
||
if not transferinfo:
|
||
logger.error("文件整理模块运行失败")
|
||
return False, "文件整理模块运行失败"
|
||
|
||
# 回调,位置传参:任务、整理结果
|
||
if callback:
|
||
return callback(task, transferinfo)
|
||
|
||
return transferinfo.success, transferinfo.message
|
||
|
||
finally:
|
||
self.jobview.try_remove_job(task)
|
||
self._finish_scrape_batch_task(task)
|