Files
MoviePilot/app/application/transfer.py
T

1257 lines
49 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 threading
from copy import deepcopy
from dataclasses import dataclass
from pathlib import Path
from time import monotonic
from typing import Callable, Dict, List, Optional, Tuple, Union
from pydantic import BaseModel, ConfigDict
from app.schemas.transfer import MetaInfo as _SchemaMetaInfo
from app.schemas.transfer import MusicInfo as _SchemaMusicInfo
from app.schemas.transfer import MusicMeta as _SchemaMusicMeta
from app.schemas.workflow import MediaInfo as _SchemaMediaInfo
from app.adapters.system.host import SystemUtils
from app.application.agent import get_prompt_manager, get_running_agent_manager
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.file import FileItem
from app.schemas.history import DownloadHistory
from app.schemas.media import OptionalMediaIdentityMixin, resolve_media_identity
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,
)
class TransferTask(OptionalMediaIdentityMixin, BaseModel):
"""
文件整理任务。
"""
# 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
def to_dict(self):
"""
返回字典。
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"] = self.meta.to_dict() if self.meta else None
dicts["mediainfo"] = self.mediainfo.to_dict() if self.mediainfo else None
dicts["target_directory"] = self.target_directory.model_dump() if self.target_directory else None
return dicts
class TransferQueue(BaseModel):
"""
异步整理队列信息。
和 TransferTask 一起从 app/schemas 搬来:它装着一个 TransferTask 和一个回调函数,
回调根本不可序列化,因此从来就不是 DTO,只是恰好和视图模型住在同一个文件里。
"""
# 任务信息
task: Optional[TransferTask] = None
# 回调函数
callback: Optional[Callable] = None
# 整理结果
result: Optional[TransferInfo] = None
class TransferQueueService:
"""协调整理任务登记、入队、移除和队列视图查询。"""
def __init__(
self,
*,
register_task: Callable[[TransferTask], bool],
enqueue: Callable[[TransferQueue], None],
before_enqueue: Callable[[TransferTask], None],
after_enqueue: Callable[[TransferTask], None],
remove_task: Callable[[FileItem], None],
list_tasks: Callable[[], List[TransferJob]],
expire_tasks: Callable[[], None],
) -> None:
"""保存队列用例依赖,避免 Application 服务绑定具体线程队列实现。"""
self._register_task = register_task
self._enqueue = enqueue
self._before_enqueue = before_enqueue
self._after_enqueue = after_enqueue
self._remove_task = remove_task
self._list_tasks = list_tasks
self._expire_tasks = expire_tasks
def put(self, task: TransferTask, callback: Callable) -> bool:
"""登记并入队一个整理任务,保持原有副作用顺序。"""
if not task or not self._register_task(task):
return False
self._before_enqueue(task)
self._enqueue(TransferQueue(task=task, callback=callback))
self._after_enqueue(task)
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[Tuple, TransferJob] = {}
# 汇总季集清单
_season_episodes: Dict[Tuple, List[int]] = {}
# 记录从 meta 作业迁移到 media 作业的关系,用于清理提前失败后残留的 media 作业
_meta_to_media_ids: Dict[Tuple, set[Tuple]] = {}
# 记录任务最近一次状态心跳,供外部异步接管任务的失活检测使用
_task_state_changed_at: Dict[Tuple[str, str], float] = {}
# 记录仍由主程序整理线程直接执行的任务,避免把阻塞中的本地任务误判为失活
_active_executions: set[Tuple[str, str]] = 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: MetaBase = None, season: Optional[int] = None) -> Tuple:
"""
获取元数据ID
"""
return meta.name, season
@staticmethod
def __get_media_id(media: Optional[Union[MediaInfo, MusicInfo]] = None,
season: Optional[int] = None) -> Tuple:
"""
获取媒体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: TransferTask = None) -> Tuple:
"""
获取作业ID
"""
if task.mediainfo:
return self.__get_media_id(
media=task.mediainfo, season=task.meta.begin_season
)
else:
return self.__get_meta_id(meta=task.meta, season=task.meta.begin_season)
def get_job_id(self, task: TransferTask) -> Tuple:
"""返回任务当前所属的稳定作业身份,供作业级附加状态隔离使用。"""
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(**mediainfo.to_dict())
return _SchemaMediaInfo(**mediainfo.to_dict())
else:
# 没有媒体信息
meta: MetaBase = task.meta
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(**task.meta.to_dict())
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(t.fileitem) == file_key
for job in self._job_view.values()
for t in job.tasks
):
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=task.meta.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(t.fileitem) == file_key
for t in self._job_view[__mediaid__].tasks
]
):
logger.debug(f"任务 {task.fileitem.name} 已存在,跳过重复添加")
return False
self._job_view[__mediaid__].tasks.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(task.meta.episode_list)
self._season_episodes[__mediaid__] = list(
set(self._season_episodes[__mediaid__])
)
else:
self._season_episodes[__mediaid__] = task.meta.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:
metaid = self.__get_meta_id(
meta=task.meta, season=task.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: Tuple) -> bool:
"""
检查指定作业是否已完成
"""
if job_id not in self._job_view:
return True
return all(
task.state in ["completed", "failed"]
for task in self._job_view[job_id].tasks
)
def __pop_job(self, job_id: Tuple):
"""
移除指定作业和对应季集缓存
"""
job = self._job_view.pop(job_id, None)
self._season_episodes.pop(job_id, None)
if not job:
return
for task in job.tasks:
file_key = self.__get_file_key(task.fileitem)
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[Tuple]):
"""
清理已进入终态的独立作业或关联作业组。
"""
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):
"""
标记任务仍由主程序整理线程直接执行。
: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):
"""
结束主程序整理线程对任务的直接执行标记。
: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[Tuple] = set()
with job_lock:
for mediaid, job in self._job_view.items():
for task in job.tasks:
file_key = self.__get_file_key(task.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((task.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):
"""
设置任务为运行中,并刷新外部异步任务的状态心跳。
"""
with job_lock:
__mediaid__ = self.__get_id(task)
if __mediaid__ not in self._job_view:
return
# 更新状态
for t in self._job_view[__mediaid__].tasks:
if t.fileitem == task.fileitem:
t.state = "running"
file_key = self.__get_file_key(t.fileitem)
if file_key:
self._task_state_changed_at[file_key] = monotonic()
break
def finish_task(self, task: TransferTask):
"""
设置任务为完成/成功
"""
with job_lock:
__mediaid__ = self.__get_id(task)
if __mediaid__ not in self._job_view:
return
# 更新状态
for t in self._job_view[__mediaid__].tasks:
if t.fileitem == task.fileitem:
t.state = "completed"
file_key = self.__get_file_key(t.fileitem)
if file_key:
self._task_state_changed_at[file_key] = monotonic()
break
def fail_task(self, task: TransferTask):
"""
设置任务为失败
"""
with job_lock:
__mediaid__ = self.__get_id(task)
if __mediaid__ not in self._job_view:
return
# 更新状态
for t in self._job_view[__mediaid__].tasks:
if t.fileitem == task.fileitem:
t.state = "failed"
file_key = self.__get_file_key(t.fileitem)
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(task.meta.episode_list)
)
def fail_unfinished_task(self, task: TransferTask):
"""
将指定任务视图中的非终态任务标记为失败
"""
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:
if self.__get_file_key(job_task.fileitem) != 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(task.meta.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[Tuple]]:
"""
根据文件项移除任务,并返回任务所在的作业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:
if self.__get_file_key(task.fileitem) == file_key:
job.tasks.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:
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):
"""
尝试移除任务对应的作业(严格检查未完成作业,线程安全)
"""
with job_lock:
__metaid__ = self.__get_meta_id(
meta=task.meta, season=task.meta.begin_season
)
__mediaid__ = self.__get_media_id(
media=task.mediainfo, season=task.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:
__metaid__ = self.__get_meta_id(
meta=task.meta, season=task.meta.begin_season
)
__mediaid__ = self.__get_media_id(
media=task.mediainfo, season=task.meta.begin_season
)
if __metaid__ in self._job_view:
meta_done = all(
task.state in ["completed", "failed"]
for task in self._job_view[__metaid__].tasks
)
else:
meta_done = True
if __mediaid__ in self._job_view:
media_done = all(
task.state in ["completed", "failed"]
for task in self._job_view[__mediaid__].tasks
)
else:
media_done = True
return meta_done and media_done
def is_finished(self, task: TransferTask) -> bool:
"""
检查任务对应的作业是否已完成且有成功的记录
"""
with job_lock:
__metaid__ = self.__get_meta_id(
meta=task.meta, season=task.meta.begin_season
)
__mediaid__ = self.__get_media_id(
media=task.mediainfo, season=task.meta.begin_season
)
if __metaid__ in self._job_view:
meta_finished = all(
task.state in ["completed", "failed"]
for task in self._job_view[__metaid__].tasks
)
else:
meta_finished = True
if __mediaid__ in self._job_view:
tasks = self._job_view[__mediaid__].tasks
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:
__metaid__ = self.__get_meta_id(
meta=task.meta, season=task.meta.begin_season
)
__mediaid__ = self.__get_media_id(
media=task.mediainfo, season=task.meta.begin_season
)
if __metaid__ in self._job_view:
meta_success = all(
task.state in ["completed"]
for task in self._job_view[__metaid__].tasks
)
else:
meta_success = True
if __mediaid__ in self._job_view:
media_success = all(
task.state in ["completed"]
for task in self._job_view[__mediaid__].tasks
)
else:
media_success = True
return meta_success and media_success
def get_all_torrent_hashes(self) -> set[str]:
"""
获取所有种子的哈希值集合
"""
with job_lock:
return {
task.download_hash
for job in self._job_view.values()
for task in job.tasks
}
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
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
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(self._job_view[__metaid__].tasks) > 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 self._job_view[__mediaid__].tasks
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 self._job_view[__mediaid__].tasks
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 self._job_view[__mediaid__].tasks
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(
[
task.fileitem.size
if task.fileitem.size is not None
else (
SystemUtils.get_directory_size(Path(task.fileitem.path))
if task.fileitem.storage == "local"
else 0
)
for task in self._job_view[__mediaid__].tasks
if task.state == "completed"
]
)
def total(self) -> int:
"""
获取所有任务总数
"""
with job_lock:
return sum([len(job.tasks) 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
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 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}"
)