Files
MoviePilot/app/scheduler/catalog.py
JontyLee f2918b9285 fix(scheduler): 让声明为不限时的协程任务预算真正生效
JobSpec.timeout_seconds 此前无法表达“显式不限时”:所有未声明预算的协程任务
一律补齐 SCHEDULER_JOB_TIMEOUT,订阅搜索队列因此被 3600 秒总时长判死,而它的
托管协程消费者无法靠取消收尾,只会把正常长轮次记成失败。现在收敛为三态语义
(正数为上限、0 为显式不限时、缺省继承默认),并修正 asyncio 超时异常 str()
为空导致的日志、系统通知与 SystemError 事件文本缺失。
2026-10-26 10:34:42 +08:00

566 lines
20 KiB
Python
Raw Permalink 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.
"""
调度器静态任务目录与 APScheduler 投影。
"""
import hashlib
from datetime import datetime, timedelta
from typing import List, Optional, TypedDict
import pytz
from apscheduler.executors.pool import BasePoolExecutor
from apscheduler.schedulers.background import BackgroundScheduler
from app.adapters.external.server import MoviePilotServerHelper
from app.adapters.system.update import system_update_manager
from app.application.configuration import (
SchedulerRuntimeConfig,
)
from app.application.mediaserver import get_mediaserver_configs
from app.application.outbox import dispatch_pending_outbox
from app.application.plugin.runtime import get_plugin_manager
from app.application.scheduling import ( # noqa: E402
JobCatalog,
JobRecoveryPolicy,
JobSpec,
)
from app.runtime.execution import IdleCacheReleasingThreadPoolExecutor
from app.runtime.scheduling import TimerUtils
from app.scheduler.contract import _SchedulerOwnerBase
from app.scheduler.services import SchedulerServices
from app.schemas.system import MediaServerConf as _SchemaMediaServerConf
class _MediaServerSchedule(TypedDict):
"""
媒体服务器同步任务的内部投影。
:ivar id: 媒体服务器配置标识
:ivar name: 任务列表中显示的服务器名称
:ivar server: 媒体服务器类型
:ivar interval: 两次同步之间的分钟数
"""
id: str
name: str
server: str
interval: int
def _subscription_search_job_specs(
services: SchedulerServices, search_interval: int = 24,
) -> tuple[JobSpec, ...]:
"""
构造订阅搜索、新增搜索与持久队列恢复任务目录。
"""
return (
JobSpec(
"subscribe_search", "订阅搜索补全", services.search_subscribe, "subscription",
kwargs={"state": "R", "scheduled_interval": search_interval}
),
JobSpec(
"new_subscribe_search",
"新增订阅搜索",
services.search_subscribe,
"subscription",
kwargs={"state": "N"},
),
JobSpec(
"subscribe_search_queue",
"恢复订阅搜索队列",
services.resume_subscribe_search,
"subscription",
# 单轮耗时由队列自身限额决定,取消又杀不掉同步消费者,因此不叠加总时长预算。
timeout_seconds=0,
recovery=JobRecoveryPolicy.DURABLE_QUEUE,
),
)
def _wallpaper_job_specs(
services: SchedulerServices,
wallpaper: str,
) -> tuple[JobSpec, ...]:
"""
仅在启用壁纸时返回壁纸缓存任务声明。
"""
if not wallpaper:
return ()
return (JobSpec("random_wallpager", "壁纸缓存", services.get_wallpapers, "image"),)
# APScheduler 未提供类型注解,基类在 mypy 中是 Any
# 自主 Agent 任务只需独立的有限触发容量;协程执行仍由原主循环和句柄 owner 托管。
_AGENT_SCHEDULER_WORKERS = 2
class _SchedulerThreadPoolExecutor(BasePoolExecutor): # type: ignore[misc]
"""
APScheduler 线程池执行器:定时任务线程按需增长、不收缩,空闲时交还各自的分配器线程缓存。
"""
def __init__(self, max_workers: int) -> None:
"""
以空闲时刷新线程缓存的线程池作为 APScheduler 的底层池。
"""
super().__init__(IdleCacheReleasingThreadPoolExecutor(max_workers))
class SchedulerCatalogOwner(_SchedulerOwnerBase):
"""
调度器静态任务目录与 APScheduler 投影。
"""
@staticmethod
def _get_mediaserver_sync_interval(
mediaserver: _SchemaMediaServerConf,
default_interval: Optional[int],
) -> Optional[int]:
"""
获取媒体服务器的有效同步间隔,未设置时回退旧全局配置。
"""
interval = mediaserver.sync_interval
if interval is None:
interval = default_interval
if interval is None:
return None
try:
interval = int(interval)
except (TypeError, ValueError):
return None
return interval if interval > 0 else None
@classmethod
def _build_mediaserver_sync_schedules(
cls,
mediaservers: List[_SchemaMediaServerConf],
default_interval: Optional[int],
) -> List[_MediaServerSchedule]:
"""
构建已启用媒体服务器的独立自动同步任务描述。
"""
schedules: List[_MediaServerSchedule] = []
job_ids: set[str] = set()
for mediaserver in mediaservers:
if not mediaserver or not mediaserver.enabled or not mediaserver.name:
continue
interval = cls._get_mediaserver_sync_interval(
mediaserver=mediaserver,
default_interval=default_interval,
)
if not interval:
continue
digest = hashlib.sha256(mediaserver.name.encode("utf-8")).hexdigest()[:12]
job_id = f"mediaserver_sync_{digest}"
if job_id in job_ids:
continue
job_ids.add(job_id)
schedules.append(
{
"id": job_id,
"name": f"同步媒体服务器 - {mediaserver.name}",
"server": mediaserver.name,
"interval": interval,
}
)
return schedules
def _register_database_backup_job(
self,
config: SchedulerRuntimeConfig,
) -> None:
"""
在共享调度器中按当前配置维护唯一的数据库备份作业。
"""
if not config.db_backup_enable or not config.db_backup_cron.strip():
return
job_id = "database_backup"
job = JobSpec(
job_id,
"数据库备份",
self.database_backup,
"database",
recovery=JobRecoveryPolicy.DURABLE_QUEUE,
).to_runtime_state()
self._assign_job_generation(job_id, job)
self._jobs[job_id] = job
self._scheduler.add_job(
self.start,
trigger=TimerUtils.build_schedule_trigger(
trigger_type="cron",
trigger_value=config.db_backup_cron,
timezone_name=config.timezone,
),
id=job_id,
name="数据库备份",
kwargs={"job_id": job_id},
replace_existing=True,
)
def _poll_subscription_search_queue(self) -> None:
"""
仅在消费者空闲时唤醒托管协程,轮询不等待搜索也不重复报告重入。
"""
if not self._is_job_active("subscribe_search_queue"):
self.start("subscribe_search_queue")
def _register_subscription_search_queue_job(self, config: SchedulerRuntimeConfig) -> None:
"""
注册轻量轮询;搜索协程的真实生命周期由 Scheduler 持有。
"""
self._scheduler.add_job(
self._poll_subscription_search_queue,
"interval",
id="subscribe_search_queue",
name="恢复订阅搜索队列",
seconds=10,
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(seconds=5),
)
def _initialize_catalog(self, config: SchedulerRuntimeConfig) -> None:
"""
构建完整任务目录并投影到尚未启动的 APScheduler。
"""
services = self._scheduler_services()
# 各服务的运行状态
self._jobs = JobCatalog(
[
JobSpec("cookiecloud", "同步CookieCloud站点", services.sync_cookies, "site"),
JobSpec("mediaserver_sync", "同步媒体服务器", services.sync_mediaserver, "mediaserver"),
JobSpec("subscribe_tmdb", "订阅元数据更新", services.check_subscribe, "subscription"),
*_subscription_search_job_specs(services, config.subscribe_search_interval),
JobSpec("subscribe_refresh", "订阅刷新", services.refresh_subscribe, "subscription"),
JobSpec("subscribe_follow", "关注的订阅分享", services.follow_subscribe, "subscription"),
JobSpec(
"transfer",
"下载文件整理",
services.process_transfer,
"transfer",
recovery=JobRecoveryPolicy.DURABLE_QUEUE,
),
JobSpec(
"clear_cache",
"缓存清理",
self.clear_cache,
"runtime",
manual=True,
recovery=JobRecoveryPolicy.MANUAL_ONLY,
),
JobSpec("data_cleanup", "数据表清理", services.cleanup_data, "database"),
JobSpec("user_auth", "用户认证检查", self.user_auth, "security"),
JobSpec("scheduler_job", "公共定时服务", services.run_modules, "module"),
*_wallpaper_job_specs(services, config.wallpaper),
JobSpec("sitedata_refresh", "站点数据刷新", services.refresh_site_data, "site"),
JobSpec("recommend_refresh", "推荐缓存", services.refresh_recommend, "recommend"),
JobSpec(
"plugin_market_refresh",
"插件市场缓存",
get_plugin_manager().async_get_online_plugins,
"plugin",
kwargs={"force": True},
),
JobSpec("subscribe_calendar_cache", "订阅日历缓存", services.cache_subscribe_calendar, "subscription"),
JobSpec("full_gc", "主动内存回收", self.full_gc, "runtime"),
JobSpec("agent_heartbeat", "智能体定时任务", self.agent_heartbeat, "agent"),
JobSpec("usage_report", "安装版本统计上报", MoviePilotServerHelper.report_usage, "server"),
*(
[JobSpec("system_update_check", "检查系统更新", system_update_manager.check_scheduled, "system")]
if config.update_check_enabled
else []
),
]
).runtime_states()
for job_id, job in self._jobs.items():
self._assign_job_generation(job_id, job)
self._scheduler = BackgroundScheduler(
timezone=config.timezone,
executors={
"default": _SchedulerThreadPoolExecutor(config.scheduler_workers),
"agent": _SchedulerThreadPoolExecutor(_AGENT_SCHEDULER_WORKERS),
},
job_defaults={"coalesce": True, "max_instances": 1, "misfire_grace_time": None},
)
self._register_database_backup_job(config)
outbox_job = JobSpec(
"outbox_dispatch",
"重试未完成的后台处理",
dispatch_pending_outbox,
"outbox",
recovery=JobRecoveryPolicy.DURABLE_QUEUE,
).to_runtime_state()
self._assign_job_generation("outbox_dispatch", outbox_job)
self._jobs["outbox_dispatch"] = outbox_job
self._scheduler.add_job(
self.start,
"interval",
id="outbox_dispatch",
name="重试未完成的后台处理",
seconds=30,
next_run_time=datetime.now(pytz.timezone(config.timezone)),
kwargs={"job_id": "outbox_dispatch"},
replace_existing=True,
)
# CookieCloud定时同步
if (
config.cookiecloud_configured
and config.cookiecloud_interval
and str(config.cookiecloud_interval).isdigit()
):
self._scheduler.add_job(
self.start,
"interval",
id="cookiecloud",
name="同步CookieCloud站点",
minutes=int(config.cookiecloud_interval),
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(minutes=5),
kwargs={"job_id": "cookiecloud"},
)
# 按媒体服务器分别注册自动同步任务
mediaserver_schedules = self._build_mediaserver_sync_schedules(
mediaservers=get_mediaserver_configs(include_disabled=True),
default_interval=config.mediaserver_sync_interval,
)
for mediaserver_schedule in mediaserver_schedules:
job_id = mediaserver_schedule["id"]
job = JobSpec(
job_id,
mediaserver_schedule["name"],
services.sync_mediaserver,
"mediaserver",
kwargs={"server": mediaserver_schedule["server"]},
).to_runtime_state()
self._assign_job_generation(job_id, job)
self._jobs[job_id] = job
self._scheduler.add_job(
self.start,
"interval",
id=job_id,
name=mediaserver_schedule["name"],
hours=mediaserver_schedule["interval"],
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(minutes=10),
kwargs={"job_id": job_id},
)
# 新增订阅时搜索(5分钟检查一次)
self._register_subscription_search_queue_job(config)
self._scheduler.add_job(
self.start,
"interval",
id="new_subscribe_search",
name="新增订阅搜索",
minutes=5,
kwargs={"job_id": "new_subscribe_search"},
)
# 检查更新订阅TMDB数据(每隔6小时)
self._scheduler.add_job(
self.start,
"interval",
id="subscribe_tmdb",
name="订阅元数据更新",
hours=6,
kwargs={"job_id": "subscribe_tmdb"},
)
# 每五分钟检查逐条订阅到期时间;实际搜索仍受系统或独立周期约束。
if config.subscribe_search:
self._scheduler.add_job(
self.start,
"interval",
id="subscribe_search",
name="订阅搜索补全",
minutes=5,
kwargs={"job_id": "subscribe_search"},
)
if config.subscribe_mode == "spider":
# 站点首页种子定时刷新模式
triggers = TimerUtils.random_scheduler(num_executions=32)
for trigger in triggers:
self._scheduler.add_job(
self.start,
"cron",
id=f"subscribe_refresh|{trigger.hour}:{trigger.minute}",
name="订阅刷新",
hour=trigger.hour,
minute=trigger.minute,
kwargs={"job_id": "subscribe_refresh"},
)
else:
# RSS订阅模式
self._scheduler.add_job(
self.start,
"interval",
id="subscribe_refresh",
name="RSS订阅刷新",
minutes=config.subscribe_rss_interval,
kwargs={"job_id": "subscribe_refresh"},
)
# 关注订阅分享(每1小时)
self._scheduler.add_job(
self.start,
"interval",
id="subscribe_follow",
name="关注的订阅分享",
hours=1,
kwargs={"job_id": "subscribe_follow"},
)
# 下载器文件转移(每5分钟)
self._scheduler.add_job(
self.start,
"interval",
id="transfer",
name="下载文件整理",
minutes=5,
kwargs={"job_id": "transfer"},
)
# 仅在启用壁纸时后台刷新壁纸缓存。
if config.wallpaper:
self._scheduler.add_job(
self.start,
"interval",
id="random_wallpager",
name="壁纸缓存",
minutes=30,
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(seconds=1),
kwargs={"job_id": "random_wallpager"},
)
# 公共定时服务
self._scheduler.add_job(
self.start,
"interval",
id="scheduler_job",
name="公共定时服务",
minutes=10,
kwargs={"job_id": "scheduler_job"},
)
# 数据表清理服务,每天凌晨执行一次
if config.data_cleanup_enable:
self._scheduler.add_job(
self.start,
"cron",
id="data_cleanup",
name="数据表清理",
hour=3,
minute=30,
kwargs={"job_id": "data_cleanup"},
)
# 定时检查用户认证,每隔10分钟
self._scheduler.add_job(
self.start,
"interval",
id="user_auth",
name="用户认证检查",
minutes=10,
kwargs={"job_id": "user_auth"},
)
# 站点数据刷新
if config.sitedata_refresh_interval:
self._scheduler.add_job(
self.start,
"interval",
id="sitedata_refresh",
name="站点数据刷新",
minutes=config.sitedata_refresh_interval * 60,
kwargs={"job_id": "sitedata_refresh"},
)
# 推荐缓存
self._scheduler.add_job(
self.start,
"interval",
id="recommend_refresh",
name="推荐缓存",
hours=24,
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(seconds=5),
kwargs={"job_id": "recommend_refresh"},
)
# 插件市场缓存
self._scheduler.add_job(
self.start,
"interval",
id="plugin_market_refresh",
name="插件市场缓存",
minutes=30,
kwargs={"job_id": "plugin_market_refresh"},
)
if config.update_check_enabled:
# 任一更新开关开启即注册,执行时分别检查已启用的主程序或资源。
self._scheduler.add_job(
self.start,
"interval",
id="system_update_check",
name="检查系统更新",
hours=6,
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(minutes=1),
kwargs={"job_id": "system_update_check"},
)
# 订阅日历缓存
self._scheduler.add_job(
self.start,
"interval",
id="subscribe_calendar_cache",
name="订阅日历缓存",
hours=6,
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(minutes=2),
kwargs={"job_id": "subscribe_calendar_cache"},
)
# 主动内存回收
if config.memory_gc_interval:
self._scheduler.add_job(
self.start,
"interval",
id="full_gc",
name="主动内存回收",
minutes=config.memory_gc_interval,
kwargs={"job_id": "full_gc"},
)
# 智能体定时任务检查
if config.ai_agent_enable and config.ai_agent_job_interval:
self._scheduler.add_job(
self.start,
"interval",
id="agent_heartbeat",
executor="agent",
name="智能体定时任务",
hours=config.ai_agent_job_interval,
kwargs={"job_id": "agent_heartbeat"},
)
# 安装版本统计上报
if config.usage_statistic_share:
self._scheduler.add_job(
self.start,
"interval",
id="usage_report",
name="安装版本统计上报",
hours=12,
kwargs={"job_id": "usage_report"},
)
# 初始化工作流服务
self.init_workflow_jobs()
# 恢复 Agent 自主定时任务
if config.ai_agent_enable:
self.init_agent_task_jobs()
# 初始化插件服务
self.init_plugin_jobs()