mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-31 20:34:48 +08:00
严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。 按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩, CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。 修复空值、异步和供应方类型边界,保持插件与 API 兼容合同, 并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。
1707 lines
73 KiB
Python
1707 lines
73 KiB
Python
"""
|
||
整理队列、工作线程、租约与恢复调度。
|
||
"""
|
||
|
||
import asyncio
|
||
import queue
|
||
import threading
|
||
import time
|
||
import traceback
|
||
import uuid
|
||
from pathlib import Path
|
||
from typing import Any, Callable, Dict, List, Optional, Tuple, cast
|
||
|
||
from pydantic_core import to_jsonable_python
|
||
|
||
from app.application.chain.context import ChainRuntimeContext
|
||
from app.application.directory import DirectoryHelper
|
||
from app.application.history import (
|
||
DownloadHistorySnapshot,
|
||
)
|
||
from app.application.transfer.execution import (
|
||
TransferExecutionCheckpoint,
|
||
TransferExecutionConflictError,
|
||
TransferExecutionRepository,
|
||
TransferExecutionSnapshot,
|
||
TransferExecutionState,
|
||
)
|
||
from app.application.transfer.jobs import JobId
|
||
from app.application.transfer.notifications import (
|
||
TransferNotificationAggregator,
|
||
TransferSuccessNotification,
|
||
)
|
||
from app.application.transfer.workflow import (
|
||
JobManager,
|
||
TransferAdmission,
|
||
TransferFailureNotificationAggregator,
|
||
TransferLeaseLostError,
|
||
TransferPlanningInput,
|
||
TransferPlanningStateError,
|
||
TransferQueue,
|
||
TransferQueueService,
|
||
TransferTask,
|
||
)
|
||
from app.chain.media import MediaChain
|
||
from app.chain.transfer.contract import _TransferOwnerBase
|
||
from app.chain.transfer.records import apply_download_history_classification
|
||
from app.runtime.log import logger
|
||
from app.runtime.progress import ProgressHelper
|
||
from app.runtime.stop import runtime_stop_state
|
||
from app.schemas.file import FileItem
|
||
from app.schemas.system import TransferDirectoryConf
|
||
from app.schemas.tmdb import TmdbEpisode
|
||
from app.schemas.transfer import DownloaderTorrent, TransferInfo, TransferJob, TransferQueueItemData
|
||
from app.schemas.types import (
|
||
MediaSource,
|
||
MediaType,
|
||
ProgressKey,
|
||
TorrentStatus,
|
||
)
|
||
|
||
task_lock = threading.Lock()
|
||
downloader_lock = threading.Lock()
|
||
|
||
_WORKER_RESTART_TIMEOUT_SECONDS = 30.0
|
||
_WORKER_CLOSE_TIMEOUT_SECONDS = 30.0
|
||
_QUEUE_ADMISSION_LOCK_WAIT_SECONDS = 0.1
|
||
|
||
|
||
def _unique_pending_torrents(
|
||
torrents: List[DownloaderTorrent], existing_hashes: set[str]
|
||
) -> List[DownloaderTorrent]:
|
||
"""
|
||
按下载器返回顺序排除已整理和本轮重复的任务。
|
||
|
||
:param torrents: 本轮已完成的下载任务
|
||
:param existing_hashes: 整理队列中已有的任务哈希
|
||
:return: 保留首次出现的待整理任务
|
||
"""
|
||
seen: set[Optional[str]] = set()
|
||
pending: List[DownloaderTorrent] = []
|
||
for torrent in torrents:
|
||
torrent_hash = torrent.hash
|
||
if torrent_hash in existing_hashes or torrent_hash in seen:
|
||
continue
|
||
seen.add(torrent_hash)
|
||
pending.append(torrent)
|
||
return pending
|
||
|
||
|
||
class TransferQueueOwner(_TransferOwnerBase):
|
||
"""
|
||
唯一持有整理工作线程、队列、租约和恢复调度。
|
||
"""
|
||
|
||
_WORKER_RESTART_TIMEOUT_SECONDS = _WORKER_RESTART_TIMEOUT_SECONDS
|
||
_WORKER_CLOSE_TIMEOUT_SECONDS = _WORKER_CLOSE_TIMEOUT_SECONDS
|
||
|
||
def __init__(
|
||
self,
|
||
runtime_context: Optional[ChainRuntimeContext] = None,
|
||
) -> None:
|
||
"""
|
||
初始化文件整理处理链。
|
||
"""
|
||
super().__init__(runtime_context=runtime_context)
|
||
self._refresh_file_extensions()
|
||
self._queue: queue.Queue[TransferQueue | object] = queue.Queue()
|
||
self._transfer_threads: list[threading.Thread] = []
|
||
# 队列间隔时间(秒)
|
||
self._transfer_interval = 15
|
||
self.jobview = JobManager()
|
||
# 整理失败通知聚合器
|
||
self.failure_notification_aggregator = TransferFailureNotificationAggregator()
|
||
self.success_notification_aggregator = TransferNotificationAggregator[TransferSuccessNotification]()
|
||
# 持久准入仓库先于内存入队保存任务,进程退出后仍可恢复。
|
||
self._transfer_admissions = self.transfer_admission_repository
|
||
self._transfer_executions = self.transfer_execution_repository
|
||
self._success_target_files: Dict[JobId, List[str]] = {}
|
||
# 批次级刮削缓冲,避免同一批多文件入库重复触发目录刮削
|
||
self._scrape_batches: Dict[str, Dict[str, Any]] = {}
|
||
self._progress = ProgressHelper(ProgressKey.FileTransfer)
|
||
self._threads: list[threading.Thread] = []
|
||
self._retiring_threads: List[threading.Thread] = []
|
||
self._queue_active = False
|
||
# 每一代执行线程 使用独立停止信号,避免热更新启动新执行线程 后旧线程重新取任务
|
||
self._worker_stop_event = threading.Event()
|
||
# 生命周期操作串行化;状态锁只保护短临界区,不覆盖同步文件 I/O 等待
|
||
self._worker_lifecycle_lock = threading.RLock()
|
||
self._worker_state_lock = threading.RLock()
|
||
self._closing = False
|
||
# 恢复调度与租约续期均由整理链持有,关闭时与执行线程 一并收敛。
|
||
self._worker_owner_id = uuid.uuid4().hex
|
||
self._owned_leases: Dict[str, Tuple[str, float]] = {}
|
||
self._queued_lease_tokens: set[Tuple[str, str]] = set()
|
||
self._resident_tasks: Dict[Tuple[str, str], TransferTask] = {}
|
||
self._replay_thread: Optional[threading.Thread] = None
|
||
self._replay_stop_event = threading.Event()
|
||
self._recovery_wakeup_event = threading.Event()
|
||
self._lease_heartbeat_thread: Optional[threading.Thread] = None
|
||
self._lease_heartbeat_stop_event = threading.Event()
|
||
self._lease_release_thread: Optional[threading.Thread] = None
|
||
self._active_tasks = 0
|
||
self._processed_num = 0
|
||
self._fail_num = 0
|
||
self._total_num = 0
|
||
self._TransferChain__init()
|
||
|
||
def _refresh_file_extensions(self) -> None:
|
||
"""
|
||
按最新运行配置更新整理允许的媒体、字幕和音频后缀。
|
||
"""
|
||
self._media_exts = self.runtime_config.video_extensions
|
||
self._subtitle_exts = self.runtime_config.subtitle_extensions
|
||
self._audio_exts = self.runtime_config.audio_extensions
|
||
self._allowed_exts = self._media_exts + self._audio_exts + self._subtitle_exts + (
|
||
".lrc", ".txt", ".yaml",
|
||
)
|
||
|
||
def _TransferChain__init(self) -> bool:
|
||
"""
|
||
启动一代文件整理线程,并返回是否成功取得 执行线程 所有权。
|
||
"""
|
||
with self._worker_lifecycle_lock:
|
||
with self._worker_state_lock:
|
||
if self._closing:
|
||
logger.warning("文件整理链已进入关闭状态,拒绝重新启动 worker")
|
||
return False
|
||
self._retiring_threads = [
|
||
thread for thread in self._retiring_threads if thread.is_alive()
|
||
]
|
||
alive_threads = [thread for thread in self._threads if thread.is_alive()]
|
||
if alive_threads:
|
||
logger.error(
|
||
"上一代文件整理线程尚未收敛,拒绝并行启动新 worker:%s",
|
||
", ".join(thread.name for thread in alive_threads),
|
||
)
|
||
self._threads = alive_threads
|
||
return False
|
||
stop_event = threading.Event()
|
||
threads = [
|
||
threading.Thread(
|
||
target=self._TransferChain__start_transfer,
|
||
args=(stop_event,),
|
||
name=f"transfer-{index}",
|
||
daemon=True,
|
||
)
|
||
for index in range(self.runtime_config.transfer_threads)
|
||
]
|
||
self._worker_stop_event = stop_event
|
||
self._threads = threads
|
||
self._queue_active = True
|
||
for index, thread in enumerate(threads):
|
||
logger.info(f"启动文件整理线程 {index + 1} ...")
|
||
thread.start()
|
||
return True
|
||
|
||
@staticmethod
|
||
def _TransferChain__join_threads(
|
||
threads: List[threading.Thread], deadline: float
|
||
) -> List[threading.Thread]:
|
||
"""
|
||
在统一截止时间内等待线程,返回仍未收敛且继续由调用方持有的线程。
|
||
"""
|
||
current_thread = threading.current_thread()
|
||
for thread in threads:
|
||
if thread is current_thread or not thread.is_alive():
|
||
continue
|
||
thread.join(timeout=max(0.0, deadline - time.monotonic()))
|
||
return [thread for thread in threads if thread.is_alive()]
|
||
|
||
def _TransferChain__acquire_worker_lifecycle_lock(self, deadline: float) -> bool:
|
||
"""
|
||
在统一截止时间内取得 执行线程 生命周期锁,并兼容同线程 RLock 重入。
|
||
"""
|
||
return self._worker_lifecycle_lock.acquire(
|
||
timeout=max(0.0, deadline - time.monotonic())
|
||
)
|
||
|
||
def _TransferChain__request_worker_stop(self) -> List[threading.Thread]:
|
||
"""
|
||
发布当前 执行线程 代的停止信号,并用哨兵唤醒空闲线程。
|
||
"""
|
||
with self._worker_state_lock:
|
||
self._queue_active = False
|
||
self._worker_stop_event.set()
|
||
current_threads = list(self._threads)
|
||
threads = [*self._retiring_threads, *current_threads]
|
||
for _ in current_threads:
|
||
self._queue.put(self._QUEUE_STOP_SENTINEL)
|
||
return threads
|
||
|
||
def _TransferChain__stop(self, timeout_seconds: float = _WORKER_RESTART_TIMEOUT_SECONDS) -> bool:
|
||
"""
|
||
在锁等待与线程 join 的共享预算内停止当前 执行线程 代。
|
||
"""
|
||
deadline = time.monotonic() + max(0.0, timeout_seconds)
|
||
if not self._TransferChain__acquire_worker_lifecycle_lock(deadline):
|
||
logger.error(
|
||
"未在 %.1f 秒内取得文件整理 worker 生命周期锁",
|
||
max(0.0, timeout_seconds),
|
||
)
|
||
return False
|
||
try:
|
||
threads = self._TransferChain__request_worker_stop()
|
||
alive_threads = self._TransferChain__join_threads(threads, deadline)
|
||
with self._worker_state_lock:
|
||
self._threads = []
|
||
self._retiring_threads = alive_threads
|
||
if alive_threads:
|
||
logger.error(
|
||
"文件整理线程未在 %.1f 秒内收敛,仍由 TransferChain 持有:%s",
|
||
max(0.0, timeout_seconds),
|
||
", ".join(thread.name for thread in alive_threads),
|
||
)
|
||
return False
|
||
logger.info("文件整理线程已停止")
|
||
return True
|
||
finally:
|
||
self._worker_lifecycle_lock.release()
|
||
|
||
def close_workers(self, timeout_seconds: float = _WORKER_CLOSE_TIMEOUT_SECONDS) -> bool:
|
||
"""
|
||
关闭整理 执行线程 与 pending 回放,并拒绝后续队列写入。
|
||
|
||
这是宿主生命周期使用的同步边界。停止信号只能阻止线程领取下一项工作,不能
|
||
取消已经进入同步文件或数据库 I/O 的调用;超过预算时保留活线程句柄并返回
|
||
False,调用方据此避免过早释放仍被使用的数据库等下游资源。
|
||
|
||
:param timeout_seconds: 生命周期锁、执行线程 与回放线程共享的最大等待秒数
|
||
:return: 全部后台线程均已收敛时返回 True,否则返回 False
|
||
"""
|
||
self._TransferChain__ensure_lease_runtime_state()
|
||
deadline = time.monotonic() + max(0.0, timeout_seconds)
|
||
if not self._TransferChain__acquire_worker_lifecycle_lock(deadline):
|
||
logger.error(
|
||
"未在 %.1f 秒内取得整理后台生命周期锁,关闭未开始",
|
||
max(0.0, timeout_seconds),
|
||
)
|
||
return False
|
||
try:
|
||
with self._worker_state_lock:
|
||
self._closing = True
|
||
worker_threads = self._TransferChain__request_worker_stop()
|
||
replay_thread = self._replay_thread
|
||
heartbeat_thread = self._lease_heartbeat_thread
|
||
self._replay_stop_event.set()
|
||
self._recovery_wakeup_event.set()
|
||
|
||
alive_workers = self._TransferChain__join_threads(worker_threads, deadline)
|
||
alive_replays = self._TransferChain__join_threads(
|
||
[replay_thread] if replay_thread else [], deadline
|
||
)
|
||
with self._worker_state_lock:
|
||
self._threads = []
|
||
self._retiring_threads = alive_workers
|
||
if (
|
||
replay_thread
|
||
and replay_thread not in alive_replays
|
||
and self._replay_thread is replay_thread
|
||
):
|
||
self._replay_thread = None
|
||
|
||
alive_threads = [*alive_workers, *alive_replays]
|
||
if alive_threads:
|
||
logger.error(
|
||
"整理后台线程未在 %.1f 秒内收敛,仍由 TransferChain 持有:%s",
|
||
max(0.0, timeout_seconds),
|
||
", ".join(thread.name for thread in alive_threads),
|
||
)
|
||
return False
|
||
with self._worker_state_lock:
|
||
release_thread = self._TransferChain__start_lease_release_owner_locked(
|
||
error="整理宿主关闭,释放未结算任务租约"
|
||
)
|
||
alive_releases = self._TransferChain__join_threads(
|
||
[release_thread] if release_thread else [], deadline
|
||
)
|
||
if alive_releases:
|
||
logger.error(
|
||
"整理租约释放线程未在 %.1f 秒内收敛,heartbeat 保持运行:%s",
|
||
max(0.0, timeout_seconds),
|
||
", ".join(thread.name for thread in alive_releases),
|
||
)
|
||
return False
|
||
with self._worker_state_lock:
|
||
if self._lease_release_thread is release_thread:
|
||
self._lease_release_thread = None
|
||
self._lease_heartbeat_stop_event.set()
|
||
alive_heartbeats = self._TransferChain__join_threads(
|
||
[heartbeat_thread] if heartbeat_thread else [], deadline
|
||
)
|
||
if heartbeat_thread and not alive_heartbeats:
|
||
with self._worker_state_lock:
|
||
if self._lease_heartbeat_thread is heartbeat_thread:
|
||
self._lease_heartbeat_thread = None
|
||
if alive_heartbeats:
|
||
logger.error(
|
||
"整理租约续期线程未在 %.1f 秒内收敛:%s",
|
||
max(0.0, timeout_seconds),
|
||
", ".join(thread.name for thread in alive_heartbeats),
|
||
)
|
||
return False
|
||
logger.info("文件整理 worker 与待处理回放线程已关闭")
|
||
return True
|
||
finally:
|
||
self._worker_lifecycle_lock.release()
|
||
|
||
async def close(self, timeout_seconds: float = _WORKER_CLOSE_TIMEOUT_SECONDS) -> bool:
|
||
"""
|
||
收口整理线程和失败通知,并返回依赖是否可以安全释放。
|
||
|
||
同步文件 I/O 在线程内无法被 asyncio 取消,因此先在线程池中执行有界
|
||
``close_workers``。只有 执行线程 与 replay 全部退出后才关闭通知和重试;若
|
||
超时则保留通知依赖,供仍在运行的整理回调继续使用。
|
||
|
||
:param timeout_seconds: 执行线程 与 replay 共享的最大等待秒数
|
||
:return: 所有整理后台任务 均已收敛时返回 True
|
||
"""
|
||
workers_closed = await asyncio.to_thread(
|
||
self.close_workers,
|
||
timeout_seconds,
|
||
)
|
||
if not workers_closed:
|
||
return False
|
||
self.failure_notification_aggregator.close()
|
||
self.success_notification_aggregator.close()
|
||
return True
|
||
|
||
def on_config_changed(self) -> None:
|
||
"""
|
||
配置变更时重启文件整理线程。
|
||
"""
|
||
with self._worker_lifecycle_lock:
|
||
if self._closing:
|
||
logger.info("文件整理链正在关闭,忽略 worker 配置热更新")
|
||
return
|
||
self._refresh_file_extensions()
|
||
if not self._TransferChain__stop(
|
||
timeout_seconds=self._WORKER_RESTART_TIMEOUT_SECONDS
|
||
):
|
||
logger.warning(
|
||
"旧文件整理 worker 仍在收尾;其停止信号保持有效,新一代接管后续队列"
|
||
)
|
||
self._TransferChain__init()
|
||
|
||
def _TransferChain__get_transfer_target_dir_path(
|
||
self, transferinfo: Optional[TransferInfo]
|
||
) -> Optional[str]:
|
||
"""
|
||
获取整理目标目录路径,兼容 OpenList 等成功后目录项短时间不可见的存储。
|
||
"""
|
||
if not transferinfo:
|
||
return None
|
||
if transferinfo.target_diritem and transferinfo.target_diritem.path:
|
||
return transferinfo.target_diritem.path
|
||
if transferinfo.target_item and transferinfo.target_item.path:
|
||
return Path(transferinfo.target_item.path).parent.as_posix()
|
||
if transferinfo.file_list_new:
|
||
return Path(transferinfo.file_list_new[0]).parent.as_posix()
|
||
return None
|
||
|
||
def put_to_queue(self, task: TransferTask) -> bool:
|
||
"""
|
||
串行准入并入队,数据库等待不占用心跳所需的状态锁。
|
||
|
||
:param task: 任务信息
|
||
:return: 新任务入队或相同恢复租约已被接收时返回 True;关闭、无效或冲突返回 False
|
||
:raises Exception: 持久准入、批次登记或内存入队失败
|
||
"""
|
||
self._TransferChain__ensure_lease_runtime_state()
|
||
# 关闭会持生命周期锁等待 执行线程或恢复任务,等待入队锁时必须能响应关闭。
|
||
while not self._closing:
|
||
if not self._worker_lifecycle_lock.acquire(timeout=_QUEUE_ADMISSION_LOCK_WAIT_SECONDS):
|
||
continue
|
||
try:
|
||
if self._closing:
|
||
break
|
||
if isinstance(task.lease_token, str) and task.lease_token:
|
||
return self._TransferChain__enqueue_claimed_task(task)
|
||
return self._transfer_queue_service().put(task, self._TransferChain__default_callback)
|
||
finally:
|
||
self._worker_lifecycle_lock.release()
|
||
logger.warning("文件整理链已关闭,拒绝新的队列任务")
|
||
return False
|
||
|
||
def _transfer_queue_service(self) -> TransferQueueService:
|
||
"""
|
||
构建保持旧队列对象和私有兼容接缝的应用服务。
|
||
"""
|
||
return TransferQueueService(
|
||
register_task=self._TransferChain__put_to_jobview,
|
||
admit_task=self._TransferChain__admit_transfer,
|
||
enqueue=self._enqueue_transfer_task,
|
||
before_enqueue=self._register_scrape_batch_task,
|
||
enqueue_failed=self._TransferChain__record_enqueue_failure,
|
||
remove_task=self.jobview.remove_task,
|
||
list_tasks=self.jobview.list_jobs,
|
||
expire_tasks=self._TransferChain__expire_stale_transfer_tasks,
|
||
list_tasks_page=self.jobview.list_jobs_page,
|
||
)
|
||
|
||
def replay_pending(self) -> None:
|
||
"""
|
||
启动唯一恢复调度任务,并唤醒一次即时恢复扫描。
|
||
|
||
启动回放、同进程入队补偿和租约过期接管都经由这个入口。调度线程只负责
|
||
claim 和重新入队;实际业务仍由普通整理 执行线程 执行。
|
||
"""
|
||
self._TransferChain__ensure_recovery_scheduler(immediate=True)
|
||
|
||
def _TransferChain__ensure_recovery_scheduler(self, *, immediate: bool) -> None:
|
||
"""
|
||
确保唯一恢复调度任务 存在,并只为显式请求执行即时扫描。
|
||
"""
|
||
self._TransferChain__ensure_lease_runtime_state()
|
||
with self._worker_state_lock:
|
||
if self._closing:
|
||
logger.info("文件整理链正在关闭,跳过待处理文件回放")
|
||
return
|
||
self._TransferChain__start_lease_heartbeat_owner_locked()
|
||
if self._replay_thread and self._replay_thread.is_alive():
|
||
if immediate:
|
||
self._recovery_wakeup_event.set()
|
||
return
|
||
thread = threading.Thread(
|
||
target=self._TransferChain__run_replay_pending,
|
||
args=(self._replay_stop_event, immediate),
|
||
name="MoviePilot-TransferReplay",
|
||
daemon=True,
|
||
)
|
||
self._replay_thread = thread
|
||
if immediate:
|
||
self._recovery_wakeup_event.set()
|
||
# 在状态锁内启动,避免 close_workers 看到尚未 start 的线程后错误 join。
|
||
thread.start()
|
||
|
||
def _TransferChain__run_replay_pending(
|
||
self,
|
||
stop_event: threading.Event,
|
||
initial_immediate: bool = True,
|
||
) -> None:
|
||
"""
|
||
按初次扫描意图持续恢复,失败兜底新建调度任务 时先等待固定轮询。
|
||
"""
|
||
try:
|
||
if not initial_immediate:
|
||
self._recovery_wakeup_event.wait(
|
||
timeout=self._RECOVERY_POLL_INTERVAL_SECONDS
|
||
)
|
||
while not stop_event.is_set():
|
||
self._recovery_wakeup_event.clear()
|
||
self._TransferChain__replay_pending(stop_event)
|
||
self._recovery_wakeup_event.wait(
|
||
timeout=self._RECOVERY_POLL_INTERVAL_SECONDS
|
||
)
|
||
finally:
|
||
with self._worker_state_lock:
|
||
if self._replay_thread is threading.current_thread():
|
||
self._replay_thread = None
|
||
|
||
def _TransferChain__ensure_lease_runtime_state(self) -> None:
|
||
"""
|
||
为绕过构造器的兼容调用补齐进程持有者 与租约线程状态。
|
||
"""
|
||
if not hasattr(self, "_worker_owner_id"):
|
||
self._worker_owner_id = uuid.uuid4().hex
|
||
if not hasattr(self, "_owned_leases"):
|
||
self._owned_leases = {}
|
||
if not hasattr(self, "_queued_lease_tokens"):
|
||
self._queued_lease_tokens = set()
|
||
if not hasattr(self, "_resident_tasks"):
|
||
self._resident_tasks = {}
|
||
if not hasattr(self, "_recovery_wakeup_event"):
|
||
self._recovery_wakeup_event = threading.Event()
|
||
if not hasattr(self, "_lease_heartbeat_thread"):
|
||
self._lease_heartbeat_thread = None
|
||
if not hasattr(self, "_lease_heartbeat_stop_event"):
|
||
self._lease_heartbeat_stop_event = threading.Event()
|
||
if not hasattr(self, "_lease_release_thread"):
|
||
self._lease_release_thread = None
|
||
if not hasattr(self, "_replay_thread"):
|
||
self._replay_thread = None
|
||
if not hasattr(self, "_replay_stop_event"):
|
||
self._replay_stop_event = threading.Event()
|
||
if not hasattr(self, "_worker_state_lock"):
|
||
self._worker_state_lock = threading.RLock()
|
||
if not hasattr(self, "_closing"):
|
||
self._closing = False
|
||
|
||
def _TransferChain__start_lease_heartbeat_owner_locked(self) -> None:
|
||
"""
|
||
在状态锁内确保当前进程只有一个租约续期线程。
|
||
"""
|
||
heartbeat_thread = self._lease_heartbeat_thread
|
||
if heartbeat_thread and heartbeat_thread.is_alive():
|
||
return
|
||
heartbeat_thread = threading.Thread(
|
||
target=self._TransferChain__run_lease_heartbeat,
|
||
args=(self._lease_heartbeat_stop_event,),
|
||
name="MoviePilot-TransferLeaseHeartbeat",
|
||
daemon=True,
|
||
)
|
||
self._lease_heartbeat_thread = heartbeat_thread
|
||
heartbeat_thread.start()
|
||
|
||
def _TransferChain__ensure_lease_heartbeat_owner(self) -> None:
|
||
"""
|
||
按需启动进程级租约续期任务,供启动前到达的普通任务使用。
|
||
"""
|
||
self._TransferChain__ensure_lease_runtime_state()
|
||
with self._worker_state_lock:
|
||
if not self._closing:
|
||
self._TransferChain__start_lease_heartbeat_owner_locked()
|
||
|
||
def _TransferChain__claim_recovery_batch(self) -> List[TransferAdmission]:
|
||
"""
|
||
按 执行线程 并发度 claim 当前可安全占用的恢复租约槽位。
|
||
"""
|
||
runtime_config = getattr(self, "runtime_config", None)
|
||
worker_capacity = max(
|
||
1,
|
||
int(getattr(runtime_config, "transfer_threads", 1)),
|
||
)
|
||
with self._worker_state_lock:
|
||
owned_count = len(self._owned_leases)
|
||
recovery_capacity = max(0, worker_capacity - owned_count)
|
||
if recovery_capacity <= 0:
|
||
return []
|
||
return cast(
|
||
List[TransferAdmission],
|
||
self._transfer_admissions.claim_recoverable(
|
||
owner_id=self._worker_owner_id,
|
||
limit=min(self._RECOVERY_CLAIM_LIMIT, recovery_capacity),
|
||
lease_seconds=self._WORKER_LEASE_SECONDS,
|
||
),
|
||
)
|
||
|
||
def _TransferChain__run_lease_heartbeat(self, stop_event: threading.Event) -> None:
|
||
"""
|
||
按固定周期续期本进程已 claim 且尚未结算的任务。
|
||
"""
|
||
try:
|
||
while not stop_event.wait(self._LEASE_HEARTBEAT_INTERVAL_SECONDS):
|
||
self._TransferChain__heartbeat_owned_leases()
|
||
finally:
|
||
with self._worker_state_lock:
|
||
if self._lease_heartbeat_thread is threading.current_thread():
|
||
self._lease_heartbeat_thread = None
|
||
|
||
def _TransferChain__heartbeat_owned_leases(self) -> None:
|
||
"""
|
||
经 Application Port 续期所有排队中或执行中的任务租约。
|
||
"""
|
||
self._TransferChain__ensure_lease_runtime_state()
|
||
with self._worker_state_lock:
|
||
owned_leases = list(self._owned_leases.items())
|
||
for task_id, (lease_token, deadline) in owned_leases:
|
||
try:
|
||
admission = self._transfer_admissions.heartbeat(
|
||
task_id=task_id,
|
||
lease_token=lease_token,
|
||
lease_seconds=self._WORKER_LEASE_SECONDS,
|
||
)
|
||
except Exception as err:
|
||
if time.monotonic() >= deadline:
|
||
self._TransferChain__forget_owned_lease(task_id, lease_token)
|
||
logger.error(
|
||
"整理任务租约续期持续失败并已超过本地期限:%s - %s",
|
||
task_id,
|
||
err,
|
||
)
|
||
else:
|
||
logger.error(f"整理任务租约续期失败:{task_id} - {err}")
|
||
continue
|
||
if (
|
||
admission is None
|
||
or admission.lease_owner != self._worker_owner_id
|
||
or admission.lease_token != lease_token
|
||
):
|
||
self._TransferChain__forget_owned_lease(task_id, lease_token)
|
||
logger.error(f"整理任务租约已失效或被接管:{task_id}")
|
||
continue
|
||
with self._worker_state_lock:
|
||
current = self._owned_leases.get(task_id)
|
||
if current and current[0] == lease_token:
|
||
self._owned_leases[task_id] = (
|
||
lease_token,
|
||
time.monotonic() + self._WORKER_LEASE_SECONDS,
|
||
)
|
||
|
||
def _TransferChain__forget_owned_lease(self, task_id: str, lease_token: str) -> None:
|
||
"""
|
||
仅在 token 仍匹配时移除本进程租约镜像,避免删掉新接管记录。
|
||
"""
|
||
with self._worker_state_lock:
|
||
current = self._owned_leases.get(task_id)
|
||
if current and current[0] == lease_token:
|
||
self._owned_leases.pop(task_id, None)
|
||
self._queued_lease_tokens.discard((task_id, lease_token))
|
||
|
||
def _TransferChain__is_claimed_task_enqueued(self, task_id: str, lease_token: str) -> bool:
|
||
"""
|
||
返回 claim 是否已被队列接收,包含刚被 执行线程 取走但尚未完成的任务。
|
||
"""
|
||
with self._worker_state_lock:
|
||
return (task_id, lease_token) in self._queued_lease_tokens or any(
|
||
(task.admission_task_id, task.lease_token) == (task_id, lease_token)
|
||
for task in getattr(self, "_resident_tasks", {}).values()
|
||
)
|
||
|
||
def _TransferChain__owns_lease(self, task_id: str, lease_token: Optional[str]) -> bool:
|
||
"""
|
||
返回本地续期镜像是否仍持有指定 token。
|
||
"""
|
||
if not lease_token:
|
||
return False
|
||
with self._worker_state_lock:
|
||
current = self._owned_leases.get(task_id)
|
||
return bool(current and current[0] == lease_token)
|
||
|
||
def _TransferChain__assert_owned_lease(self, task: TransferTask) -> None:
|
||
"""
|
||
拒绝无 token、已过本地期限或已被 heartbeat 判失效的任务推进。
|
||
"""
|
||
if task.preview:
|
||
return
|
||
task_id = task.admission_task_id
|
||
lease_token = task.lease_token
|
||
if (
|
||
not task_id
|
||
or not lease_token
|
||
or task.lease_owner != self._worker_owner_id
|
||
):
|
||
raise TransferLeaseLostError("整理任务缺少当前进程的有效执行租约")
|
||
with self._worker_state_lock:
|
||
current = self._owned_leases.get(task_id)
|
||
if (
|
||
current is None
|
||
or current[0] != lease_token
|
||
or current[1] <= time.monotonic()
|
||
):
|
||
if current and current[0] == lease_token:
|
||
self._owned_leases.pop(task_id, None)
|
||
raise TransferLeaseLostError(f"整理任务租约已经失效:{task_id}")
|
||
|
||
def _TransferChain__release_task_claim(
|
||
self,
|
||
task: TransferTask,
|
||
*,
|
||
error: Optional[str] = None,
|
||
) -> bool:
|
||
"""
|
||
按 task token 释放未到终态的 claim,并按固定轮询等待恢复。
|
||
"""
|
||
task_id = task.admission_task_id if task else None
|
||
lease_token = task.lease_token if task else None
|
||
if not task_id or not lease_token:
|
||
return False
|
||
try:
|
||
return bool(
|
||
self._transfer_admissions.release_claim(
|
||
task_id=task_id,
|
||
lease_token=lease_token,
|
||
error=error,
|
||
)
|
||
)
|
||
except Exception as err:
|
||
logger.error(f"释放整理任务租约失败:{task_id} - {err}")
|
||
return False
|
||
finally:
|
||
self._TransferChain__forget_owned_lease(task_id, lease_token)
|
||
self._TransferChain__ensure_recovery_scheduler(immediate=False)
|
||
|
||
def _TransferChain__release_admission_claim(
|
||
self,
|
||
admission: TransferAdmission,
|
||
*,
|
||
error: Optional[str] = None,
|
||
) -> None:
|
||
"""
|
||
释放尚未绑定或成功入队的恢复 claim。
|
||
"""
|
||
if not admission.lease_token:
|
||
return
|
||
try:
|
||
self._transfer_admissions.release_claim(
|
||
task_id=admission.task_id,
|
||
lease_token=admission.lease_token,
|
||
error=error,
|
||
)
|
||
except Exception as err:
|
||
logger.error(f"释放恢复任务租约失败:{admission.task_id} - {err}")
|
||
finally:
|
||
self._TransferChain__forget_owned_lease(admission.task_id, admission.lease_token)
|
||
|
||
def _TransferChain__release_all_owned_leases(self, *, error: str) -> None:
|
||
"""
|
||
在执行任务 全部收敛后释放剩余租约,避免关停后等待租约自然过期。
|
||
"""
|
||
with self._worker_state_lock:
|
||
owned_leases = list(self._owned_leases.items())
|
||
for task_id, (lease_token, _deadline) in owned_leases:
|
||
try:
|
||
self._transfer_admissions.release_claim(
|
||
task_id=task_id,
|
||
lease_token=lease_token,
|
||
error=error,
|
||
)
|
||
except Exception as err:
|
||
logger.error(f"关闭时释放整理任务租约失败:{task_id} - {err}")
|
||
finally:
|
||
self._TransferChain__forget_owned_lease(task_id, lease_token)
|
||
|
||
def _TransferChain__start_lease_release_owner_locked(
|
||
self, *, error: str
|
||
) -> Optional[threading.Thread]:
|
||
"""
|
||
启动并持有唯一租约释放线程,使同步数据库阻塞不突破关闭预算。
|
||
"""
|
||
release_thread = self._lease_release_thread
|
||
if release_thread is not None:
|
||
return release_thread
|
||
if not self._owned_leases:
|
||
return None
|
||
release_thread = threading.Thread(
|
||
target=self._TransferChain__release_all_owned_leases,
|
||
kwargs={"error": error},
|
||
name="MoviePilot-TransferLeaseRelease",
|
||
daemon=True,
|
||
)
|
||
self._lease_release_thread = release_thread
|
||
release_thread.start()
|
||
return release_thread
|
||
|
||
@staticmethod
|
||
def _resident_task_key(task: TransferTask) -> Tuple[str, str]:
|
||
"""
|
||
真实队列凭证使用存储与路径,不依赖媒体识别分组或视图状态。
|
||
"""
|
||
assert task.fileitem is not None
|
||
assert task.fileitem.path
|
||
path = Path(str(task.fileitem.path).replace("\\", "/")).as_posix().rstrip("/") or "/"
|
||
return task.fileitem.storage or "local", path
|
||
|
||
def _enqueue_transfer_task(self, item: TransferQueue) -> None:
|
||
"""
|
||
入队成功后登记真实任务及其租约,登记持续到 执行线程 完成该队列项。
|
||
"""
|
||
task = item.task
|
||
assert task is not None
|
||
key = self._resident_task_key(task)
|
||
with self._worker_state_lock:
|
||
self._queue.put(item)
|
||
self._resident_tasks[key] = task
|
||
if task.admission_task_id and task.lease_token:
|
||
self._queued_lease_tokens.add((task.admission_task_id, task.lease_token))
|
||
|
||
def _finish_queue_item(self, task: TransferTask) -> None:
|
||
"""
|
||
仅移除当前队列项的执行登记,避免旧 执行线程 清掉同源新任务。
|
||
"""
|
||
with self._worker_state_lock:
|
||
residents = getattr(self, "_resident_tasks", {})
|
||
key = self._resident_task_key(task)
|
||
if residents.get(key) is task:
|
||
residents.pop(key)
|
||
if task.admission_task_id and task.lease_token:
|
||
self._queued_lease_tokens.discard((task.admission_task_id, task.lease_token))
|
||
self._queue.task_done()
|
||
|
||
def _TransferChain__enqueue_claimed_task(self, task: TransferTask) -> bool:
|
||
"""
|
||
复用持有相同租约的真实队列任务;残留视图必须重新入队才能算恢复。
|
||
"""
|
||
self._TransferChain__assert_owned_lease(task)
|
||
with self._worker_state_lock:
|
||
resident = self._resident_tasks.get(self._resident_task_key(task))
|
||
if resident is not None:
|
||
return (resident.admission_task_id, resident.lease_token) == (
|
||
task.admission_task_id, task.lease_token
|
||
)
|
||
if not self._TransferChain__put_to_jobview(task):
|
||
# 作业组未完成时会保留单个文件的旧终态;视图不等于真实队列。
|
||
self.jobview.remove_task(task.fileitem)
|
||
if not self._TransferChain__put_to_jobview(task):
|
||
return False
|
||
try:
|
||
self._register_scrape_batch_task(task)
|
||
self._enqueue_transfer_task(
|
||
TransferQueue(task=task, callback=self._TransferChain__default_callback)
|
||
)
|
||
except Exception as err:
|
||
try:
|
||
self._TransferChain__record_enqueue_failure(task, err)
|
||
finally:
|
||
self.jobview.remove_task(task.fileitem)
|
||
raise
|
||
return True
|
||
|
||
def _TransferChain__replay_pending(
|
||
self, stop_event: Optional[threading.Event] = None
|
||
) -> None:
|
||
"""
|
||
把落盘登记的待整理文件重新送回整理入口。
|
||
|
||
accepted 记录按原始请求重新规划;planned 记录绑定已提交检查点,重新识别
|
||
领域对象后只执行冻结路径,不再次触发重命名或目标计算。
|
||
|
||
:param stop_event: 宿主关闭信号;只阻止处理下一条登记,不取消运行中的同步 I/O
|
||
"""
|
||
stop_event = stop_event or threading.Event()
|
||
if stop_event.is_set():
|
||
return
|
||
self._TransferChain__ensure_lease_runtime_state()
|
||
try:
|
||
pendings = self._TransferChain__claim_recovery_batch()
|
||
except Exception as err:
|
||
logger.error(f"读取待整理文件登记失败:{err}")
|
||
return
|
||
if not pendings:
|
||
return
|
||
try:
|
||
for admission in pendings:
|
||
self._TransferChain__register_claimed_admission(admission)
|
||
except Exception as err:
|
||
logger.error(f"登记恢复任务租约失败:{err}")
|
||
for claimed in pendings:
|
||
self._TransferChain__release_admission_claim(claimed, error=str(err))
|
||
return
|
||
logger.info(f"发现 {len(pendings)} 个上次未整理完的文件,正在重新送入整理链 ...")
|
||
replayed = 0
|
||
for index, admission in enumerate(pendings):
|
||
if stop_event.is_set():
|
||
for unprocessed in pendings[index:]:
|
||
self._TransferChain__release_admission_claim(
|
||
unprocessed,
|
||
error="整理宿主关闭,恢复任务尚未入队",
|
||
)
|
||
break
|
||
storage = admission.storage
|
||
src_path = admission.src_path
|
||
try:
|
||
execution_snapshot = self._TransferChain__execution_replay_snapshot(admission)
|
||
use_frozen_source = bool(
|
||
admission.checkpoint is not None
|
||
or execution_snapshot.state is not TransferExecutionState.NOT_STARTED
|
||
or execution_snapshot.steps
|
||
)
|
||
fileitem: Optional[FileItem]
|
||
if use_frozen_source:
|
||
planning_input = admission.planning_input
|
||
if planning_input is None:
|
||
raise TransferPlanningStateError(
|
||
"执行态回放缺少整理规划输入"
|
||
)
|
||
fileitem = FileItem.model_validate(
|
||
planning_input.source_fileitem
|
||
)
|
||
should_discard = False
|
||
else:
|
||
fileitem, should_discard = self._TransferChain__build_replay_fileitem(
|
||
storage,
|
||
src_path,
|
||
admission.planning_input,
|
||
)
|
||
# stat 等同步 I/O 返回后重新检查,关闭期间不得注销尚未完成的登记。
|
||
if stop_event.is_set():
|
||
self._TransferChain__release_admission_claim(
|
||
admission,
|
||
error="整理宿主关闭,恢复任务尚未入队",
|
||
)
|
||
for unprocessed in pendings[index + 1:]:
|
||
self._TransferChain__release_admission_claim(
|
||
unprocessed,
|
||
error="整理宿主关闭,恢复任务尚未入队",
|
||
)
|
||
break
|
||
if not fileitem:
|
||
if should_discard:
|
||
# 源文件确认已消失,注销登记避免每次启动重复回放
|
||
lease_token = admission.lease_token
|
||
if lease_token is None:
|
||
raise TransferLeaseLostError(
|
||
f"恢复任务缺少 lease token:{admission.task_id}"
|
||
)
|
||
discarded = self._transfer_admissions.abandon_unstarted(
|
||
task_id=admission.task_id,
|
||
lease_token=lease_token,
|
||
)
|
||
if not discarded:
|
||
logger.warning(
|
||
f"恢复任务终态注销被 CAS 拒绝:{admission.task_id}"
|
||
)
|
||
self._TransferChain__release_admission_claim(
|
||
admission,
|
||
error="源已消失但任务状态已变化,保留登记供恢复",
|
||
)
|
||
else:
|
||
self._TransferChain__forget_owned_lease(
|
||
admission.task_id,
|
||
lease_token,
|
||
)
|
||
else:
|
||
self._TransferChain__release_admission_claim(
|
||
admission,
|
||
error="恢复源文件暂时不可读取",
|
||
)
|
||
continue
|
||
if admission.checkpoint:
|
||
if self._TransferChain__queue_planned_replay(
|
||
fileitem,
|
||
admission,
|
||
execution_checkpoint=(
|
||
execution_snapshot.checkpoint
|
||
if execution_snapshot.state is TransferExecutionState.SETTLING
|
||
else None
|
||
),
|
||
):
|
||
replayed += 1
|
||
else:
|
||
self._TransferChain__release_admission_claim(
|
||
admission,
|
||
error="恢复任务未进入内存队列",
|
||
)
|
||
continue
|
||
planning_input = admission.planning_input
|
||
if planning_input and (
|
||
planning_input.meta
|
||
or planning_input.mediainfo
|
||
or planning_input.episodes_info
|
||
):
|
||
if self._TransferChain__queue_accepted_replay(fileitem, admission):
|
||
replayed += 1
|
||
else:
|
||
self._TransferChain__release_admission_claim(
|
||
admission,
|
||
error="恢复任务未进入内存队列",
|
||
)
|
||
continue
|
||
replay_kwargs = self._TransferChain__build_replay_kwargs(planning_input)
|
||
self._execute_transfer(
|
||
fileitem=fileitem,
|
||
recovery_admission=admission,
|
||
**replay_kwargs,
|
||
)
|
||
assert admission.lease_token is not None
|
||
if self._TransferChain__is_claimed_task_enqueued(
|
||
admission.task_id,
|
||
admission.lease_token,
|
||
):
|
||
replayed += 1
|
||
else:
|
||
self._TransferChain__release_admission_claim(
|
||
admission,
|
||
error="旧恢复入口未产生可执行队列任务",
|
||
)
|
||
except Exception as err:
|
||
logger.error(f"回放待整理文件失败:{storage}:{src_path} - {err}")
|
||
if self._TransferChain__owns_lease(admission.task_id, admission.lease_token):
|
||
self._TransferChain__release_admission_claim(admission, error=str(err))
|
||
if stop_event.is_set():
|
||
logger.info(
|
||
"待整理文件回放收到关闭请求,已送入 %s 个文件,其余登记保持待处理",
|
||
replayed,
|
||
)
|
||
else:
|
||
self._TransferChain__log_replay_summary(replayed, len(pendings))
|
||
|
||
@staticmethod
|
||
def _TransferChain__log_replay_summary(replayed: int, claimed: int) -> None:
|
||
"""
|
||
按实际入队结果记录恢复汇总,避免零任务仍输出成功标记。
|
||
"""
|
||
if replayed:
|
||
logger.info(f"✓ 待整理文件回放完成,{replayed} 个文件已重新送入整理链")
|
||
return
|
||
logger.warning(
|
||
f"待整理文件回放未入队:claim {claimed} 个,"
|
||
"原因见前序日志与 transferpending.last_error"
|
||
)
|
||
|
||
def _TransferChain__execution_replay_snapshot(
|
||
self,
|
||
admission: TransferAdmission,
|
||
) -> TransferExecutionSnapshot:
|
||
"""
|
||
读取已 claim 的执行快照,确保任何步骤证据都先于源文件探测。
|
||
"""
|
||
repository: Optional[TransferExecutionRepository] = getattr(
|
||
self,
|
||
"_transfer_executions",
|
||
None,
|
||
)
|
||
if repository is None:
|
||
raise RuntimeError("整理恢复缺少 execution repository")
|
||
snapshot = repository.get_snapshot(task_id=admission.task_id)
|
||
if snapshot is None:
|
||
raise TransferExecutionConflictError("整理恢复状态不完整,请重新识别文件后再整理")
|
||
if (
|
||
snapshot.state is TransferExecutionState.SETTLING
|
||
and snapshot.checkpoint is None
|
||
):
|
||
raise TransferExecutionConflictError(
|
||
"整理任务记录不完整,请重新识别文件后再整理"
|
||
)
|
||
return snapshot
|
||
|
||
@staticmethod
|
||
def _TransferChain__build_replay_kwargs(
|
||
planning_input: Optional[TransferPlanningInput],
|
||
) -> dict[str, Any]:
|
||
"""
|
||
从持久请求快照恢复公开整理入口可接受的稳定参数。
|
||
"""
|
||
if planning_input is None:
|
||
return {}
|
||
options = planning_input.options
|
||
target_directory_payload = planning_input.target_directory
|
||
cleanup_payload = options.get("cleanup_dest_fileitem")
|
||
try:
|
||
media_source = (
|
||
MediaSource(planning_input.media_source)
|
||
if planning_input.media_source
|
||
else None
|
||
)
|
||
except ValueError:
|
||
media_source = None
|
||
try:
|
||
media_type = (
|
||
MediaType(planning_input.media_type)
|
||
if planning_input.media_type
|
||
else None
|
||
)
|
||
except ValueError:
|
||
media_type = None
|
||
return {
|
||
"mtype": media_type,
|
||
"media_source": media_source,
|
||
"media_id": planning_input.media_id,
|
||
"target_directory": (
|
||
TransferDirectoryConf.model_validate(target_directory_payload)
|
||
if target_directory_payload
|
||
else None
|
||
),
|
||
"target_storage": planning_input.target_storage,
|
||
"target_path": (
|
||
Path(planning_input.target_path)
|
||
if planning_input.target_path
|
||
else None
|
||
),
|
||
"transfer_type": planning_input.requested_transfer_type,
|
||
"scrape": options.get("scrape"),
|
||
"library_type_folder": options.get("library_type_folder"),
|
||
"library_category_folder": options.get("library_category_folder"),
|
||
"downloader": options.get("downloader"),
|
||
"download_hash": options.get("download_hash"),
|
||
"background": True,
|
||
"manual": bool(options.get("manual")),
|
||
"preview": False,
|
||
"cleanup_dest_fileitem": (
|
||
FileItem.model_validate(cleanup_payload)
|
||
if isinstance(cleanup_payload, dict)
|
||
else None
|
||
),
|
||
}
|
||
|
||
def _TransferChain__queue_accepted_replay(
|
||
self,
|
||
fileitem: FileItem,
|
||
admission: TransferAdmission,
|
||
) -> bool:
|
||
"""
|
||
从 accepted 输入离线恢复显式领域上下文并送入单任务队列。
|
||
"""
|
||
planning_input = admission.planning_input
|
||
if planning_input is None:
|
||
raise TransferPlanningStateError("accepted 回放缺少整理规划输入")
|
||
options = planning_input.options
|
||
replay_kwargs = self._TransferChain__build_replay_kwargs(planning_input)
|
||
task = TransferTask(
|
||
fileitem=fileitem,
|
||
meta=(
|
||
self._TransferChain__restore_meta_snapshot(
|
||
planning_input.meta,
|
||
options.get("_meta_kind"),
|
||
)
|
||
if planning_input.meta
|
||
else None
|
||
),
|
||
mediainfo=(
|
||
self._TransferChain__restore_mediainfo_snapshot(
|
||
planning_input.mediainfo,
|
||
options.get("_mediainfo_kind"),
|
||
)
|
||
if planning_input.mediainfo
|
||
else None
|
||
),
|
||
mtype=replay_kwargs["mtype"],
|
||
media_source=replay_kwargs["media_source"],
|
||
media_id=replay_kwargs["media_id"],
|
||
target_directory=replay_kwargs["target_directory"],
|
||
target_storage=replay_kwargs["target_storage"],
|
||
target_path=replay_kwargs["target_path"],
|
||
transfer_type=replay_kwargs["transfer_type"],
|
||
scrape=replay_kwargs["scrape"],
|
||
library_type_folder=replay_kwargs["library_type_folder"],
|
||
library_category_folder=replay_kwargs["library_category_folder"],
|
||
episodes_info=[
|
||
TmdbEpisode.model_validate(item)
|
||
for item in planning_input.episodes_info
|
||
],
|
||
username=options.get("username"),
|
||
downloader=replay_kwargs["downloader"],
|
||
download_hash=replay_kwargs["download_hash"],
|
||
manual=replay_kwargs["manual"],
|
||
background=True,
|
||
preview=False,
|
||
)
|
||
task.bind_admission_task_id(admission.task_id)
|
||
self._TransferChain__bind_claimed_admission(task, admission)
|
||
task.bind_planning_input(planning_input)
|
||
if planning_input.mediainfo or planning_input.options.get("music_recognition_scope"):
|
||
task.mark_planning_context_restored()
|
||
return self.put_to_queue(task)
|
||
|
||
def _TransferChain__queue_planned_replay(
|
||
self,
|
||
fileitem: FileItem,
|
||
admission: TransferAdmission,
|
||
*,
|
||
execution_checkpoint: Optional[TransferExecutionCheckpoint] = None,
|
||
) -> bool:
|
||
"""
|
||
把已提交检查点直接恢复为单个任务,避免重新扫描、识别和规划。
|
||
"""
|
||
checkpoint = admission.checkpoint
|
||
if checkpoint is None:
|
||
raise TransferPlanningStateError("planned 回放缺少整理计划检查点")
|
||
options = checkpoint.planning_input.options
|
||
invocation = checkpoint.provider_invocation
|
||
task = TransferTask(
|
||
fileitem=fileitem,
|
||
target_storage=(
|
||
invocation.target_storage if invocation else checkpoint.target_storage
|
||
),
|
||
target_path=(
|
||
Path(invocation.target_path)
|
||
if invocation and invocation.target_path
|
||
else Path(checkpoint.root_target_path)
|
||
if checkpoint.root_target_path
|
||
else None
|
||
),
|
||
transfer_type=(
|
||
invocation.transfer_type
|
||
if invocation
|
||
else checkpoint.resolved_transfer_type
|
||
),
|
||
scrape=invocation.scrape if invocation else checkpoint.need_scrape,
|
||
library_type_folder=(
|
||
invocation.library_type_folder
|
||
if invocation
|
||
else bool(options.get("library_type_folder"))
|
||
),
|
||
library_category_folder=(
|
||
invocation.library_category_folder
|
||
if invocation
|
||
else bool(options.get("library_category_folder"))
|
||
),
|
||
username=options.get("username"),
|
||
downloader=options.get("downloader"),
|
||
download_hash=options.get("download_hash"),
|
||
manual=bool(options.get("manual")),
|
||
background=True,
|
||
preview=False,
|
||
)
|
||
task.bind_admission_task_id(admission.task_id)
|
||
self._TransferChain__bind_claimed_admission(task, admission)
|
||
task.bind_planning_input(checkpoint.planning_input)
|
||
task.bind_plan_checkpoint(checkpoint)
|
||
if execution_checkpoint is not None:
|
||
task.bind_execution_checkpoint(execution_checkpoint)
|
||
self._TransferChain__restore_planned_task(task)
|
||
return self.put_to_queue(task)
|
||
|
||
@staticmethod
|
||
def _TransferChain__build_replay_fileitem(
|
||
storage: str,
|
||
src_path: str,
|
||
planning_input: Optional[TransferPlanningInput] = None,
|
||
) -> Tuple[Optional[FileItem], bool]:
|
||
"""
|
||
为回放构造文件项。
|
||
|
||
必须用 stat 的异常类型区分「文件真的没了」和「挂载暂时读不到」,不能用
|
||
Path.exists():它在任何 OSError 下都返回 False,会把挂载抖动
|
||
(Transport endpoint is not connected)误判成文件消失,进而注销登记
|
||
——那等于在故障期间主动丢件,正是本表要防的事。
|
||
|
||
:param storage: 存储
|
||
:param src_path: 源文件路径,以 / 结尾表示蓝光原盘目录
|
||
:param planning_input: 准入时保存的完整源文件身份快照
|
||
:return: (文件项, 是否应注销登记)。文件项为 None 表示本次不回放;
|
||
只有确认源文件已经消失时才注销登记
|
||
"""
|
||
# 蓝光原盘目录在登记时保留了尾部斜杠,这里据此还原类型
|
||
is_dir = src_path.endswith("/")
|
||
path = Path(src_path)
|
||
size, modify_time = None, None
|
||
if storage == "local":
|
||
try:
|
||
file_stat = path.stat()
|
||
size, modify_time = file_stat.st_size, file_stat.st_mtime
|
||
except FileNotFoundError:
|
||
logger.info(f"待整理文件已不存在,注销登记:{src_path}")
|
||
return None, True
|
||
except OSError as err:
|
||
# 挂载未就绪或无响应属于暂时性故障,保留登记等下次启动再回放
|
||
logger.warn(f"读取待整理文件失败,保留登记等待下次回放:{src_path} - {err}")
|
||
return None, False
|
||
snapshot = (
|
||
planning_input.source_fileitem
|
||
if planning_input and planning_input.source_fileitem
|
||
else {}
|
||
)
|
||
fileitem = FileItem.model_validate({
|
||
**snapshot,
|
||
"storage": storage,
|
||
"path": src_path if is_dir else path.as_posix(),
|
||
"type": "dir" if is_dir else snapshot.get("type") or "file",
|
||
"name": snapshot.get("name") or path.name,
|
||
"basename": snapshot.get("basename") or path.stem,
|
||
"extension": (
|
||
snapshot.get("extension")
|
||
if snapshot.get("extension") is not None
|
||
else path.suffix[1:] if not is_dir else None
|
||
),
|
||
"size": size if size is not None else snapshot.get("size"),
|
||
"modify_time": (
|
||
modify_time
|
||
if modify_time is not None
|
||
else snapshot.get("modify_time")
|
||
),
|
||
})
|
||
return fileitem, False
|
||
|
||
@staticmethod
|
||
def _TransferChain__json_snapshot(value: Any) -> Any:
|
||
"""
|
||
把受控领域对象投影为可持久化 JSON 值。
|
||
"""
|
||
if value is None:
|
||
return None
|
||
return to_jsonable_python(value, serialize_unknown=True)
|
||
|
||
def _TransferChain__record_enqueue_failure(
|
||
self,
|
||
task: TransferTask,
|
||
error: Exception,
|
||
) -> None:
|
||
"""
|
||
按是否已经 claim 选择 fencing 失败记录,并撤销批次占位。
|
||
"""
|
||
try:
|
||
if task.lease_token:
|
||
self._TransferChain__release_task_claim(task, error=str(error))
|
||
elif task.admission_task_id:
|
||
self._transfer_admissions.record_enqueue_failure(
|
||
task_id=task.admission_task_id,
|
||
error=str(error),
|
||
)
|
||
except Exception as record_error:
|
||
logger.error(
|
||
"记录整理任务入队失败原因异常:"
|
||
f"{task.admission_task_id} - {record_error}"
|
||
)
|
||
finally:
|
||
self._finish_scrape_batch_task(task)
|
||
self._TransferChain__ensure_recovery_scheduler(immediate=False)
|
||
|
||
def _TransferChain__put_to_jobview(self, task: TransferTask) -> bool:
|
||
"""
|
||
添加到作业视图
|
||
|
||
:return: True表示任务已添加,False表示任务无效或已存在(重复)
|
||
"""
|
||
if task and task.manual:
|
||
self.jobview.remove_task(task.fileitem, finished_only=True)
|
||
return bool(self.jobview.add_task(task))
|
||
|
||
def remove_from_queue(self, fileitem: FileItem) -> None:
|
||
"""
|
||
从待整理队列移除
|
||
"""
|
||
self._transfer_queue_service().remove(fileitem)
|
||
|
||
def _TransferChain__start_job_execution(self, task: TransferTask) -> None:
|
||
"""
|
||
在作业视图支持执行租约时标记主程序任务开始执行。
|
||
"""
|
||
marker = getattr(self.jobview, "start_execution", None)
|
||
if marker:
|
||
marker(task)
|
||
|
||
def _TransferChain__expire_stale_transfer_tasks(self) -> None:
|
||
"""
|
||
清理外部接管后失去状态心跳的运行中整理任务。
|
||
"""
|
||
timeout_minutes = max(int(self.runtime_config.transfer_task_timeout), 0)
|
||
expire_tasks = getattr(self.jobview, "expire_stale_running_tasks", None)
|
||
expired_tasks = (
|
||
expire_tasks(timeout_seconds=timeout_minutes * 60)
|
||
if expire_tasks
|
||
else []
|
||
)
|
||
for fileitem, inactive_seconds in expired_tasks:
|
||
logger.error(
|
||
f"整理任务 {fileitem.path} 已连续 {inactive_seconds // 60} 分钟无状态心跳,"
|
||
"已标记失败并从整理队列视图清理"
|
||
)
|
||
|
||
def _TransferChain__settle_transfer_progress_if_idle(self) -> None:
|
||
"""
|
||
在没有 active 或未结算真实任务时结束进度并重置本批计数。
|
||
"""
|
||
with task_lock:
|
||
# unfinished_tasks 同时覆盖队列内任务和已被其他执行线程 取走、尚未来得及
|
||
# 登记 active 的任务。持有 Queue 自身互斥锁完成判断和计数重置,使并发
|
||
# enqueue 只能发生在旧批次归零之后;仍在 deque 中的停止哨兵不算真实任务。
|
||
with self._queue.all_tasks_done:
|
||
queued_stop_sentinels = sum(
|
||
item is self._QUEUE_STOP_SENTINEL
|
||
for item in self._queue.queue
|
||
)
|
||
has_unsettled_tasks = (
|
||
self._queue.unfinished_tasks > queued_stop_sentinels
|
||
)
|
||
if (
|
||
self._active_tasks != 0
|
||
or self._processed_num <= 0
|
||
or has_unsettled_tasks
|
||
):
|
||
return
|
||
processed_num = self._processed_num
|
||
fail_num = self._fail_num
|
||
self._total_num = 0
|
||
self._processed_num = 0
|
||
self._fail_num = 0
|
||
__end_msg = (
|
||
f"整理队列处理完成,共整理 {processed_num} 个文件,"
|
||
f"失败 {fail_num} 个"
|
||
)
|
||
logger.info(__end_msg)
|
||
self._progress.update(value=100, text=__end_msg)
|
||
self._progress.end()
|
||
|
||
def _finish_unclaimed_queue_task(self, task: TransferTask) -> None:
|
||
"""
|
||
收口未取得租约的队列项,避免 waiting 残影持续阻挡手动重整。
|
||
"""
|
||
self.jobview.fail_unfinished_task(task)
|
||
self.jobview.try_remove_job(task)
|
||
self._finish_scrape_batch_task(task)
|
||
self._finish_queue_item(task)
|
||
self._TransferChain__settle_transfer_progress_if_idle()
|
||
|
||
def _TransferChain__start_transfer(self, stop_event: threading.Event) -> None:
|
||
"""
|
||
处理当前 执行线程 代的队列,停止后不领取下一项任务。
|
||
|
||
:param stop_event: 当前 执行线程 代专属停止信号,热更新后不会被重新清除
|
||
"""
|
||
while not runtime_stop_state.is_system_stopped and not stop_event.is_set():
|
||
try:
|
||
item = self._queue.get(
|
||
block=True, timeout=self._transfer_interval
|
||
)
|
||
if item is self._QUEUE_STOP_SENTINEL:
|
||
self._queue.task_done()
|
||
self._TransferChain__settle_transfer_progress_if_idle()
|
||
if stop_event.is_set() or runtime_stop_state.is_system_stopped:
|
||
break
|
||
continue
|
||
item = cast(TransferQueue, item)
|
||
if stop_event.is_set() or runtime_stop_state.is_system_stopped:
|
||
# 关闭信号与 queue.get 竞态时,把尚未处理的任务放回队列;其
|
||
# TransferPending 登记保持不变,供同进程重启执行线程 或下次启动回放。
|
||
self._queue.put(item)
|
||
self._queue.task_done()
|
||
break
|
||
if not item:
|
||
continue
|
||
|
||
task = item.task
|
||
if not task:
|
||
self._queue.task_done()
|
||
self._TransferChain__settle_transfer_progress_if_idle()
|
||
continue
|
||
|
||
if task.admission_task_id and task.lease_token:
|
||
with self._worker_state_lock:
|
||
self._queued_lease_tokens.discard(
|
||
(task.admission_task_id, task.lease_token)
|
||
)
|
||
try:
|
||
self._TransferChain__claim_task_for_execution(task)
|
||
except TransferLeaseLostError as err:
|
||
logger.info(f"跳过未取得执行租约的整理任务:{err}")
|
||
self._TransferChain__release_task_claim(task, error=str(err))
|
||
self._finish_unclaimed_queue_task(task)
|
||
continue
|
||
except Exception as err:
|
||
logger.error(
|
||
f"整理任务 claim 失败,保留 durable admission:{err}"
|
||
)
|
||
self._finish_unclaimed_queue_task(task)
|
||
self._TransferChain__ensure_recovery_scheduler(immediate=False)
|
||
continue
|
||
|
||
fileitem = task.fileitem
|
||
|
||
with task_lock:
|
||
# 批次总数 = 本批已处理数 + 未终态数。作业视图会残留上一批
|
||
# 已完成的任务(作业要等关联任务全部终态才移除),用全量
|
||
# total() 会把历史任务计入本批(如显示 8 个实际只处理 2 个),
|
||
# 且进度分母虚高导致百分比走不满
|
||
current_total = self._processed_num + self.jobview.pending_total()
|
||
# 更新总数,取当前总数和当前已处理+运行中+队列中的最大值
|
||
self._total_num = max(self._total_num, current_total)
|
||
|
||
# 如果当前没有在运行的任务且处理数为0,说明是一个新序列的开始
|
||
if self._active_tasks == 0 and self._processed_num == 0:
|
||
logger.info("开始整理队列处理...")
|
||
self._progress.start()
|
||
self._processed_num = 0
|
||
self._fail_num = 0
|
||
__process_msg = (
|
||
f"开始整理队列处理,当前共 {self._total_num} 个文件 ..."
|
||
)
|
||
logger.info(__process_msg)
|
||
self._progress.update(value=0, text=__process_msg)
|
||
self._active_tasks += 1
|
||
|
||
terminal = False
|
||
terminal_settlement: Optional[bool] = None
|
||
state = False
|
||
err_msg = ""
|
||
|
||
def callback_after_terminal_settlement(
|
||
callback_task: TransferTask,
|
||
transferinfo: TransferInfo,
|
||
) -> Tuple[bool, str]:
|
||
"""
|
||
由默认回调原子提交历史、事件与 持久化 终态。
|
||
"""
|
||
nonlocal terminal_settlement
|
||
if item.callback:
|
||
try:
|
||
callback_result: Tuple[bool, str] = item.callback(
|
||
callback_task,
|
||
transferinfo,
|
||
)
|
||
return callback_result
|
||
finally:
|
||
if not callback_task.preview:
|
||
terminal_settlement = callback_task.terminal_settled
|
||
return transferinfo.success, transferinfo.message or ""
|
||
|
||
try:
|
||
self._TransferChain__start_job_execution(task)
|
||
__process_msg = f"正在整理 {fileitem.name} ..."
|
||
logger.info(__process_msg)
|
||
with task_lock:
|
||
self._progress.update(
|
||
value=(self._processed_num / self._total_num * 100)
|
||
if self._total_num
|
||
else 0,
|
||
text=__process_msg,
|
||
)
|
||
state, err_msg = self._TransferChain__handle_transfer(
|
||
task=task,
|
||
callback=callback_after_terminal_settlement,
|
||
)
|
||
terminal = task.plan_checkpoint is not None
|
||
|
||
with task_lock:
|
||
if not state:
|
||
self._fail_num += 1
|
||
self._processed_num += 1
|
||
__process_msg = f"{fileitem.name} 整理完成"
|
||
logger.info(__process_msg)
|
||
self._progress.update(
|
||
value=(self._processed_num / self._total_num * 100)
|
||
if self._total_num
|
||
else 100,
|
||
text=__process_msg,
|
||
)
|
||
except Exception as error:
|
||
if terminal_settlement is not None:
|
||
terminal = True
|
||
state, err_msg = self._TransferChain__handle_transfer_execution_error(
|
||
task,
|
||
error,
|
||
preview=bool(task.preview),
|
||
)
|
||
with task_lock:
|
||
self._processed_num += 1
|
||
self._fail_num += 1
|
||
finally:
|
||
durable_settled = False
|
||
try:
|
||
durable_settled = self._TransferChain__finish_job_execution(
|
||
task,
|
||
terminal=terminal,
|
||
terminal_settlement=terminal_settlement,
|
||
)
|
||
except Exception as err:
|
||
logger.error(
|
||
f"整理任务终态结算异常:{task.admission_task_id} - {err}"
|
||
)
|
||
finally:
|
||
self._finish_queue_item(task)
|
||
with task_lock:
|
||
self._active_tasks -= 1
|
||
if terminal and state and not durable_settled:
|
||
self._fail_num += 1
|
||
self._TransferChain__settle_transfer_progress_if_idle()
|
||
|
||
except queue.Empty:
|
||
# 即使队列空了,如果还有任务在运行,也不应该结束进度
|
||
# 这部分逻辑已经在 finally 的 active_tasks == 0 中处理了
|
||
self._TransferChain__expire_stale_transfer_tasks()
|
||
continue
|
||
except Exception as e:
|
||
logger.error(f"整理队列处理出现错误:{e} - {traceback.format_exc()}")
|
||
|
||
def get_queue_tasks(self) -> List[TransferJob]:
|
||
"""
|
||
获取整理任务列表
|
||
"""
|
||
return self._transfer_queue_service().list()
|
||
|
||
def get_queue_tasks_page(self, page: int, count: int) -> Tuple[List[TransferQueueItemData], int]:
|
||
"""
|
||
获取供前端展示的受限整理队列快照。
|
||
"""
|
||
return self._transfer_queue_service().list_page(page, count)
|
||
|
||
def process(self, progress_callback: Optional[Callable[..., None]] = None) -> bool:
|
||
"""
|
||
获取下载器中的种子列表,并执行整理
|
||
|
||
:param progress_callback: 定时服务进度更新回调
|
||
"""
|
||
# 全局锁,避免定时服务重复
|
||
with downloader_lock:
|
||
download_dirs = DirectoryHelper().get_download_dirs()
|
||
|
||
# 如果没有下载器监控的目录则不处理
|
||
if not any(
|
||
dir_info.monitor_type == "downloader"
|
||
for dir_info in download_dirs
|
||
):
|
||
if progress_callback:
|
||
progress_callback(value=100, text="未配置下载器监控目录,跳过整理")
|
||
return True
|
||
|
||
logger.info("开始整理下载器中已经完成下载的文件 ...")
|
||
if progress_callback:
|
||
progress_callback(value=0, text="正在查询已完成下载任务 ...")
|
||
|
||
if torrents_list := self.list_torrents(status=TorrentStatus.TRANSFER):
|
||
existing_hashes = self.jobview.get_all_torrent_hashes()
|
||
# 排除多下载器返回的重复种子
|
||
torrents = _unique_pending_torrents(torrents_list, existing_hashes)
|
||
else:
|
||
torrents = []
|
||
|
||
if not torrents:
|
||
logger.info("没有已完成下载但未整理的任务")
|
||
if progress_callback:
|
||
progress_callback(value=100, text="没有已完成下载但未整理的任务")
|
||
return False
|
||
|
||
logger.info(f"获取到 {len(torrents)} 个已完成的下载任务")
|
||
if progress_callback:
|
||
progress_callback(
|
||
value=0,
|
||
text=f"获取到 {len(torrents)} 个已完成下载任务",
|
||
data={"total": len(torrents), "finished": 0},
|
||
)
|
||
|
||
try:
|
||
total_num = len(torrents)
|
||
for index, torrent in enumerate(torrents, start=1):
|
||
if runtime_stop_state.is_system_stopped:
|
||
break
|
||
if progress_callback:
|
||
torrent_name = (
|
||
getattr(torrent, "title", None)
|
||
or getattr(torrent, "name", None)
|
||
or torrent.hash
|
||
)
|
||
progress_callback(
|
||
value=(index - 1) / total_num * 100,
|
||
text=f"正在整理下载任务({index}/{total_num}){torrent_name} ...",
|
||
data={
|
||
"total": total_num,
|
||
"finished": index - 1,
|
||
"current": torrent.hash,
|
||
},
|
||
)
|
||
|
||
fileitem = self._build_transfer_fileitem(torrent, download_dirs)
|
||
if not fileitem:
|
||
continue
|
||
|
||
downloadhis: Optional[DownloadHistorySnapshot] = (
|
||
self.download_history_repository.get_by_hash(torrent.hash)
|
||
if torrent.hash
|
||
else None
|
||
)
|
||
# 下载记录中的媒体类型作为整理类型来源,无下载记录时留空由文件后缀兜底
|
||
mtype: Optional[MediaType] = None
|
||
if downloadhis:
|
||
try:
|
||
mtype = MediaType(downloadhis.type)
|
||
except ValueError:
|
||
mtype = MediaType.TV
|
||
mediainfo = MediaChain().recognize_media(
|
||
mtype=mtype,
|
||
media_source=downloadhis.media_source,
|
||
media_id=downloadhis.media_id,
|
||
music_type=self._download_history_music_type(downloadhis),
|
||
episode_group=downloadhis.episode_group,
|
||
)
|
||
if mediainfo:
|
||
self.obtain_images(mediainfo)
|
||
mediainfo = apply_download_history_classification(
|
||
mediainfo,
|
||
downloadhis,
|
||
)
|
||
|
||
else:
|
||
# 非MoviePilot下载的任务,按文件识别
|
||
mediainfo = None
|
||
|
||
# 执行异步整理,匹配源目录
|
||
self.do_transfer(
|
||
fileitem=fileitem,
|
||
mediainfo=mediainfo,
|
||
mtype=mtype,
|
||
downloader=torrent.downloader,
|
||
download_hash=torrent.hash,
|
||
)
|
||
if progress_callback:
|
||
progress_callback(
|
||
value=index / total_num * 100,
|
||
text=f"下载任务({index}/{total_num})整理处理完成",
|
||
data={"total": total_num, "finished": index},
|
||
)
|
||
|
||
finally:
|
||
torrents.clear()
|
||
del torrents
|
||
|
||
return True
|