Compare commits

...

24 Commits

Author SHA1 Message Date
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
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
25 changed files with 387 additions and 231 deletions

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)

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

@@ -217,7 +217,8 @@ 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")
# 插件安装数据共享
PLUGIN_STATISTIC_SHARE: bool = True
# 是否开启插件热加载

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 = {}
@@ -833,8 +836,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:

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

@@ -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

@@ -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

@@ -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.7'
FRONTEND_VERSION = 'v2.4.7'