Compare commits

...

40 Commits

Author SHA1 Message Date
jxxghp
65412a4263 v2.4.8
- 修复了部分情况下插件不注册定时服务的问题
- 二级分类策略支持发行年份范围
- 支持自定义背景壁纸
- 支持插件扩展工作流动作,并编排到工作流中
2025-05-16 12:47:38 +08:00
jxxghp
0233b78c8e fix plugin actions api 2025-05-15 22:13:15 +08:00
jxxghp
b0b25e4cfa fix plugin actions api 2025-05-15 22:02:05 +08:00
jxxghp
806288d587 add:查询插件动作API 2025-05-15 20:54:39 +08:00
jxxghp
97265fc43b feat:二级分类发行年份支持范围 2025-05-15 20:13:44 +08:00
jxxghp
41ca50d0d4 feat:工作流支持调用插件动作 2025-05-15 19:55:14 +08:00
jxxghp
9d02206fd9 feat:二级分类支持发行年份 2025-05-15 15:52:42 +08:00
jxxghp
ba2293eb30 feat:默认配置更多第三方插件仓库 2025-05-15 12:50:18 +08:00
jxxghp
8b9e28975d Merge pull request #4280 from Miralia/v2 2025-05-15 12:09:18 +08:00
jxxghp
22ae8b8f87 fix 非str类型设置保存 2025-05-15 12:00:09 +08:00
Miralia
187e352cbd feat(meta): 修改正则表达式 2025-05-15 11:50:31 +08:00
Miralia
23ef8ad28d feat(meta): 扩展音视频格式匹配规则 2025-05-15 09:58:27 +08:00
jxxghp
1dadf56c42 fix #4276 2025-05-15 08:40:38 +08:00
jxxghp
52640b80c0 Merge pull request #4276 from Seed680/v2
支持支持自定义壁纸api地址,返回中配置中允许的图片文件后缀格式图片都会返回作为壁纸
2025-05-15 08:24:01 +08:00
jxxghp
fe25f8f48f fix #4277 2025-05-15 07:12:52 +08:00
jxxghp
7f59572d8b Merge pull request #4279 from wumode/pip_invocation 2025-05-15 06:43:53 +08:00
wumode
90fc4c6bad Use sys.executable -m pip for env-safe package installation 2025-05-14 23:19:40 +08:00
qiaoyun680
16b6c0da33 支持支持自定义壁纸api地址,返回中配置中允许的图片文件后缀格式图片都会返回作为壁纸 2025-05-14 20:04:38 +08:00
qiaoyun680
488a691f29 支持支持自定义壁纸api地址,返回中配置中允许的图片文件后缀格式图片都会返回作为壁纸 2025-05-14 16:50:17 +08:00
jxxghp
bcbfe2ccd5 feat:增加默认插件仓库 2025-05-14 15:10:27 +08:00
jxxghp
bd9a1d7ec7 Merge pull request #4275 from akvsdk/fix_time_error 2025-05-14 13:10:41 +08:00
jiangyuqing
9331ba64d6 fix 时间解析问题 2025-05-14 12:51:02 +08:00
jxxghp
21e5cb0a03 v2.4.7
- 修复了订阅文件信息显示问题
- 修复了默认通知模板格式中季号的显示问题
- 修复了原始语言图片刮削的问题
- 修复了馒头新版标签无法识别的问题
- 优化了联邦插件API的注册
2025-05-14 09:16:12 +08:00
jxxghp
1a8e0c9ecb fix #4270 2025-05-14 08:41:06 +08:00
jxxghp
16fc0d31cd fix #4270 2025-05-14 08:11:50 +08:00
jxxghp
a622ada58b 更新 lifecycle.py 2025-05-13 23:58:08 +08:00
jxxghp
ee9c4948d3 refactor: 优化启停逻辑 2025-05-13 23:47:12 +08:00
jxxghp
cf28e1d963 refactor: 优化启停逻辑 2025-05-13 23:11:38 +08:00
jxxghp
089ec36160 Merge pull request #4269 from wikrin/v2 2025-05-13 21:44:22 +08:00
jxxghp
04ce774c22 fix plugin initializer 2025-05-13 21:37:10 +08:00
Attente
99c1422f37 feat(message): 优化消息模板中的季号显示格式
- 在 TemplateContextBuilder 中添加 season_fmt 字段,用于存储 Sxx 格式的季号
- 在 meta_info 中添加 season_fmt 字段,用于存储 Sxx 格式的季号
- 更新消息模板中的 season 引用为 season_fmt,以实现统一的季号显示格式
- 新增数据库迁移脚本,用于更新消息模板中的 season 引用为 season_fmt
2025-05-13 21:21:27 +08:00
Attente
b583a60f23 refactor(app): 增加消息构建器的空值过滤
- 在 TemplateContextBuilder 类中增加了对空值的过滤,解决通知模板渲染出`'None'`的问题
2025-05-13 21:21:27 +08:00
jxxghp
7be2910809 fix api register bug 2025-05-13 20:52:22 +08:00
jxxghp
30de524319 fix api register bug 2025-05-13 20:35:36 +08:00
jxxghp
c431d5e759 Merge pull request #4267 from k1z/v2 2025-05-13 18:45:01 +08:00
jxxghp
184b62b024 fix plugin apis 2025-05-13 16:36:50 +08:00
wangkai
2751770350 修复馒头新版标签无法识别的问题 2025-05-13 12:23:51 +08:00
jxxghp
75d98aee8e Merge pull request #4262 from wumode/fix_4180 2025-05-12 21:16:48 +08:00
wumode
48120b9406 fix: get_torrent_tags fails to properly retrieve the existing tags 2025-05-12 21:05:30 +08:00
wumode
0e302d7959 fix: add '已整理' tag to non-default downloader 2025-05-12 21:04:03 +08:00
34 changed files with 682 additions and 286 deletions

View File

@@ -0,0 +1,72 @@
from pydantic import Field
from app.actions import BaseAction
from app.core.plugin import PluginManager
from app.log import logger
from app.schemas import ActionParams, ActionContext
class InvokePluginParams(ActionParams):
"""
调用插件动作参数
"""
plugin_id: str = Field(default=None, description="插件ID")
action_id: str = Field(default=None, description="动作ID")
action_params: dict = Field(default={}, description="动作参数")
class InvokePluginAction(BaseAction):
"""
调用插件
"""
_success = False
def __init__(self, action_id: str):
super().__init__(action_id)
self._success = False
@classmethod
@property
def name(cls) -> str: # noqa
return "调用插件"
@classmethod
@property
def description(cls) -> str: # noqa
return "调用插件提供的动作"
@classmethod
@property
def data(cls) -> dict: # noqa
return InvokePluginParams().dict()
@property
def success(self) -> bool:
return self._success
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
"""
执行插件定义的动作
"""
params = InvokePluginParams(**params)
if not params.plugin_id or not params.action_id:
return context
try:
plugin_actions = PluginManager().get_plugin_actions(params.plugin_id)
if not plugin_actions:
logger.error(f"插件不存在: {params.plugin_id}")
return context
actions = plugin_actions[0].get("actions", [])
action = next((action for action in actions if action.action_id == params.action_id), None)
if not action or not action.get("func"):
logger.error(f"插件动作不存在: {params.plugin_id} - {params.action_id}")
return context
# 执行插件动作
self._success, context = action["func"](context, **params.action_params)
except Exception as e:
self._success = False
logger.error(f"调用插件动作失败: {e}")
return context
self.job_done()
return context

View File

@@ -11,7 +11,7 @@ from app.chain.mediaserver import MediaServerChain
from app.core import security
from app.core.config import settings
from app.helper.sites import SitesHelper
from app.utils.web import WebUtils
from app.helper.wallpaper import WallpaperHelper
router = APIRouter()
@@ -55,9 +55,11 @@ def wallpaper() -> Any:
获取登录页面电影海报
"""
if settings.WALLPAPER == "bing":
url = WebUtils.get_bing_wallpaper()
url = WallpaperHelper().get_bing_wallpaper()
elif settings.WALLPAPER == "mediaserver":
url = MediaServerChain().get_latest_wallpaper()
elif settings.WALLPAPER == "customize":
url = WallpaperHelper().get_customize_wallpaper()
else:
url = TmdbChain().get_random_wallpager()
if url:
@@ -74,10 +76,12 @@ def wallpapers() -> Any:
获取登录页面电影海报
"""
if settings.WALLPAPER == "bing":
return WebUtils.get_bing_wallpapers()
return WallpaperHelper().get_bing_wallpapers()
elif settings.WALLPAPER == "mediaserver":
return MediaServerChain().get_latest_wallpapers()
elif settings.WALLPAPER == "tmdb":
return TmdbChain().get_trending_wallpapers()
elif settings.WALLPAPER == "customize":
return WallpaperHelper().get_customize_wallpapers()
else:
return []

View File

@@ -122,6 +122,18 @@ def _clean_protected_routes(existing_paths: dict):
logger.error(f"Error removing protected route {protected_route}: {str(e)}")
def register_plugin(plugin_id: str):
"""
注册一个插件相关的服务
"""
# 注册插件服务
Scheduler().update_plugin_job(plugin_id)
# 注册菜单命令
Command().init_commands(plugin_id)
# 注册插件API
register_plugin_api(plugin_id)
@router.get("/", summary="所有插件", response_model=List[schemas.Plugin])
def all_plugins(_: schemas.TokenPayload = Depends(get_current_active_superuser),
state: Optional[str] = "all") -> List[schemas.Plugin]:
@@ -185,6 +197,18 @@ def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
return PluginHelper().get_statistic()
@router.get("/reload/{plugin_id}", summary="重新加载插件", response_model=schemas.Response)
def reload_plugin(plugin_id: str, _: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
"""
重新加载插件
"""
# 重新加载插件
PluginManager().reload_plugin(plugin_id)
# 注册插件服务
register_plugin(plugin_id)
return schemas.Response(success=True)
@router.get("/install/{plugin_id}", summary="安装插件", response_model=schemas.Response)
def install(plugin_id: str,
repo_url: Optional[str] = "",
@@ -213,14 +237,8 @@ def install(plugin_id: str,
install_plugins.append(plugin_id)
# 保存设置
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
# 加载插件到内存
PluginManager().reload_plugin(plugin_id)
# 注册插件服务
Scheduler().update_plugin_job(plugin_id)
# 注册菜单命令
Command().init_commands(plugin_id)
# 注册插件API
register_plugin_api(plugin_id)
# 重新加载插件
reload_plugin(plugin_id)
return schemas.Response(success=True)
@@ -316,14 +334,8 @@ def reset_plugin(plugin_id: str,
PluginManager().delete_plugin_config(plugin_id)
# 删除插件所有数据
PluginManager().delete_plugin_data(plugin_id)
# 重新生效插件
PluginManager().reload_plugin(plugin_id)
# 注册插件服务
Scheduler().update_plugin_job(plugin_id)
# 注册菜单命令
Command().init_commands(plugin_id)
# 注册插件API
register_plugin_api(plugin_id)
# 重新加载插件
reload_plugin(plugin_id)
return schemas.Response(success=True)
@@ -382,11 +394,7 @@ def set_plugin_config(plugin_id: str, conf: dict,
# 重新生效插件
PluginManager().init_plugin(plugin_id, conf)
# 注册插件服务
Scheduler().update_plugin_job(plugin_id)
# 注册菜单命令
Command().init_commands(plugin_id)
# 注册插件API
register_plugin_api(plugin_id)
register_plugin(plugin_id)
return schemas.Response(success=True)
@@ -411,7 +419,3 @@ def uninstall_plugin(plugin_id: str,
# 移除插件
PluginManager().remove_plugin(plugin_id)
return schemas.Response(success=True)
# 注册全部插件API
register_plugin_api()

View File

@@ -7,6 +7,7 @@ from starlette.background import BackgroundTasks
from app import schemas
from app.chain.site import SiteChain
from app.chain.torrents import TorrentsChain
from app.command import Command
from app.core.event import EventManager
from app.core.plugin import PluginManager
from app.core.security import verify_token
@@ -22,6 +23,7 @@ from app.helper.sites import SitesHelper
from app.scheduler import Scheduler
from app.schemas.types import SystemConfigKey, EventType
from app.utils.string import StringUtils
from startup.plugins_initializer import register_plugin_api
router = APIRouter()
@@ -385,8 +387,11 @@ def auth_site(
return schemas.Response(success=False, message="请输入认证站点和认证参数")
status, msg = SitesHelper().check_user(auth_info.site, auth_info.params)
SystemConfigOper().set(SystemConfigKey.UserSiteAuthParams, auth_info.dict())
# 认证成功后,重新初始化插件
PluginManager().init_config()
Scheduler().init_plugin_jobs()
Command().init_commands()
register_plugin_api()
return schemas.Response(success=status, message=msg)

View File

@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
from app import schemas
from app.core.config import global_vars
from app.core.plugin import PluginManager
from app.core.workflow import WorkFlowManager
from app.db import get_db
from app.db.models.workflow import Workflow
@@ -43,6 +44,14 @@ def create_workflow(workflow: schemas.Workflow,
return schemas.Response(success=True, message="创建工作流成功")
@router.get("/plugin/actions", summary="查询插件动作", response_model=List[dict])
def list_plugin_actions(plugin_id: str = None, _: schemas.TokenPayload = Depends(get_current_active_user)) -> Any:
"""
获取所有动作
"""
return PluginManager().get_plugin_actions(plugin_id)
@router.get("/actions", summary="所有动作", response_model=List[dict])
def list_actions(_: schemas.TokenPayload = Depends(get_current_active_user)) -> Any:
"""

2
app/chain/transfer.py Normal file → Executable file
View File

@@ -860,7 +860,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
# 设置下载任务状态
if state:
self.transfer_completed(hashs=torrent.hash)
self.transfer_completed(hashs=torrent.hash, downloader=torrent.downloader)
# 结束
logger.info("所有下载器中下载完成的文件已整理完成")

View File

@@ -1,4 +1,5 @@
import copy
import json
import os
import re
import secrets
@@ -89,6 +90,8 @@ class ConfigModel(BaseModel):
PROXY_HOST: Optional[str] = None
# 登录页面电影海报,tmdb/bing/mediaserver
WALLPAPER: str = "tmdb"
# 自定义壁纸api地址
CUSTOMIZE_WALLPAPER_API_URL: Optional[str] = None
# 媒体搜索来源 themoviedb/douban/bangumi多个用,分隔
SEARCH_SOURCE: str = "themoviedb,douban,bangumi"
# 媒体识别来源 themoviedb/douban
@@ -217,7 +220,16 @@ class ConfigModel(BaseModel):
"https://github.com/thsrite/MoviePilot-Plugins,"
"https://github.com/honue/MoviePilot-Plugins,"
"https://github.com/InfinityPacer/MoviePilot-Plugins,"
"https://github.com/DDS-Derek/MoviePilot-Plugins")
"https://github.com/DDS-Derek/MoviePilot-Plugins,"
"https://github.com/madrays/MoviePilot-Plugins,"
"https://github.com/justzerock/MoviePilot-Plugins,"
"https://github.com/KoWming/MoviePilot-Plugins,"
"https://github.com/wikrin/MoviePilot-Plugins,"
"https://github.com/HankunYu/MoviePilot-Plugins,"
"https://github.com/baozaodetudou/MoviePilot-Plugins,"
"https://github.com/Aqr-K/MoviePilot-Plugins,"
"https://github.com/hotlcc/MoviePilot-Plugins-Third,"
"https://github.com/gxterry/MoviePilot-Plugins")
# 插件安装数据共享
PLUGIN_STATISTIC_SHARE: bool = True
# 是否开启插件热加载
@@ -358,13 +370,16 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
if field_name in fields_not_keep_spaces:
value = re.sub(r"\s+", "", value)
return value, str(value) != str(original_value)
# # 后续考虑支持 list 类型的处理
# elif expected_type is list:
# if isinstance(value, list):
# return value, False
# if isinstance(value, str):
# items = [item.strip() for item in value.split(",") if item.strip()]
# return items, items != original_value.split(",")
# 支持 list 类型的处理
elif expected_type is list:
if isinstance(value, list):
return value, str(value) != str(original_value)
if isinstance(value, str):
items = json.loads(value)
if isinstance(original_value, list):
return items, items != original_value
else:
return items, str(items) != str(original_value)
# 可根据需要添加更多类型处理
else:
return value, str(value) != str(original_value)
@@ -405,7 +420,13 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
logger.warning(message)
return False, message
else:
set_key(SystemUtils.get_env_path(), field.name, str(converted_value) if converted_value is not None else "")
# 如果是列表、字典或集合类型将其转换为JSON字符串
if isinstance(converted_value, (list, dict, set)):
value_to_write = json.dumps(converted_value)
else:
value_to_write = str(converted_value) if converted_value is not None else ""
set_key(SystemUtils.get_env_path(), field.name, value_to_write)
if is_converted:
logger.info(f"配置项 '{field.name}' 已自动修正并写入到 'app.env' 文件")
return True, message

View File

@@ -31,7 +31,7 @@ class MetaVideo(MetaBase):
_part_re = r"(^PART[0-9ABI]{0,2}$|^CD[0-9]{0,2}$|^DVD[0-9]{0,2}$|^DISK[0-9]{0,2}$|^DISC[0-9]{0,2}$)"
_roman_numerals = r"^(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$"
_source_re = r"^BLURAY$|^HDTV$|^UHDTV$|^HDDVD$|^WEBRIP$|^DVDRIP$|^BDRIP$|^BLU$|^WEB$|^BD$|^HDRip$|^REMUX$|^UHD$"
_effect_re = r"^SDR$|^HDR\d*$|^DOLBY$|^DOVI$|^DV$|^3D$|^REPACK$"
_effect_re = r"^SDR$|^HDR\d*$|^DOLBY$|^DOVI$|^DV$|^3D$|^REPACK$|^HLG$"
_resources_type_re = r"%s|%s" % (_source_re, _effect_re)
_name_no_begin_re = r"^[\[【].+?[\]】]"
_name_no_chinese_re = r".*版|.*字幕"
@@ -50,8 +50,8 @@ class MetaVideo(MetaBase):
r"|CD[\s.]*[1-9]|DVD[\s.]*[1-9]|DISK[\s.]*[1-9]|DISC[\s.]*[1-9]|\s+GB"
_resources_pix_re = r"^[SBUHD]*(\d{3,4}[PI]+)|\d{3,4}X(\d{3,4})"
_resources_pix_re2 = r"(^[248]+K)"
_video_encode_re = r"^[HX]26[45]$|^AVC$|^HEVC$|^VC\d?$|^MPEG\d?$|^Xvid$|^DivX$|^HDR\d*$"
_audio_encode_re = r"^DTS\d?$|^DTSHD$|^DTSHDMA$|^Atmos$|^TrueHD\d?$|^AC3$|^\dAudios?$|^DDP\d?$|^DD\d?$|^LPCM\d?$|^AAC\d?$|^FLAC\d?$|^HD\d?$|^MA\d?$"
_video_encode_re = r"^(H26[45])$|^(x26[45])$|^AVC$|^HEVC$|^VC\d?$|^MPEG\d?$|^Xvid$|^DivX$|^AV1$|^HDR\d*$|^AVS(\+|[23])$"
_audio_encode_re = r"^DTS\d?$|^DTSHD$|^DTSHDMA$|^Atmos$|^TrueHD\d?$|^AC3$|^\dAudios?$|^DDP\d?$|^DD\+\d?$|^DD\d?$|^LPCM\d?$|^AAC\d?$|^FLAC\d?$|^HD\d?$|^MA\d?$|^HR\d?$|^Opus\d?$|^Vorbis\d?$"
def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
"""
@@ -592,7 +592,12 @@ class MetaVideo(MetaBase):
self._stop_name_flag = True
self._last_token_type = "videoencode"
if not self.video_encode:
self.video_encode = re_res.group(1).upper()
if re_res.group(1):
self.video_encode = re_res.group(1).upper()
elif re_res.group(2):
self.video_encode = re_res.group(2).lower()
else:
self.video_encode = re_res.group(0).upper()
self._last_token = self.video_encode
elif self.video_encode == "10bit":
self.video_encode = f"{re_res.group(1).upper()} 10bit"

View File

@@ -204,18 +204,21 @@ class PluginManager(metaclass=Singleton):
# 停止插件
if pid:
logger.info(f"正在停止插件 {pid}...")
plugin_obj = self._running_plugins.get(pid)
if not plugin_obj:
logger.warning(f"插件 {pid} 不存在或未加载")
return
plugins = {pid: plugin_obj}
else:
logger.info("正在停止所有插件...")
for plugin_id, plugin in self._running_plugins.items():
if pid and plugin_id != pid:
continue
plugins = self._running_plugins
for plugin_id, plugin in plugins.items():
eventmanager.disable_event_handler(type(plugin))
self.__stop_plugin(plugin)
# 清空对像
if pid:
# 清空指定插件
if pid in self._running_plugins:
self._running_plugins.pop(pid)
self._running_plugins.pop(pid, None)
else:
# 清空
self._plugins = {}
@@ -533,6 +536,35 @@ class PluginManager(metaclass=Singleton):
logger.error(f"获取插件 {plugin_id} 模块出错:{str(e)}")
return ret_modules
def get_plugin_actions(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:
"""
获取插件动作
[{
"id": "动作ID",
"name": "动作名称",
"func": self.xxx,
"kwargs": {} # 需要附加传递的参数
}]
"""
ret_actions = []
for plugin_id, plugin in self._running_plugins.items():
if pid and pid != plugin_id:
continue
if hasattr(plugin, "get_actions") and ObjectUtils.check_method(plugin.get_actions):
try:
if not plugin.get_state():
continue
actions = plugin.get_actions()
if actions:
ret_actions.append({
"plugin_id": plugin_id,
"plugin_name": plugin.plugin_name,
"actions": actions
})
except Exception as e:
logger.error(f"获取插件 {plugin_id} 动作出错:{str(e)}")
return ret_actions
@staticmethod
def get_plugin_remote_entry(plugin_id: str, dist_path: str) -> str:
"""
@@ -833,8 +865,8 @@ class PluginManager(metaclass=Singleton):
logger.debug(f"获取插件是否在本地包中存在失败,{e}")
return False
def get_plugins_from_market(self, market: str, package_version: Optional[str] = None) -> Optional[
List[schemas.Plugin]]:
def get_plugins_from_market(self, market: str,
package_version: Optional[str] = None) -> Optional[List[schemas.Plugin]]:
"""
从指定的市场获取插件信息
:param market: 市场的 URL 或标识

View File

@@ -66,13 +66,15 @@ class TemplateContextBuilder:
if include_raw_objects:
self._add_raw_objects(meta, mediainfo, torrentinfo, transferinfo, episodes_info)
return self._context
# 移除空值
return {k: v for k, v in self._context.items() if v is not None}
def _add_media_info(self, mediainfo: MediaInfo):
"""
增加媒体信息
"""
if not mediainfo: return
season_fmt = f"S{mediainfo.season:02d}" if mediainfo.season is not None else None
base_info = {
# 标题
"title": self.__convert_invalid_characters(mediainfo.title),
@@ -82,6 +84,8 @@ class TemplateContextBuilder:
"original_title": self.__convert_invalid_characters(mediainfo.original_title),
# 季号
"season": self._context.get("season") or mediainfo.season,
# Sxx
"season_fmt": self._context.get("season_fmt") or season_fmt,
# 年份
"year": mediainfo.year or self._context.get("year"),
# 媒体标题 + 年份
@@ -148,6 +152,8 @@ class TemplateContextBuilder:
meta.name, meta.year) if meta.year else meta.name,
# 季号
"season": meta.season_seq,
# Sxx
"season_fmt": meta.season,
# 集号
"episode": meta.episode_seqs,
# 季集 SxxExx
@@ -269,7 +275,7 @@ class TemplateContextBuilder:
# 当前季的全部集信息
"__episodes_info__": episodes_info,
}
self._context.update({k: v for k, v in raw_objects.items() if v is not None})
self._context.update(raw_objects)
@staticmethod
def __convert_invalid_characters(filename: str):
@@ -565,6 +571,7 @@ class MessageQueueManager(metaclass=SingletonClass):
def _parse_schedule(periods: Union[list, dict]) -> List[tuple[int, int, int, int]]:
"""
将字符串时间格式转换为分钟数元组
支持格式为 'HH:MM''HH:MM:SS' 的时间字符串
"""
parsed = []
if not periods:
@@ -576,9 +583,31 @@ class MessageQueueManager(metaclass=SingletonClass):
continue
if not period.get('start') or not period.get('end'):
continue
start_h, start_m = map(int, period['start'].split(':'))
end_h, end_m = map(int, period['end'].split(':'))
parsed.append((start_h, start_m, end_h, end_m))
try:
# 处理 start 时间
start_parts = period['start'].split(':')
if len(start_parts) == 2:
start_h, start_m = map(int, start_parts)
elif len(start_parts) >= 3:
start_h, start_m = map(int, start_parts[:2]) # 只取前两个部分 (HH:MM)
else:
continue
# 处理 end 时间
end_parts = period['end'].split(':')
if len(end_parts) == 2:
end_h, end_m = map(int, end_parts)
elif len(end_parts) >= 3:
end_h, end_m = map(int, end_parts[:2]) # 只取前两个部分 (HH:MM)
else:
continue
parsed.append((start_h, start_m, end_h, end_m))
except ValueError as e:
logger.error(f"解析时间周期时出现错误:{period}. 错误:{str(e)}. 跳过此周期。")
continue
except Exception as e:
logger.error(f"解析时间周期时出现意外错误:{period}. 错误:{str(e)}. 跳过此周期。")
continue
return parsed
@staticmethod

View File

@@ -7,14 +7,15 @@ from typing import List, Any, Callable
from app.log import logger
FilterFuncType = Callable[[str, Any], bool]
def _default_filter(name: str, obj: Any) -> bool:
"""
默认过滤器
"""
return True
return True if name and obj else False
class ModuleHelper:
"""
@@ -76,7 +77,8 @@ class ModuleHelper:
def reload_sub_modules(parent_module, parent_module_name):
"""重新加载一级子模块"""
for sub_importer, sub_module_name, sub_is_pkg in pkgutil.walk_packages(parent_module.__path__, parent_module_name+'.'):
for sub_importer, sub_module_name, sub_is_pkg in pkgutil.walk_packages(parent_module.__path__,
parent_module_name + '.'):
try:
full_sub_module = importlib.import_module(sub_module_name)
importlib.reload(full_sub_module)

View File

@@ -1,3 +1,4 @@
import sys
import json
import shutil
import traceback
@@ -455,15 +456,15 @@ class PluginHelper(metaclass=Singleton):
:param requirements_file: 依赖的 requirements.txt 文件路径
:return: (是否成功, 错误信息)
"""
base_cmd = [sys.executable, "-m", "pip", "install", "-r", str(requirements_file)]
strategies = []
# 添加策略到列表中
if settings.PIP_PROXY:
strategies.append(("镜像站", ["pip", "install", "-r", str(requirements_file), "-i", settings.PIP_PROXY]))
strategies.append(("镜像站", base_cmd + ["-i", settings.PIP_PROXY]))
if settings.PROXY_HOST:
strategies.append(
("代理", ["pip", "install", "-r", str(requirements_file), "--proxy", settings.PROXY_HOST]))
strategies.append(("直连", ["pip", "install", "-r", str(requirements_file)]))
strategies.append(("代理", base_cmd + ["--proxy", settings.PROXY_HOST]))
strategies.append(("直连", base_cmd))
# 遍历策略进行安装
for strategy_name, pip_command in strategies:

105
app/helper/wallpaper.py Normal file
View File

@@ -0,0 +1,105 @@
from typing import Optional, List
from app.core.cache import cached
from app.core.config import settings
from app.utils.http import RequestUtils
from app.utils.singleton import Singleton
class WallpaperHelper(metaclass=Singleton):
def __init__(self):
self.req = RequestUtils(timeout=5)
@cached(maxsize=1, ttl=3600)
def get_bing_wallpaper(self) -> Optional[str]:
"""
获取Bing每日壁纸
"""
url = "https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1"
resp = self.req.get_res(url)
if resp and resp.status_code == 200:
try:
result = resp.json()
if isinstance(result, dict):
for image in result.get('images') or []:
return f"https://cn.bing.com{image.get('url')}" if 'url' in image else ''
except Exception as err:
print(str(err))
return None
@cached(maxsize=1, ttl=3600)
def get_bing_wallpapers(self, num: int = 7) -> List[str]:
"""
获取7天的Bing每日壁纸
"""
url = f"https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n={num}"
resp = self.req.get_res(url)
if resp and resp.status_code == 200:
try:
result = resp.json()
if isinstance(result, dict):
return [f"https://cn.bing.com{image.get('url')}" for image in result.get('images') or []]
except Exception as err:
print(str(err))
return []
@cached(maxsize=1, ttl=3600)
def get_customize_wallpaper(self) -> Optional[str]:
"""
获取自定义壁纸api壁纸
"""
wallpaper_list = self.get_customize_wallpapers()
if wallpaper_list:
return wallpaper_list[0]
return None
@cached(maxsize=1, ttl=3600)
def get_customize_wallpapers(self) -> List[str]:
"""
获取自定义壁纸api壁纸
"""
def find_files_with_suffixes(obj, suffixes: List[str]) -> List[str]:
"""
递归查找对象中所有包含特定后缀的文件,返回匹配的字符串列表
支持输入:字典、列表、字符串
"""
_result = []
# 处理字符串
if isinstance(obj, str):
if obj.endswith(tuple(suffixes)):
_result.append(obj)
# 处理字典
elif isinstance(obj, dict):
for value in obj.values():
_result.extend(find_files_with_suffixes(value, suffixes))
# 处理列表
elif isinstance(obj, list):
for item in obj:
_result.extend(find_files_with_suffixes(item, suffixes))
return _result
# 判断是否存在自定义壁纸api
if settings.CUSTOMIZE_WALLPAPER_API_URL:
wallpaper_list = []
resp = self.req.get_res(settings.CUSTOMIZE_WALLPAPER_API_URL)
if resp and resp.status_code == 200:
# 如果返回的是图片格式
content_type = resp.headers.get('Content-Type')
if content_type and content_type.lower() == 'image/jpeg':
wallpaper_list.append(resp.url)
else:
try:
result = resp.json()
if isinstance(result, list) or isinstance(result, dict) or isinstance(result, str):
wallpaper_list = find_files_with_suffixes(result, settings.SECURITY_IMAGE_SUFFIXES)
except Exception as err:
print(str(err))
return wallpaper_list
else:
return []

View File

@@ -108,11 +108,17 @@ class MTorrentSpider:
category = MediaType.MOVIE.value
else:
category = MediaType.UNKNOWN.value
labels_value = self._labels.get(result.get('labels') or "0") or ""
if labels_value:
labels = labels_value.split()
# 处理馒头新版标签
labels = []
labels_new = result.get( 'labelsNew' )
if labels_new:
# 新版标签本身就是list
labels = labels_new
else:
labels = []
# 旧版标签
labels_value = self._labels.get(result.get('labels') or "0") or ""
if labels_value:
labels = labels_value.split()
torrent = {
'title': result.get('name'),
'description': result.get('smallDescr'),

View File

@@ -115,11 +115,18 @@ class CategoryHelper(metaclass=Singleton):
for attr, value in item.items():
if not value:
continue
info_value = tmdb_info.get(attr)
if attr == "release_year":
# 发行年份
info_value = tmdb_info.get("release_date") or tmdb_info.get("first_air_date")
if info_value:
info_value = str(info_value)[:4]
else:
info_value = tmdb_info.get(attr)
if not info_value:
match_flag = False
continue
elif attr == "production_countries":
# 制片国家
info_values = [str(val.get("iso_3166_1")).upper() for val in info_value]
else:
if isinstance(info_value, list):
@@ -128,7 +135,18 @@ class CategoryHelper(metaclass=Singleton):
info_values = [str(info_value).upper()]
if value.find(",") != -1:
# , 分隔多个值
values = [str(val).upper() for val in value.split(",") if val]
elif value.find("-") != -1:
# - 表示范围,仅限于数字
value_begin = value.split("-")[0]
value_end = value.split("-")[1]
if value_begin.isdigit() and value_end.isdigit():
# 数字范围
values = [str(val) for val in range(int(value_begin), int(value_end) + 1)]
else:
# 字符串范围
values = [str(value_begin), str(value_end)]
else:
values = [str(value).upper()]

View File

@@ -76,7 +76,7 @@ class TmdbScraper:
"""
images = {}
if season is not None:
# 只需要集的图片
# 只需要集的图片
if episode:
# 集的图片
if mediainfo.episode_group:
@@ -100,25 +100,25 @@ class TmdbScraper:
images[poster_name] = poster_url
return images
else:
# 主媒体图片
_mediainfo = self.original_tmdb(mediainfo).get_info(mediainfo.type, mediainfo.tmdb_id)
if _mediainfo:
for attr_name, attr_value in _mediainfo.items():
if attr_name.endswith("_path") and attr_value is not None:
image_url = f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{attr_value}"
image_name = attr_name.replace("_path", "") + Path(image_url).suffix
images[image_name] = image_url
return images
else:
for attr_name, attr_value in vars(mediainfo).items():
if attr_value \
and attr_name.endswith("_path") \
and attr_value \
and isinstance(attr_value, str) \
and attr_value.startswith("http"):
image_name = attr_name.replace("_path", "") + Path(attr_value).suffix
images[image_name] = attr_value
return images
# 获取媒体信息中原有图片TheMovieDb或Fanart
for attr_name, attr_value in vars(mediainfo).items():
if attr_value \
and attr_name.endswith("_path") \
and attr_value \
and isinstance(attr_value, str) \
and attr_value.startswith("http"):
image_name = attr_name.replace("_path", "") + Path(attr_value).suffix
images[image_name] = attr_value
# 替换原语言Poster
if settings.TMDB_SCRAP_ORIGINAL_IMAGE:
_mediainfo = self.original_tmdb(mediainfo).get_info(mediainfo.type, mediainfo.tmdb_id)
if _mediainfo:
for attr_name, attr_value in _mediainfo.items():
if attr_name.endswith("_path") and attr_value is not None:
image_url = f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{attr_value}"
image_name = attr_name.replace("_path", "") + Path(image_url).suffix
images[image_name] = image_url
return images
@staticmethod
def get_season_poster(seasoninfo: dict, season: int) -> Tuple[str, str]:

View File

@@ -25,29 +25,17 @@ class TmdbApi:
def __init__(self, language: Optional[str] = None):
# TMDB主体
self.tmdb = TMDb()
# 域名
self.tmdb.domain = settings.TMDB_API_DOMAIN
# 开启缓存
self.tmdb.cache = True
# APIKEY
self.tmdb.api_key = settings.TMDB_API_KEY
# 语种
self.tmdb.language = language or settings.TMDB_LOCALE
# 代理
self.tmdb.proxies = settings.PROXY
# 调试模式
self.tmdb.debug = False
self.tmdb = TMDb(language=language)
# TMDB查询对象
self.search = Search()
self.movie = Movie()
self.tv = TV()
self.season_obj = Season()
self.episode_obj = Episode()
self.discover = Discover()
self.trending = Trending()
self.person = Person()
self.collection = Collection()
self.search = Search(language=language)
self.movie = Movie(language=language)
self.tv = TV(language=language)
self.season_obj = Season(language=language)
self.episode_obj = Episode(language=language)
self.discover = Discover(language=language)
self.trending = Trending(language=language)
self.person = Person(language=language)
self.collection = Collection(language=language)
def search_multiis(self, title: str) -> List[dict]:
"""

View File

@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
import logging
import os
import time
from datetime import datetime
@@ -17,19 +16,22 @@ logger = logging.getLogger(__name__)
class TMDb(object):
TMDB_API_KEY = "TMDB_API_KEY"
TMDB_LANGUAGE = "TMDB_LANGUAGE"
TMDB_SESSION_ID = "TMDB_SESSION_ID"
TMDB_WAIT_ON_RATE_LIMIT = "TMDB_WAIT_ON_RATE_LIMIT"
TMDB_DEBUG_ENABLED = "TMDB_DEBUG_ENABLED"
TMDB_CACHE_ENABLED = "TMDB_CACHE_ENABLED"
TMDB_PROXIES = "TMDB_PROXIES"
TMDB_DOMAIN = "TMDB_DOMAIN"
_req = None
_session = None
def __init__(self, obj_cached=True, session=None):
def __init__(self, obj_cached=True, session=None, language=None):
self._api_key = settings.TMDB_API_KEY
self._language = language or settings.TMDB_LOCALE or "en-US"
self._session_id = None
self._wait_on_rate_limit = True
self._debug_enabled = False
self._cache_enabled = obj_cached
self._proxies = settings.PROXY
self._domain = settings.TMDB_API_DOMAIN
self._page = None
self._total_results = None
self._total_pages = None
if session is not None:
self._req = RequestUtils(session=session, proxies=self.proxies)
else:
@@ -39,103 +41,88 @@ class TMDb(object):
self._reset = None
self._timeout = 15
self.obj_cached = obj_cached
if os.environ.get(self.TMDB_LANGUAGE) is None:
os.environ[self.TMDB_LANGUAGE] = "en-US"
@property
def page(self):
return os.environ["page"]
return self._page
@property
def total_results(self):
return os.environ["total_results"]
return self._total_results
@property
def total_pages(self):
return os.environ["total_pages"]
return self._total_pages
@property
def api_key(self):
return os.environ.get(self.TMDB_API_KEY)
return self._api_key
@property
def domain(self):
return os.environ.get(self.TMDB_DOMAIN)
return self._domain
@property
def proxies(self):
proxy = os.environ.get(self.TMDB_PROXIES)
if proxy is not None:
proxy = eval(proxy)
return proxy
return self._proxies
@proxies.setter
def proxies(self, proxies):
if proxies is not None:
os.environ[self.TMDB_PROXIES] = str(proxies)
self._proxies = proxies
@api_key.setter
def api_key(self, api_key):
os.environ[self.TMDB_API_KEY] = str(api_key)
self._api_key = str(api_key)
@domain.setter
def domain(self, domain):
os.environ[self.TMDB_DOMAIN] = str(domain)
self._domain = str(domain)
@property
def language(self):
return os.environ.get(self.TMDB_LANGUAGE)
return self._language
@language.setter
def language(self, language):
os.environ[self.TMDB_LANGUAGE] = language
self._language = language
@property
def has_session(self):
return True if os.environ.get(self.TMDB_SESSION_ID) else False
return True if self._session_id else False
@property
def session_id(self):
if not os.environ.get(self.TMDB_SESSION_ID):
if not self._session_id:
raise TMDbException("Must Authenticate to create a session run Authentication(username, password)")
return os.environ.get(self.TMDB_SESSION_ID)
return self._session_id
@session_id.setter
def session_id(self, session_id):
os.environ[self.TMDB_SESSION_ID] = session_id
self._session_id = session_id
@property
def wait_on_rate_limit(self):
if os.environ.get(self.TMDB_WAIT_ON_RATE_LIMIT) == "False":
return False
else:
return True
return self._wait_on_rate_limit
@wait_on_rate_limit.setter
def wait_on_rate_limit(self, wait_on_rate_limit):
os.environ[self.TMDB_WAIT_ON_RATE_LIMIT] = str(wait_on_rate_limit)
self._wait_on_rate_limit = bool(wait_on_rate_limit)
@property
def debug(self):
if os.environ.get(self.TMDB_DEBUG_ENABLED) == "True":
return True
else:
return False
return self._debug_enabled
@debug.setter
def debug(self, debug):
os.environ[self.TMDB_DEBUG_ENABLED] = str(debug)
self._debug_enabled = bool(debug)
@property
def cache(self):
if os.environ.get(self.TMDB_CACHE_ENABLED) == "False":
return False
else:
return True
return self._cache_enabled
@cache.setter
def cache(self, cache):
os.environ[self.TMDB_CACHE_ENABLED] = str(cache)
self._cache_enabled = bool(cache)
@cached(maxsize=settings.CACHE_CONF["tmdb"], ttl=settings.CACHE_CONF["meta"])
def cached_request(self, method, url, data, json,
@@ -197,30 +184,30 @@ class TMDb(object):
else:
raise TMDbException("达到请求频率限制,将在 %d 秒后重试..." % sleep_time)
json = req.json()
json_data = req.json()
if "page" in json:
os.environ["page"] = str(json["page"])
if "page" in json_data:
self._page = json_data["page"]
if "total_results" in json:
os.environ["total_results"] = str(json["total_results"])
if "total_results" in json_data:
self._total_results = json_data["total_results"]
if "total_pages" in json:
os.environ["total_pages"] = str(json["total_pages"])
if "total_pages" in json_data:
self._total_pages = json_data["total_pages"]
if self.debug:
logger.info(json)
logger.info(json_data)
logger.info(self.cached_request.cache_info())
if "errors" in json:
raise TMDbException(json["errors"])
if "errors" in json_data:
raise TMDbException(json_data["errors"])
if "success" in json and json["success"] is False:
raise TMDbException(json["status_message"])
if "success" in json_data and json_data["success"] is False:
raise TMDbException(json_data["status_message"])
if key:
return json.get(key)
return json
return json_data.get(key)
return json_data
def close(self):
if self._session:

5
app/modules/transmission/transmission.py Normal file → Executable file
View File

@@ -163,8 +163,9 @@ class Transmission:
if not self.trc:
return []
try:
torrent = self.trc.get_torrents(ids=ids, arguments=self._trarg)
if torrent:
torrents = self.trc.get_torrents(ids=ids, arguments=self._trarg)
if len(torrents):
torrent = torrents[0]
labels = [str(tag).strip()
for tag in torrent.labels] if hasattr(torrent, "labels") else []
return labels

View File

@@ -182,6 +182,22 @@ class _PluginBase(metaclass=ABCMeta):
"""
pass
def get_actions(self) -> List[Dict[str, Any]]:
"""
获取插件工作流动作
[{
"id": "动作ID",
"name": "动作名称",
"func": self.xxx,
"kwargs": {} # 需要附加传递的参数
}]
对实现函数的要求:
1、函数的第一个参数固定为 ActionContent 实例如需要传递额外参数在kwargs中定义
2、函数的返回执行状态 True / False更新后的 ActionContent 实例
"""
pass
@abstractmethod
def stop_service(self):
"""

View File

@@ -51,6 +51,8 @@ class Scheduler(metaclass=Singleton):
_jobs = {}
# 用户认证失败次数
_auth_count = 0
# 用户认证失败消息发送
_auth_message = False
def __init__(self):
self.init()
@@ -661,9 +663,11 @@ class Scheduler(metaclass=Singleton):
# 最大重试次数
__max_try__ = 30
if self._auth_count > __max_try__:
SchedulerChain().messagehelper.put(title=f"用户认证失败",
message="用户认证失败次数过多,将不再尝试认证!",
role="system")
if not self._auth_message:
SchedulerChain().messagehelper.put(title=f"用户认证失败",
message="用户认证失败次数过多,将不再尝试认证!",
role="system")
self._auth_message = True
return
logger.info("用户未认证,正在尝试认证...")
auth_conf = SystemConfigOper().get(SystemConfigKey.UserSiteAuthParams)
@@ -678,10 +682,11 @@ class Scheduler(metaclass=Singleton):
Notification(
mtype=NotificationType.Manual,
title="MoviePilot用户认证成功",
text=f"使用站点:{msg}",
text=f"使用站点:{msg}如有插件使用异常请重启MoviePilot。",
link=settings.MP_DOMAIN('#/site')
)
)
# 认证通过后重新初始化插件
PluginManager().init_config()
self.init_plugin_jobs()

View File

@@ -197,13 +197,13 @@ class ContentType(str, Enum):
操作状态的通知消息类型标识
"""
# 订阅添加成功
SubscribeAdded: str = "subscribeAdded"
SubscribeAdded = "subscribeAdded"
# 订阅完成
SubscribeComplete: str = "subscribeComplete"
SubscribeComplete = "subscribeComplete"
# 入库成功
OrganizeSuccess: str = "organizeSuccess"
OrganizeSuccess = "organizeSuccess"
# 下载开始(添加下载任务成功)
DownloadAdded: str = "downloadAdded"
DownloadAdded = "downloadAdded"
# 消息渠道

View File

@@ -0,0 +1,22 @@
from app.command import Command
def init_command():
"""
初始化命令
"""
Command()
def stop_command():
"""
停止命令
"""
pass
def restart_command():
"""
重启命令
"""
Command().init_commands()

View File

@@ -3,10 +3,25 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.startup.workflow_initializer import init_workflow, stop_workflow
from app.startup.modules_initializer import shutdown_modules, start_modules
from app.startup.plugins_initializer import init_plugins_async
from app.core.config import global_vars
from app.startup.command_initializer import init_command, stop_command, restart_command
from app.startup.modules_initializer import init_modules, stop_modules
from app.startup.monitor_initializer import stop_monitor, init_monitor
from app.startup.plugins_initializer import init_plugins, stop_plugins, sync_plugins
from app.startup.routers_initializer import init_routers
from app.startup.scheduler_initializer import stop_scheduler, init_scheduler, init_plugin_scheduler
from app.startup.workflow_initializer import init_workflow, stop_workflow
async def init_plugin_system():
"""
同步插件及重启相关依赖服务
"""
if await sync_plugins():
# 重新注册插件定时服务
init_plugin_scheduler()
# 重新注册命令
restart_command()
@asynccontextmanager
@@ -15,29 +30,45 @@ async def lifespan(app: FastAPI):
定义应用的生命周期事件
"""
print("Starting up...")
# 启动模块
start_modules(app)
# 初始化工作流动作
init_workflow(app)
# 初始化模块
init_modules()
# 初始化路由
init_routers(app)
# 初始化插件
plugin_init_task = asyncio.create_task(init_plugins_async())
init_plugins()
# 初始化定时器
init_scheduler()
# 初始化监控器
init_monitor()
# 初始化命令
init_command()
# 初始化工作流
init_workflow()
# 插件同步到本地
sync_plugins_task = asyncio.create_task(init_plugin_system())
try:
# 在此处 yield表示应用已经启动控制权交回 FastAPI 主事件循环
yield
finally:
print("Shutting down...")
# 停止信号
global_vars.stop_system()
try:
# 取消插件初始化
plugin_init_task.cancel()
await plugin_init_task
sync_plugins_task.cancel()
await sync_plugins_task
except asyncio.CancelledError:
print("Plugin installation task cancelled.")
pass
except Exception as e:
print(f"Error during plugin installation shutdown: {e}")
# 清理模块
shutdown_modules(app)
# 关闭工作流
stop_workflow(app)
print(str(e))
# 停止工作流
stop_workflow()
# 停止命令
stop_command()
# 停止监控器
stop_monitor()
# 停止定时器
stop_scheduler()
# 停止插件
stop_plugins()
# 停止模块
stop_modules()

View File

@@ -1,12 +1,11 @@
import sys
from fastapi import FastAPI
from app.core.cache import close_cache
from app.core.config import global_vars, settings
from app.core.config import settings
from app.core.module import ModuleManager
from app.log import logger
from app.utils.system import SystemUtils
from app.command import CommandChain
# SitesHelper涉及资源包拉取提前引入并容错提示
try:
@@ -18,18 +17,14 @@ except ImportError as e:
sys.exit(1)
from app.core.event import EventManager
from app.core.plugin import PluginManager
from app.helper.thread import ThreadHelper
from app.helper.display import DisplayHelper
from app.helper.resource import ResourceHelper
from app.helper.message import MessageHelper
from app.scheduler import Scheduler
from app.monitor import Monitor
from app.schemas import Notification, NotificationType
from app.schemas.types import SystemConfigKey
from app.db import close_database
from app.db.systemconfig_oper import SystemConfigOper
from app.command import Command, CommandChain
def start_frontend():
@@ -109,25 +104,16 @@ def check_auth():
)
def shutdown_modules(_: FastAPI):
def stop_modules():
"""
服务关闭
"""
# 停止信号
global_vars.stop_system()
# 停止模块
ModuleManager().stop()
# 停止插件
PluginManager().stop()
PluginManager().stop_monitor()
# 停止事件消费
EventManager().stop()
# 停止虚拟显示
DisplayHelper().stop()
# 停止定时服务
Scheduler().stop()
# 停止监控
Monitor().stop()
# 停止线程池
ThreadHelper().shutdown()
# 停止缓存连接
@@ -140,7 +126,7 @@ def shutdown_modules(_: FastAPI):
clear_temp()
def start_modules(_: FastAPI):
def init_modules():
"""
启动模块
"""
@@ -156,14 +142,6 @@ def start_modules(_: FastAPI):
ModuleManager()
# 启动事件消费
EventManager().start()
# 加载插件
PluginManager().start()
# 启动监控任务
Monitor()
# 启动定时服务
Scheduler()
# 加载命令
Command()
# 启动前端服务
start_frontend()
# 检查认证状态

View File

@@ -0,0 +1,15 @@
from app.monitor import Monitor
def init_monitor():
"""
初始化监控器
"""
Monitor()
def stop_monitor():
"""
停止监控器
"""
Monitor().stop()

View File

@@ -1,43 +1,36 @@
import asyncio
from app.command import Command
from app.core.plugin import PluginManager
from app.log import logger
from app.scheduler import Scheduler
async def init_plugins_async():
async def sync_plugins() -> bool:
"""
初始化安装插件并动态注册后台任务及API
"""
try:
loop = asyncio.get_event_loop()
plugin_manager = PluginManager()
scheduler = Scheduler()
command = Command()
sync_result = await execute_task(loop, plugin_manager.sync, "插件同步到本地")
resolved_dependencies = await execute_task(loop, plugin_manager.install_plugin_missing_dependencies,
"缺失依赖项安装")
# 判断是否需要进行插件初始化
if not sync_result and not resolved_dependencies:
logger.debug("没有新的插件同步到本地或缺失依赖项需要安装,跳过插件初始化")
return
logger.debug("没有新的插件同步到本地或缺失依赖项需要安装")
return False
# 继续执行后续的插件初始化步骤
logger.info("正在初始化所有插件")
# 为避免初始化插件异常,这里所有插件都进行初始化
# 安装完成后重新初始化插件
logger.info("正在重新初始化插件")
# 重新初始化插件
plugin_manager.init_config()
# 插件启动后注册后台任务
scheduler.init_plugin_jobs()
# 插件启动后注册菜单命令
command.init_commands()
# 插件启动后注册插件API
# 重新注册插件API
register_plugin_api()
logger.info("所有插件初始化完成")
return True
except Exception as e:
logger.error(f"插件初始化过程中出现异常: {e}")
return False
async def execute_task(loop, task_func, task_name):
@@ -62,3 +55,23 @@ def register_plugin_api():
"""
from app.api.endpoints import plugin
plugin.register_plugin_api()
def init_plugins():
"""
初始化插件
"""
PluginManager().start()
register_plugin_api()
def stop_plugins():
"""
停止插件
"""
try:
plugin_manager = PluginManager()
plugin_manager.stop()
plugin_manager.stop_monitor()
except Exception as e:
logger.error(f"停止插件时发生错误:{e}", exc_info=True)

View File

@@ -0,0 +1,29 @@
from app.scheduler import Scheduler
def init_scheduler():
"""
初始化定时器
"""
Scheduler()
def stop_scheduler():
"""
停止定时器
"""
Scheduler().stop()
def restart_scheduler():
"""
重启定时器
"""
Scheduler().init()
def init_plugin_scheduler():
"""
初始化插件定时器
"""
Scheduler().init_plugin_jobs()

View File

@@ -1,16 +1,14 @@
from fastapi import FastAPI
from app.core.workflow import WorkFlowManager
def init_workflow(_: FastAPI):
def init_workflow():
"""
初始化动作
"""
WorkFlowManager()
def stop_workflow(_: FastAPI):
def stop_workflow():
"""
停止动作
"""

View File

@@ -1,7 +1,3 @@
from typing import Optional, List
from app.core.cache import cached
from app.utils.http import RequestUtils
@@ -73,38 +69,3 @@ class WebUtils:
except Exception as err:
print(str(err))
return ""
@staticmethod
@cached(maxsize=1, ttl=3600)
def get_bing_wallpaper() -> Optional[str]:
"""
获取Bing每日壁纸
"""
url = "https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1"
resp = RequestUtils(timeout=5).get_res(url)
if resp and resp.status_code == 200:
try:
result = resp.json()
if isinstance(result, dict):
for image in result.get('images') or []:
return f"https://cn.bing.com{image.get('url')}" if 'url' in image else ''
except Exception as err:
print(str(err))
return None
@staticmethod
@cached(maxsize=1, ttl=3600)
def get_bing_wallpapers(num: int = 7) -> List[str]:
"""
获取7天的Bing每日壁纸
"""
url = f"https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n={num}"
resp = RequestUtils(timeout=5).get_res(url)
if resp and resp.status_code == 200:
try:
result = resp.json()
if isinstance(result, dict):
return [f"https://cn.bing.com{image.get('url')}" for image in result.get('images') or []]
except Exception as err:
print(str(err))
return []

View File

@@ -5,6 +5,7 @@
# `original_language` 语种,具体含义参考下方字典
# `production_countries` 国家或地区(电影)、`origin_country` 国家或地区(电视剧),具体含义参考下方字典
# `genre_ids` 内容类型,具体含义参考下方字典
# `release_year` 发行年份格式YYYY电影实际对应`release_date`字段,电视剧实际对应`first_air_date`字段,支持范围设定,如:`YYYY-YYYY`
# themoviedb 详情API返回的其它一级字段
# 4. 配置多项条件时需要同时满足,一个条件需要匹配多个值是使用`,`分隔

View File

@@ -0,0 +1,37 @@
"""2.1.5
Revision ID: 486e56a62dcb
Revises: 89d24811e894
Create Date: 2025-05-13 19:49:51.271319
"""
import re
from app.db.systemconfig_oper import SystemConfigOper
from app.schemas.types import SystemConfigKey
# revision identifiers, used by Alembic.
revision = '486e56a62dcb'
down_revision = '89d24811e894'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
### 将消息模板中的 `season`(为单数字, 且重命名需要这个字段)替换为 `season_fmt`(Sxx格式字符串) ###
_systemconfig = SystemConfigOper()
templates = _systemconfig.get(SystemConfigKey.NotificationTemplates)
if isinstance(templates, dict):
_re = r'(?<={{)(?![^}]*[%|])(\s*)season(\s*)(?=}})|(?<={%)if\s+(?![^%]*[%|])season\s*(?=%)'
for k, v in templates.items():
# 替换season为season_fmt
result = re.sub(_re, r'\1season_fmt\2', v)
templates[k] = result
# 将更新后的模板存回系统配置
_systemconfig.set(SystemConfigKey.NotificationTemplates, templates)
# ### end Alembic commands ###
def downgrade() -> None:
pass

View File

@@ -33,7 +33,7 @@ def upgrade() -> None:
"downloadAdded": """
{
'title': '{{ title_year }}'
'{% if download_episodes %} {{ season }} {{ download_episodes }}{% else %}{{ season_episode }}{% endif %} 开始下载',
'{% if download_episodes %} {{ season_fmt }} {{ download_episodes }}{% else %}{{ season_episode }}{% endif %} 开始下载',
'text': '{% if site_name %}站点:{{ site_name }}{% endif %}'
'{% if resource_term %}\\n质量{{ resource_term }}{% endif %}'
'{% if size %}\\n大小{{ size }}{% endif %}'
@@ -46,10 +46,11 @@ def upgrade() -> None:
'{% if labels %}\\n标签{{ labels }}{% endif %}'
'{% if description %}\\n描述{{ description }}{% endif %}'
}""",
"subscribeAdded": "{'title': '{{ title_year }} {{season}} 已添加订阅'}",
"subscribeAdded": "{'title': '{{ title_year }}{% if season_fmt %} {{ season_fmt }}{% endif %} 已添加订阅'}",
"subscribeComplete": """
{
'title': '{{ title_year }} {{season}} 已完成{{msgstr}}',
'title': '{{ title_year }}'
'{% if season_fmt %} {{ season_fmt }}{% endif %} 已完成{{ msgstr }}',
'text': '{% if vote_average %}评分:{{ vote_average }}{% endif %}'
'{% if username %},来自用户:{{ username }}{% endif %}'
'{% if actors %}\\n演员{{ actors }}{% endif %}'

View File

@@ -1,2 +1,2 @@
APP_VERSION = 'v2.4.6'
FRONTEND_VERSION = 'v2.4.6'
APP_VERSION = 'v2.4.8'
FRONTEND_VERSION = 'v2.4.8'