mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-13 07:26:45 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe25f8f48f | ||
|
|
7f59572d8b | ||
|
|
90fc4c6bad | ||
|
|
bcbfe2ccd5 | ||
|
|
bd9a1d7ec7 | ||
|
|
9331ba64d6 | ||
|
|
21e5cb0a03 | ||
|
|
1a8e0c9ecb | ||
|
|
16fc0d31cd | ||
|
|
a622ada58b | ||
|
|
ee9c4948d3 | ||
|
|
cf28e1d963 | ||
|
|
089ec36160 | ||
|
|
04ce774c22 | ||
|
|
99c1422f37 | ||
|
|
b583a60f23 | ||
|
|
7be2910809 | ||
|
|
30de524319 | ||
|
|
c431d5e759 | ||
|
|
184b62b024 | ||
|
|
2751770350 | ||
|
|
75d98aee8e | ||
|
|
48120b9406 | ||
|
|
0e302d7959 |
@@ -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()
|
||||
|
||||
@@ -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
2
app/chain/transfer.py
Normal file → Executable 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("所有下载器中下载完成的文件已整理完成")
|
||||
|
||||
@@ -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
|
||||
# 是否开启插件热加载
|
||||
|
||||
@@ -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 或标识
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -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
5
app/modules/transmission/transmission.py
Normal file → Executable 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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
# 消息渠道
|
||||
|
||||
22
app/startup/command_initializer.py
Normal file
22
app/startup/command_initializer.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from app.command import Command
|
||||
|
||||
|
||||
def init_command():
|
||||
"""
|
||||
初始化命令
|
||||
"""
|
||||
Command()
|
||||
|
||||
|
||||
def stop_command():
|
||||
"""
|
||||
停止命令
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
def restart_command():
|
||||
"""
|
||||
重启命令
|
||||
"""
|
||||
Command().init_commands()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
# 检查认证状态
|
||||
|
||||
15
app/startup/monitor_initializer.py
Normal file
15
app/startup/monitor_initializer.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from app.monitor import Monitor
|
||||
|
||||
|
||||
def init_monitor():
|
||||
"""
|
||||
初始化监控器
|
||||
"""
|
||||
Monitor()
|
||||
|
||||
|
||||
def stop_monitor():
|
||||
"""
|
||||
停止监控器
|
||||
"""
|
||||
Monitor().stop()
|
||||
@@ -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)
|
||||
|
||||
29
app/startup/scheduler_initializer.py
Normal file
29
app/startup/scheduler_initializer.py
Normal 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()
|
||||
@@ -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():
|
||||
"""
|
||||
停止动作
|
||||
"""
|
||||
|
||||
37
database/versions/486e56a62dcb_2_1_5.py
Normal file
37
database/versions/486e56a62dcb_2_1_5.py
Normal 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
|
||||
@@ -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 %}'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
APP_VERSION = 'v2.4.6'
|
||||
FRONTEND_VERSION = 'v2.4.6'
|
||||
APP_VERSION = 'v2.4.7'
|
||||
FRONTEND_VERSION = 'v2.4.7'
|
||||
|
||||
Reference in New Issue
Block a user