mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-08 09:13:15 +08:00
Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1336b2136d | ||
|
|
b20e21e700 | ||
|
|
c27ab4a4c7 | ||
|
|
d9e6532325 | ||
|
|
049f16ba01 | ||
|
|
6541458326 | ||
|
|
9f2912426b | ||
|
|
fde33d267a | ||
|
|
ef7f0afa37 | ||
|
|
bea77a8243 | ||
|
|
b984b83870 | ||
|
|
2153ad48db | ||
|
|
c9c43fde74 | ||
|
|
e2c9742f64 | ||
|
|
3d459a40f7 | ||
|
|
5675cd5b11 | ||
|
|
74a4d0bd66 | ||
|
|
2b8c313019 | ||
|
|
62fb6b80a3 | ||
|
|
eea86528d8 | ||
|
|
84e6abb659 | ||
|
|
da2c755b6d | ||
|
|
51f39be9bc | ||
|
|
21b762e75c | ||
|
|
54095074b6 | ||
|
|
33525730b5 | ||
|
|
71260f04b5 | ||
|
|
e2acec321d | ||
|
|
74a462a09f | ||
|
|
ad9e1a5da6 | ||
|
|
d90e3c29a5 | ||
|
|
19165eff75 | ||
|
|
52d0703812 | ||
|
|
1431a5e82a | ||
|
|
23fe643526 | ||
|
|
545b3c0482 | ||
|
|
f102119eef | ||
|
|
9bb3d707c9 | ||
|
|
b892ef50dc | ||
|
|
41e2907168 | ||
|
|
14e28ed693 | ||
|
|
79393c21ff | ||
|
|
cafa4d217c | ||
|
|
2b9e69b112 | ||
|
|
3ffcea70a7 | ||
|
|
ffc72ba6fe | ||
|
|
848becd946 | ||
|
|
71fe96d7f9 | ||
|
|
35c7238ede | ||
|
|
3578204508 | ||
|
|
c11cf17f62 | ||
|
|
5a59652684 | ||
|
|
7f5f31f143 | ||
|
|
dc1cee80b1 | ||
|
|
92cb066748 | ||
|
|
6c8ef4122b | ||
|
|
971b02ac8c | ||
|
|
d4a9643f47 | ||
|
|
e56d31fedc | ||
|
|
b9d91c5cd7 | ||
|
|
57cdb57331 | ||
|
|
0f7a7ef44f | ||
|
|
6267b3f670 | ||
|
|
82f77b4729 | ||
|
|
58da0ebb4f | ||
|
|
7a43e43478 | ||
|
|
e5ec02e043 | ||
|
|
2944c343a8 | ||
|
|
940cc566c8 | ||
|
|
db7b2cdcac | ||
|
|
8111cf5dc8 | ||
|
|
be55c7bdd9 | ||
|
|
a4288aa871 | ||
|
|
c0f15ac7ff | ||
|
|
4047d433f5 | ||
|
|
91d6769d0f | ||
|
|
ad378956bf | ||
|
|
9dcfb6dc1e | ||
|
|
2d0b21d3f2 | ||
|
|
3287c85300 | ||
|
|
fd2682bc6a | ||
|
|
9a2ef5fe48 | ||
|
|
7bd55caed7 | ||
|
|
ae36f5100a |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -16,4 +16,5 @@ config/sites/**
|
||||
*.pyc
|
||||
*.log
|
||||
.vscode
|
||||
venv
|
||||
venv
|
||||
.DS_Store
|
||||
|
||||
@@ -11,7 +11,7 @@ ENV LANG="C.UTF-8" \
|
||||
PORT=3001 \
|
||||
NGINX_PORT=3000 \
|
||||
PROXY_HOST="" \
|
||||
MOVIEPILOT_AUTO_UPDATE=release \
|
||||
MOVIEPILOT_AUTO_UPDATE=false \
|
||||
AUTH_SITE="iyuu" \
|
||||
IYUU_SIGN=""
|
||||
WORKDIR "/app"
|
||||
|
||||
@@ -108,13 +108,19 @@ def install(plugin_id: str,
|
||||
"""
|
||||
# 已安装插件
|
||||
install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
# 如果是非本地括件,或者强制安装时,则需要下载安装
|
||||
if repo_url and (force or plugin_id not in PluginManager().get_plugin_ids()):
|
||||
# 下载安装
|
||||
state, msg = PluginHelper().install(pid=plugin_id, repo_url=repo_url)
|
||||
if not state:
|
||||
# 安装失败
|
||||
return schemas.Response(success=False, message=msg)
|
||||
# 首先检查插件是否已经存在,并且是否强制安装,否则只进行安装统计
|
||||
if not force and plugin_id in PluginManager().get_plugin_ids():
|
||||
PluginHelper().install_reg(pid=plugin_id)
|
||||
else:
|
||||
# 插件不存在或需要强制安装,下载安装并注册插件
|
||||
if repo_url:
|
||||
state, msg = PluginHelper().install(pid=plugin_id, repo_url=repo_url)
|
||||
# 安装失败则直接响应
|
||||
if not state:
|
||||
return schemas.Response(success=False, message=msg)
|
||||
else:
|
||||
# repo_url 为空时,也直接响应
|
||||
return schemas.Response(success=False, message="没有传入仓库地址,无法正确安装插件,请检查配置")
|
||||
# 安装插件
|
||||
if plugin_id not in install_plugins:
|
||||
install_plugins.append(plugin_id)
|
||||
@@ -186,10 +192,7 @@ def reset_plugin(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)
|
||||
# 删除插件所有数据
|
||||
PluginManager().delete_plugin_data(plugin_id)
|
||||
# 重新生效插件
|
||||
PluginManager().init_plugin(plugin_id, {
|
||||
"enabled": False,
|
||||
"enable": False
|
||||
})
|
||||
PluginManager().reload_plugin(plugin_id)
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
# 注册插件API
|
||||
|
||||
@@ -10,8 +10,7 @@ from ruamel.yaml import CommentedMap
|
||||
from transmission_rpc import File
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.context import Context
|
||||
from app.core.context import MediaInfo, TorrentInfo
|
||||
from app.core.context import Context, MediaInfo, TorrentInfo
|
||||
from app.core.event import EventManager
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.module import ModuleManager
|
||||
@@ -79,6 +78,7 @@ class ChainBase(metaclass=ABCMeta):
|
||||
def run_module(self, method: str, *args, **kwargs) -> Any:
|
||||
"""
|
||||
运行包含该方法的所有模块,然后返回结果
|
||||
当kwargs包含命名参数raise_exception时,如模块方法抛出异常且raise_exception为True,则同步抛出异常
|
||||
"""
|
||||
|
||||
def is_result_empty(ret):
|
||||
@@ -117,6 +117,8 @@ class ChainBase(metaclass=ABCMeta):
|
||||
# 中止继续执行
|
||||
break
|
||||
except Exception as err:
|
||||
if kwargs.get("raise_exception"):
|
||||
raise
|
||||
logger.error(
|
||||
f"运行模块 {module_id}.{method} 出错:{str(err)}\n{traceback.format_exc()}")
|
||||
self.messagehelper.put(title=f"{module_name}发生了错误",
|
||||
@@ -166,7 +168,8 @@ class ChainBase(metaclass=ABCMeta):
|
||||
tmdbid=tmdbid, doubanid=doubanid, bangumiid=bangumiid, cache=cache)
|
||||
|
||||
def match_doubaninfo(self, name: str, imdbid: str = None,
|
||||
mtype: MediaType = None, year: str = None, season: int = None) -> Optional[dict]:
|
||||
mtype: MediaType = None, year: str = None, season: int = None,
|
||||
raise_exception: bool = False) -> Optional[dict]:
|
||||
"""
|
||||
搜索和匹配豆瓣信息
|
||||
:param name: 标题
|
||||
@@ -174,9 +177,10 @@ class ChainBase(metaclass=ABCMeta):
|
||||
:param mtype: 类型
|
||||
:param year: 年份
|
||||
:param season: 季
|
||||
:param raise_exception: 触发速率限制时是否抛出异常
|
||||
"""
|
||||
return self.run_module("match_doubaninfo", name=name, imdbid=imdbid,
|
||||
mtype=mtype, year=year, season=season)
|
||||
mtype=mtype, year=year, season=season, raise_exception=raise_exception)
|
||||
|
||||
def match_tmdbinfo(self, name: str, mtype: MediaType = None,
|
||||
year: str = None, season: int = None) -> Optional[dict]:
|
||||
@@ -214,14 +218,15 @@ class ChainBase(metaclass=ABCMeta):
|
||||
image_prefix=image_prefix, image_type=image_type,
|
||||
season=season, episode=episode)
|
||||
|
||||
def douban_info(self, doubanid: str, mtype: MediaType = None) -> Optional[dict]:
|
||||
def douban_info(self, doubanid: str, mtype: MediaType = None, raise_exception: bool = False) -> Optional[dict]:
|
||||
"""
|
||||
获取豆瓣信息
|
||||
:param doubanid: 豆瓣ID
|
||||
:param mtype: 媒体类型
|
||||
:return: 豆瓣信息
|
||||
:param raise_exception: 触发速率限制时是否抛出异常
|
||||
"""
|
||||
return self.run_module("douban_info", doubanid=doubanid, mtype=mtype)
|
||||
return self.run_module("douban_info", doubanid=doubanid, mtype=mtype, raise_exception=raise_exception)
|
||||
|
||||
def tvdb_info(self, tvdbid: int) -> Optional[dict]:
|
||||
"""
|
||||
|
||||
@@ -76,6 +76,8 @@ class DownloadChain(ChainBase):
|
||||
msg_text = f"{msg_text}\n促销:{torrent.volume_factor}"
|
||||
if torrent.hit_and_run:
|
||||
msg_text = f"{msg_text}\nHit&Run:是"
|
||||
if torrent.labels:
|
||||
msg_text = f"{msg_text}\n标签:{' '.join(torrent.labels)}"
|
||||
if torrent.description:
|
||||
html_re = re.compile(r'<[^>]+>', re.S)
|
||||
description = html_re.sub('', torrent.description)
|
||||
|
||||
@@ -316,34 +316,34 @@ class SearchChain(ChainBase):
|
||||
self.progress.update(value=0,
|
||||
text=f"开始搜索,共 {total_num} 个站点 ...",
|
||||
key=ProgressKey.Search)
|
||||
# 多线程
|
||||
executor = ThreadPoolExecutor(max_workers=len(indexer_sites))
|
||||
all_task = []
|
||||
for site in indexer_sites:
|
||||
if area == "imdbid":
|
||||
# 搜索IMDBID
|
||||
task = executor.submit(self.search_torrents, site=site,
|
||||
keywords=[mediainfo.imdb_id] if mediainfo else None,
|
||||
mtype=mediainfo.type if mediainfo else None,
|
||||
page=page)
|
||||
else:
|
||||
# 搜索标题
|
||||
task = executor.submit(self.search_torrents, site=site,
|
||||
keywords=keywords,
|
||||
mtype=mediainfo.type if mediainfo else None,
|
||||
page=page)
|
||||
all_task.append(task)
|
||||
# 结果集
|
||||
results = []
|
||||
for future in as_completed(all_task):
|
||||
finish_count += 1
|
||||
result = future.result()
|
||||
if result:
|
||||
results.extend(result)
|
||||
logger.info(f"站点搜索进度:{finish_count} / {total_num}")
|
||||
self.progress.update(value=finish_count / total_num * 100,
|
||||
text=f"正在搜索{keywords or ''},已完成 {finish_count} / {total_num} 个站点 ...",
|
||||
key=ProgressKey.Search)
|
||||
# 多线程
|
||||
with ThreadPoolExecutor(max_workers=len(indexer_sites)) as executor:
|
||||
all_task = []
|
||||
for site in indexer_sites:
|
||||
if area == "imdbid":
|
||||
# 搜索IMDBID
|
||||
task = executor.submit(self.search_torrents, site=site,
|
||||
keywords=[mediainfo.imdb_id] if mediainfo else None,
|
||||
mtype=mediainfo.type if mediainfo else None,
|
||||
page=page)
|
||||
else:
|
||||
# 搜索标题
|
||||
task = executor.submit(self.search_torrents, site=site,
|
||||
keywords=keywords,
|
||||
mtype=mediainfo.type if mediainfo else None,
|
||||
page=page)
|
||||
all_task.append(task)
|
||||
for future in as_completed(all_task):
|
||||
finish_count += 1
|
||||
result = future.result()
|
||||
if result:
|
||||
results.extend(result)
|
||||
logger.info(f"站点搜索进度:{finish_count} / {total_num}")
|
||||
self.progress.update(value=finish_count / total_num * 100,
|
||||
text=f"正在搜索{keywords or ''},已完成 {finish_count} / {total_num} 个站点 ...",
|
||||
key=ProgressKey.Search)
|
||||
# 计算耗时
|
||||
end_time = datetime.now()
|
||||
# 更新进度
|
||||
|
||||
@@ -110,30 +110,24 @@ class SiteChain(ChainBase):
|
||||
domain = StringUtils.get_url_domain(site.url)
|
||||
url = f"https://api.{domain}/api/member/profile"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": user_agent,
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Authorization": site.token
|
||||
"x-api-key": site.apikey,
|
||||
}
|
||||
res = RequestUtils(
|
||||
headers=headers,
|
||||
proxies=settings.PROXY if site.proxy else None,
|
||||
timeout=site.timeout or 15
|
||||
).post_res(url=url)
|
||||
if res and res.status_code == 200:
|
||||
user_info = res.json()
|
||||
if user_info and user_info.get("data"):
|
||||
# 更新最后访问时间
|
||||
res = RequestUtils(headers=headers,
|
||||
timeout=site.timeout or 15,
|
||||
proxies=settings.PROXY if site.proxy else None,
|
||||
referer=f"{site.url}index"
|
||||
).post_res(url=f"https://api.{domain}/api/member/updateLastBrowse")
|
||||
if res:
|
||||
return True, "连接成功"
|
||||
else:
|
||||
return True, f"连接成功,但更新状态失败"
|
||||
return False, "鉴权已过期或无效"
|
||||
if res is None:
|
||||
return False, "无法打开网站!"
|
||||
if res.status_code == 200:
|
||||
user_info = res.json() or {}
|
||||
if user_info.get("data"):
|
||||
return True, "连接成功"
|
||||
return False, user_info.get("message", "鉴权已过期或无效")
|
||||
else:
|
||||
return False, f"错误:{res.status_code} {res.reason}"
|
||||
|
||||
@staticmethod
|
||||
def __yema_test(site: Site) -> Tuple[bool, str]:
|
||||
|
||||
@@ -179,9 +179,9 @@ class SubscribeChain(ChainBase):
|
||||
text = f"评分:{mediainfo.vote_average}"
|
||||
# 群发
|
||||
if mediainfo.type == MediaType.TV:
|
||||
link = settings.MP_DOMAIN('#/subscribe-tv?tab=mysub')
|
||||
link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub')
|
||||
else:
|
||||
link = settings.MP_DOMAIN('#/subscribe-movie?tab=mysub')
|
||||
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
|
||||
self.post_message(Notification(mtype=NotificationType.Subscribe,
|
||||
title=f"{mediainfo.title_year} {metainfo.season} 已添加订阅",
|
||||
text=text,
|
||||
@@ -922,9 +922,9 @@ class SubscribeChain(ChainBase):
|
||||
self.subscribeoper.delete(subscribe.id)
|
||||
# 发送通知
|
||||
if mediainfo.type == MediaType.TV:
|
||||
link = settings.MP_DOMAIN('#/subscribe-tv?tab=mysub')
|
||||
link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub')
|
||||
else:
|
||||
link = settings.MP_DOMAIN('#/subscribe-movie?tab=mysub')
|
||||
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
|
||||
self.post_message(Notification(mtype=NotificationType.Subscribe,
|
||||
title=f'{mediainfo.title_year} {meta.season} 已完成{msgstr}',
|
||||
image=mediainfo.get_message_image(),
|
||||
|
||||
@@ -949,6 +949,11 @@ class TransferChain(ChainBase):
|
||||
for file in files:
|
||||
Path(file).unlink()
|
||||
logger.warn(f"文件 {path} 已删除")
|
||||
# 删除thumb图片
|
||||
thumb_file = path.parent / (path.stem + "-thumb.jpg")
|
||||
if thumb_file.exists():
|
||||
thumb_file.unlink()
|
||||
logger.info(f"文件 {thumb_file} 已删除")
|
||||
# 需要删除父目录
|
||||
elif str(path.parent) == str(path.root):
|
||||
# 根目录,不删除
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import copy
|
||||
import importlib
|
||||
import threading
|
||||
import traceback
|
||||
@@ -11,8 +12,7 @@ from app.chain.subscribe import SubscribeChain
|
||||
from app.chain.system import SystemChain
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.core.config import settings
|
||||
from app.core.event import Event as ManagerEvent
|
||||
from app.core.event import eventmanager, EventManager
|
||||
from app.core.event import Event as ManagerEvent, eventmanager, EventManager
|
||||
from app.core.plugin import PluginManager
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.thread import ThreadHelper
|
||||
@@ -194,7 +194,7 @@ class Command(metaclass=Singleton):
|
||||
# 插件事件
|
||||
self.threader.submit(
|
||||
self.pluginmanager.run_plugin_method,
|
||||
class_name, method_name, event
|
||||
class_name, method_name, copy.deepcopy(event)
|
||||
)
|
||||
|
||||
else:
|
||||
@@ -217,7 +217,7 @@ class Command(metaclass=Singleton):
|
||||
if hasattr(class_obj, method_name):
|
||||
self.threader.submit(
|
||||
getattr(class_obj, method_name),
|
||||
event
|
||||
copy.deepcopy(event)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"事件处理出错:{str(e)} - {traceback.format_exc()}")
|
||||
|
||||
@@ -224,14 +224,20 @@ class Settings(BaseSettings):
|
||||
PLUGIN_MARKET: str = "https://github.com/jxxghp/MoviePilot-Plugins,https://github.com/thsrite/MoviePilot-Plugins,https://github.com/honue/MoviePilot-Plugins,https://github.com/InfinityPacer/MoviePilot-Plugins"
|
||||
# Github token,提高请求api限流阈值 ghp_****
|
||||
GITHUB_TOKEN: Optional[str] = None
|
||||
# 指定的仓库Github token,多个仓库使用,分隔,格式:{user1}/{repo1}:ghp_****,{user2}/{repo2}:github_pat_****
|
||||
REPO_GITHUB_TOKEN: Optional[str] = None
|
||||
# Github代理服务器,格式:https://mirror.ghproxy.com/
|
||||
GITHUB_PROXY: Optional[str] = ''
|
||||
# 自动检查和更新站点资源包(站点索引、认证等)
|
||||
AUTO_UPDATE_RESOURCE: bool = True
|
||||
AUTO_UPDATE_RESOURCE: bool = False
|
||||
# 元数据识别缓存过期时间(小时)
|
||||
META_CACHE_EXPIRE: int = 0
|
||||
# 是否启用DOH解析域名
|
||||
DOH_ENABLE: bool = True
|
||||
# 使用 DOH 解析的域名列表
|
||||
DOH_DOMAINS: str = "api.themoviedb.org,api.tmdb.org,webservice.fanart.tv,api.github.com,github.com,raw.githubusercontent.com,api.telegram.org"
|
||||
# DOH 解析服务器列表
|
||||
DOH_RESOLVERS: str = "1.0.0.1,1.1.1.1,9.9.9.9,149.112.112.112"
|
||||
# 搜索多个名称
|
||||
SEARCH_MULTIPLE_NAME: bool = False
|
||||
# 订阅数据共享
|
||||
@@ -358,6 +364,37 @@ class Settings(BaseSettings):
|
||||
}
|
||||
return {}
|
||||
|
||||
def REPO_GITHUB_HEADERS(self, repo: str = None):
|
||||
"""
|
||||
Github指定的仓库请求头
|
||||
:param repo: 指定的仓库名称,格式为 "user/repo"。如果为空,或者没有找到指定仓库请求头,则返回默认的请求头信息
|
||||
:return: Github请求头
|
||||
"""
|
||||
# 如果没有传入指定的仓库名称,或没有配置指定的仓库Token,则返回默认的请求头信息
|
||||
if not repo or not self.REPO_GITHUB_TOKEN:
|
||||
return self.GITHUB_HEADERS
|
||||
headers = {}
|
||||
# 格式:{user1}/{repo1}:ghp_****,{user2}/{repo2}:github_pat_****
|
||||
token_pairs = self.REPO_GITHUB_TOKEN.split(",")
|
||||
for token_pair in token_pairs:
|
||||
try:
|
||||
parts = token_pair.split(":")
|
||||
if len(parts) != 2:
|
||||
print(f"无效的令牌格式: {token_pair}")
|
||||
continue
|
||||
repo_info = parts[0].strip()
|
||||
token = parts[1].strip()
|
||||
if not repo_info or not token:
|
||||
print(f"无效的令牌或仓库信息: {token_pair}")
|
||||
continue
|
||||
headers[repo_info] = {
|
||||
"Authorization": f"Bearer {token}"
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"处理令牌对 '{token_pair}' 时出错: {e}")
|
||||
# 如果传入了指定的仓库名称,则返回该仓库的请求头信息,否则返回默认请求头
|
||||
return headers.get(repo, self.GITHUB_HEADERS)
|
||||
|
||||
@property
|
||||
def DEFAULT_DOWNLOADER(self):
|
||||
"""
|
||||
|
||||
@@ -347,10 +347,10 @@ class MediaInfo:
|
||||
return [], []
|
||||
directors = []
|
||||
actors = []
|
||||
for cast in _credits.get("cast"):
|
||||
for cast in _credits.get("cast") or []:
|
||||
if cast.get("known_for_department") == "Acting":
|
||||
actors.append(cast)
|
||||
for crew in _credits.get("crew"):
|
||||
for crew in _credits.get("crew") or []:
|
||||
if crew.get("job") in ["Director", "Writer", "Editor", "Producer"]:
|
||||
directors.append(crew)
|
||||
return directors, actors
|
||||
|
||||
@@ -71,7 +71,10 @@ class ReleaseGroupsMatcher(metaclass=Singleton):
|
||||
"ultrahd": [],
|
||||
"others": ['B(?:MDru|eyondHD|TN)', 'C(?:fandora|trlhd|MRG)', 'DON', 'EVO', 'FLUX', 'HONE(?:|yG)',
|
||||
'N(?:oGroup|T(?:b|G))', 'PandaMoon', 'SMURF', 'T(?:EPES|aengoo|rollHD )'],
|
||||
"anime": ['ANi', 'HYSUB', 'KTXP', 'LoliHouse', 'MCE', 'Nekomoe kissaten', '(?:Lilith|NC)-Raws', '织梦字幕组']
|
||||
"anime": ['ANi', 'HYSUB', 'KTXP', 'LoliHouse', 'MCE', 'Nekomoe kissaten', 'SweetSub', 'MingY',
|
||||
'(?:Lilith|NC)-Raws', '织梦字幕组', '枫叶字幕组', '猎户手抄部', '喵萌奶茶屋', '漫猫字幕社',
|
||||
'霜庭云花Sub', '北宇治字幕组', '氢气烤肉架', '云歌字幕组', '萌樱字幕组','极影字幕社','悠哈璃羽字幕社',
|
||||
'❀拨雪寻春❀', '沸羊羊(?:制作|字幕组)', '(?:桜|樱)都字幕组',]
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import concurrent
|
||||
import concurrent.futures
|
||||
import importlib.util
|
||||
import inspect
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union
|
||||
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
@@ -20,6 +22,7 @@ from app.helper.plugin import PluginHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.log import logger
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.utils.crypto import RSAUtils
|
||||
from app.utils.object import ObjectUtils
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
@@ -158,11 +161,12 @@ class PluginManager(metaclass=Singleton):
|
||||
if pid and plugin_id != pid:
|
||||
continue
|
||||
try:
|
||||
# 如果插件具有认证级别且当前认证级别不足,则不进行实例化
|
||||
if hasattr(plugin, "auth_level"):
|
||||
plugin.auth_level = plugin.auth_level
|
||||
if self.siteshelper.auth_level < plugin.auth_level:
|
||||
continue
|
||||
# 判断插件是否满足认证要求,如不满足则不进行实例化
|
||||
if not self.__set_and_check_auth_level(plugin=plugin):
|
||||
# 如果是插件热更新实例,这里则进行替换
|
||||
if plugin_id in self._plugins:
|
||||
self._plugins[plugin_id] = plugin
|
||||
continue
|
||||
# 存储Class
|
||||
self._plugins[plugin_id] = plugin
|
||||
# 未安装的不加载
|
||||
@@ -220,8 +224,6 @@ class PluginManager(metaclass=Singleton):
|
||||
# 清空指定插件
|
||||
if pid in self._running_plugins:
|
||||
self._running_plugins.pop(pid)
|
||||
if pid in self._plugins:
|
||||
self._plugins.pop(pid)
|
||||
else:
|
||||
# 清空
|
||||
self._plugins = {}
|
||||
@@ -602,11 +604,12 @@ class PluginManager(metaclass=Singleton):
|
||||
if plugin_obj and hasattr(plugin_obj, "get_page"):
|
||||
if ObjectUtils.check_method(plugin_obj.get_page):
|
||||
plugin.has_page = True
|
||||
# 公钥
|
||||
if plugin_info.get("key"):
|
||||
plugin.plugin_public_key = plugin_info.get("key")
|
||||
# 权限
|
||||
if plugin_info.get("level"):
|
||||
plugin.auth_level = plugin_info.get("level")
|
||||
if self.siteshelper.auth_level < plugin.auth_level:
|
||||
continue
|
||||
if not self.__set_and_check_auth_level(plugin=plugin, source=plugin_info):
|
||||
continue
|
||||
# 名称
|
||||
if plugin_info.get("name"):
|
||||
plugin.plugin_name = plugin_info.get("name")
|
||||
@@ -709,11 +712,12 @@ class PluginManager(metaclass=Singleton):
|
||||
plugin.has_page = True
|
||||
else:
|
||||
plugin.has_page = False
|
||||
# 公钥
|
||||
if hasattr(plugin_class, "plugin_public_key"):
|
||||
plugin.plugin_public_key = plugin_class.plugin_public_key
|
||||
# 权限
|
||||
if hasattr(plugin_class, "auth_level"):
|
||||
plugin.auth_level = plugin_class.auth_level
|
||||
if self.siteshelper.auth_level < plugin.auth_level:
|
||||
continue
|
||||
if not self.__set_and_check_auth_level(plugin=plugin, source=plugin_class):
|
||||
continue
|
||||
# 名称
|
||||
if hasattr(plugin_class, "plugin_name"):
|
||||
plugin.plugin_name = plugin_class.plugin_name
|
||||
@@ -748,10 +752,70 @@ class PluginManager(metaclass=Singleton):
|
||||
@staticmethod
|
||||
def is_plugin_exists(pid: str) -> bool:
|
||||
"""
|
||||
判断插件是否在本地文件系统存在
|
||||
判断插件是否在本地包中存在
|
||||
:param pid: 插件ID
|
||||
"""
|
||||
if not pid:
|
||||
return False
|
||||
plugin_dir = settings.ROOT_PATH / "app" / "plugins" / pid.lower()
|
||||
return plugin_dir.exists()
|
||||
try:
|
||||
# 构建包名
|
||||
package_name = f"app.plugins.{pid.lower()}"
|
||||
# 检查包是否存在
|
||||
package_exists = importlib.util.find_spec(package_name) is not None
|
||||
logger.debug(f"{pid} exists: {package_exists}")
|
||||
return package_exists
|
||||
except Exception as e:
|
||||
logger.debug(f"获取插件是否在本地包中存在失败,{e}")
|
||||
return False
|
||||
|
||||
def __set_and_check_auth_level(self, plugin: Union[schemas.Plugin, Type[Any]],
|
||||
source: Optional[Union[dict, Type[Any]]] = None) -> bool:
|
||||
"""
|
||||
设置并检查插件的认证级别
|
||||
:param plugin: 插件对象或包含 auth_level 属性的对象
|
||||
:param source: 可选的字典对象或类对象,可能包含 "level" 或 "auth_level" 键
|
||||
:return: 如果插件的认证级别有效且当前环境的认证级别满足要求,返回 True,否则返回 False
|
||||
"""
|
||||
# 检查并赋值 source 中的 level 或 auth_level
|
||||
if source:
|
||||
if isinstance(source, dict) and "level" in source:
|
||||
plugin.auth_level = source.get("level")
|
||||
elif hasattr(source, "auth_level"):
|
||||
plugin.auth_level = source.auth_level
|
||||
# 如果 source 为空且 plugin 本身没有 auth_level,直接返回 True
|
||||
elif not hasattr(plugin, "auth_level"):
|
||||
return True
|
||||
|
||||
# auth_level 级别说明
|
||||
# 1 - 所有用户可见
|
||||
# 2 - 站点认证用户可见
|
||||
# 3 - 站点&密钥认证可见
|
||||
# 99 - 站点&特殊密钥认证可见
|
||||
# 如果当前站点认证级别大于 1 且插件级别为 99,并存在插件公钥,说明为特殊密钥认证,通过密钥匹配进行认证
|
||||
if self.siteshelper.auth_level > 1 and plugin.auth_level == 99 and hasattr(plugin, "plugin_public_key"):
|
||||
plugin_id = plugin.id if isinstance(plugin, schemas.Plugin) else plugin.__name__
|
||||
public_key = plugin.plugin_public_key
|
||||
if public_key:
|
||||
private_key = PluginManager.__get_plugin_private_key(plugin_id)
|
||||
verify = RSAUtils.verify_rsa_keys(public_key=public_key, private_key=private_key)
|
||||
return verify
|
||||
# 如果当前站点认证级别小于插件级别,则返回 False
|
||||
if self.siteshelper.auth_level < plugin.auth_level:
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def __get_plugin_private_key(plugin_id: str) -> Optional[str]:
|
||||
"""
|
||||
根据插件标识获取对应的私钥
|
||||
:param plugin_id: 插件标识
|
||||
:return: 对应的插件私钥,如果未找到则返回 None
|
||||
"""
|
||||
try:
|
||||
# 将插件标识转换为大写并构建环境变量名称
|
||||
env_var_name = f"PLUGIN_{plugin_id.upper()}_PRIVATE_KEY"
|
||||
private_key = os.environ.get(env_var_name)
|
||||
return private_key
|
||||
except Exception as e:
|
||||
logger.debug(f"获取插件 {plugin_id} 的私钥时发生错误:{e}")
|
||||
return None
|
||||
|
||||
@@ -139,3 +139,15 @@ class DownloadHistoryOper(DbOper):
|
||||
return DownloadHistory.list_by_type(db=self._db,
|
||||
mtype=mtype,
|
||||
days=days)
|
||||
|
||||
def delete_history(self, historyid):
|
||||
"""
|
||||
删除下载记录
|
||||
"""
|
||||
DownloadHistory.delete(self._db, historyid)
|
||||
|
||||
def delete_downloadfile(self, downloadfileid):
|
||||
"""
|
||||
删除下载文件记录
|
||||
"""
|
||||
DownloadFiles.delete(self._db, downloadfileid)
|
||||
|
||||
@@ -57,6 +57,7 @@ class TransferHistory(Base):
|
||||
).offset((page - 1) * count).limit(count).all()
|
||||
else:
|
||||
result = db.query(TransferHistory).filter(or_(
|
||||
TransferHistory.title.like(f'%{title}%'),
|
||||
TransferHistory.src.like(f'%{title}%'),
|
||||
TransferHistory.dest.like(f'%{title}%'),
|
||||
)).order_by(
|
||||
@@ -128,6 +129,7 @@ class TransferHistory(Base):
|
||||
return db.query(func.count(TransferHistory.id)).filter(TransferHistory.status == status).first()[0]
|
||||
else:
|
||||
return db.query(func.count(TransferHistory.id)).filter(or_(
|
||||
TransferHistory.title.like(f'%{title}%'),
|
||||
TransferHistory.src.like(f'%{title}%'),
|
||||
TransferHistory.dest.like(f'%{title}%')
|
||||
)).first()[0]
|
||||
|
||||
@@ -15,16 +15,6 @@ from typing import Dict, Optional
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
|
||||
# 定义一个全局集合来存储注册的主机
|
||||
_registered_hosts = {
|
||||
'api.themoviedb.org',
|
||||
'api.tmdb.org',
|
||||
'webservice.fanart.tv',
|
||||
'api.github.com',
|
||||
'github.com',
|
||||
'raw.githubusercontent.com',
|
||||
'api.telegram.org'
|
||||
}
|
||||
|
||||
# 定义一个全局线程池执行器
|
||||
_executor = concurrent.futures.ThreadPoolExecutor()
|
||||
@@ -32,21 +22,13 @@ _executor = concurrent.futures.ThreadPoolExecutor()
|
||||
# 定义默认的DoH配置
|
||||
_doh_timeout = 5
|
||||
_doh_cache: Dict[str, str] = {}
|
||||
_doh_resolvers = [
|
||||
# https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https
|
||||
"1.0.0.1",
|
||||
"1.1.1.1",
|
||||
# https://support.quad9.net/hc/en-us
|
||||
"9.9.9.9",
|
||||
"149.112.112.112"
|
||||
]
|
||||
|
||||
|
||||
def _patched_getaddrinfo(host, *args, **kwargs):
|
||||
"""
|
||||
socket.getaddrinfo的补丁版本。
|
||||
"""
|
||||
if host not in _registered_hosts:
|
||||
if host not in settings.DOH_DOMAINS.split(","):
|
||||
return _orig_getaddrinfo(host, *args, **kwargs)
|
||||
|
||||
# 检查主机是否已解析
|
||||
@@ -57,7 +39,7 @@ def _patched_getaddrinfo(host, *args, **kwargs):
|
||||
|
||||
# 使用DoH解析主机
|
||||
futures = []
|
||||
for resolver in _doh_resolvers:
|
||||
for resolver in settings.DOH_RESOLVERS.split(","):
|
||||
futures.append(_executor.submit(_doh_query, resolver, host))
|
||||
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
|
||||
@@ -51,7 +51,8 @@ class PluginHelper(metaclass=Singleton):
|
||||
if not user or not repo:
|
||||
return {}
|
||||
raw_url = self._base_url % (user, repo)
|
||||
res = RequestUtils(proxies=self.proxies, headers=settings.GITHUB_HEADERS,
|
||||
res = RequestUtils(proxies=self.proxies,
|
||||
headers=settings.REPO_GITHUB_HEADERS(repo=f"{user}/{repo}"),
|
||||
timeout=10).get_res(f"{raw_url}package.json")
|
||||
if res:
|
||||
try:
|
||||
@@ -137,12 +138,16 @@ class PluginHelper(metaclass=Singleton):
|
||||
if not user or not repo:
|
||||
return False, "不支持的插件仓库地址格式"
|
||||
|
||||
user_repo = f"{user}/{repo}"
|
||||
|
||||
def __get_filelist(_p: str) -> Tuple[Optional[list], Optional[str]]:
|
||||
"""
|
||||
获取插件的文件列表
|
||||
"""
|
||||
file_api = f"https://api.github.com/repos/{user}/{repo}/contents/plugins/{_p}"
|
||||
r = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS, timeout=30).get_res(file_api)
|
||||
file_api = f"https://api.github.com/repos/{user_repo}/contents/plugins/{_p}"
|
||||
r = RequestUtils(proxies=settings.PROXY,
|
||||
headers=settings.REPO_GITHUB_HEADERS(repo=user_repo),
|
||||
timeout=30).get_res(file_api)
|
||||
if r is None:
|
||||
return None, "连接仓库失败"
|
||||
elif r.status_code != 200:
|
||||
@@ -164,7 +169,8 @@ class PluginHelper(metaclass=Singleton):
|
||||
download_url = f"{settings.GITHUB_PROXY}{item.get('download_url')}"
|
||||
# 下载插件文件
|
||||
res = RequestUtils(proxies=self.proxies,
|
||||
headers=settings.GITHUB_HEADERS, timeout=60).get_res(download_url)
|
||||
headers=settings.REPO_GITHUB_HEADERS(repo=user_repo),
|
||||
timeout=60).get_res(download_url)
|
||||
if not res:
|
||||
return False, f"文件 {item.get('name')} 下载失败!"
|
||||
elif res.status_code != 200:
|
||||
|
||||
10
app/main.py
10
app/main.py
@@ -20,12 +20,20 @@ if SystemUtils.is_frozen():
|
||||
|
||||
from app.core.config import settings, global_vars
|
||||
from app.core.module import ModuleManager
|
||||
|
||||
# SitesHelper涉及资源包拉取,提前引入并容错提示
|
||||
try:
|
||||
from app.helper.sites import SitesHelper
|
||||
except ImportError as e:
|
||||
error_message = f"错误: {str(e)}\n站点认证及索引相关资源导入失败,请尝试重建容器或手动拉取资源"
|
||||
print(error_message, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
from app.core.plugin import PluginManager
|
||||
from app.db.init import init_db, update_db, init_super_user
|
||||
from app.helper.thread import ThreadHelper
|
||||
from app.helper.display import DisplayHelper
|
||||
from app.helper.resource import ResourceHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.scheduler import Scheduler
|
||||
from app.command import Command, CommandChian
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.modules.douban.apiv2 import DoubanApi
|
||||
from app.modules.douban.douban_cache import DoubanCache
|
||||
from app.modules.douban.scraper import DoubanScraper
|
||||
from app.schemas import MediaPerson
|
||||
from app.schemas.exception import APIRateLimitException
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.common import retry
|
||||
from app.utils.http import RequestUtils
|
||||
@@ -147,11 +148,12 @@ class DoubanModule(_ModuleBase):
|
||||
|
||||
return None
|
||||
|
||||
def douban_info(self, doubanid: str, mtype: MediaType = None) -> Optional[dict]:
|
||||
def douban_info(self, doubanid: str, mtype: MediaType = None, raise_exception: bool = True) -> Optional[dict]:
|
||||
"""
|
||||
获取豆瓣信息
|
||||
:param doubanid: 豆瓣ID
|
||||
:param mtype: 媒体类型
|
||||
:param raise_exception: 触发速率限制时是否抛出异常
|
||||
:return: 豆瓣信息
|
||||
"""
|
||||
"""
|
||||
@@ -427,7 +429,10 @@ class DoubanModule(_ModuleBase):
|
||||
info = self.doubanapi.tv_detail(doubanid)
|
||||
if info:
|
||||
if "subject_ip_rate_limit" in info.get("msg", ""):
|
||||
logger.warn(f"触发豆瓣IP速率限制,错误信息:{info} ...")
|
||||
msg = f"触发豆瓣IP速率限制,错误信息:{info} ..."
|
||||
logger.warn(msg)
|
||||
if raise_exception:
|
||||
raise APIRateLimitException(msg)
|
||||
return None
|
||||
celebrities = self.doubanapi.tv_celebrities(doubanid)
|
||||
if celebrities:
|
||||
@@ -442,7 +447,10 @@ class DoubanModule(_ModuleBase):
|
||||
info = self.doubanapi.movie_detail(doubanid)
|
||||
if info:
|
||||
if "subject_ip_rate_limit" in info.get("msg", ""):
|
||||
logger.warn(f"触发豆瓣IP速率限制,错误信息:{info} ...")
|
||||
msg = f"触发豆瓣IP速率限制,错误信息:{info} ..."
|
||||
logger.warn(msg)
|
||||
if raise_exception:
|
||||
raise APIRateLimitException(msg)
|
||||
return None
|
||||
celebrities = self.doubanapi.movie_celebrities(doubanid)
|
||||
if celebrities:
|
||||
@@ -601,7 +609,8 @@ class DoubanModule(_ModuleBase):
|
||||
|
||||
@retry(Exception, 5, 3, 3, logger=logger)
|
||||
def match_doubaninfo(self, name: str, imdbid: str = None,
|
||||
mtype: MediaType = None, year: str = None, season: int = None) -> dict:
|
||||
mtype: MediaType = None, year: str = None, season: int = None,
|
||||
raise_exception: bool = False) -> dict:
|
||||
"""
|
||||
搜索和匹配豆瓣信息
|
||||
:param name: 名称
|
||||
@@ -609,6 +618,7 @@ class DoubanModule(_ModuleBase):
|
||||
:param mtype: 类型
|
||||
:param year: 年份
|
||||
:param season: 季号
|
||||
:param raise_exception: 触发速率限制时是否抛出异常
|
||||
"""
|
||||
if imdbid:
|
||||
# 优先使用IMDBID查询
|
||||
@@ -624,13 +634,19 @@ class DoubanModule(_ModuleBase):
|
||||
# 搜索
|
||||
logger.info(f"开始使用名称 {name} 匹配豆瓣信息 ...")
|
||||
result = self.doubanapi.search(f"{name} {year or ''}".strip())
|
||||
if not result or not result.get("items"):
|
||||
if not result:
|
||||
logger.warn(f"未找到 {name} 的豆瓣信息")
|
||||
return {}
|
||||
# 触发rate limit
|
||||
if "search_access_rate_limit" in result.values():
|
||||
logger.warn(f"触发豆瓣API速率限制 错误信息 {result} ...")
|
||||
raise Exception("触发豆瓣API速率限制")
|
||||
msg = f"触发豆瓣API速率限制,错误信息:{result} ..."
|
||||
logger.warn(msg)
|
||||
if raise_exception:
|
||||
raise APIRateLimitException(msg)
|
||||
return {}
|
||||
if not result.get("items"):
|
||||
logger.warn(f"未找到 {name} 的豆瓣信息")
|
||||
return {}
|
||||
for item_obj in result.get("items"):
|
||||
type_name = item_obj.get("type_name")
|
||||
if type_name not in [MediaType.TV.value, MediaType.MOVIE.value]:
|
||||
|
||||
@@ -219,12 +219,13 @@ class FileTransferModule(_ModuleBase):
|
||||
"""
|
||||
# 字幕正则式
|
||||
_zhcn_sub_re = r"([.\[(](((zh[-_])?(cn|ch[si]|sg|sc))|zho?" \
|
||||
r"|chinese|(cn|ch[si]|sg|zho?|eng)[-_&](cn|ch[si]|sg|zho?|eng)" \
|
||||
r"|chinese|(cn|ch[si]|sg|zho?|eng)[-_&]?(cn|ch[si]|sg|zho?|eng)" \
|
||||
r"|简[体中]?)[.\])])" \
|
||||
r"|([\u4e00-\u9fa5]{0,3}[中双][\u4e00-\u9fa5]{0,2}[字文语][\u4e00-\u9fa5]{0,3})" \
|
||||
r"|简体|简中|JPSC" \
|
||||
r"|(?<![a-z0-9])gb(?![a-z0-9])"
|
||||
_zhtw_sub_re = r"([.\[(](((zh[-_])?(hk|tw|cht|tc))" \
|
||||
r"|(cht|eng)[-_&]?(cht|eng)" \
|
||||
r"|繁[体中]?)[.\])])" \
|
||||
r"|繁体中[文字]|中[文字]繁体|繁体|JPTC" \
|
||||
r"|(?<![a-z0-9])big5(?![a-z0-9])"
|
||||
@@ -690,6 +691,10 @@ class FileTransferModule(_ModuleBase):
|
||||
"doubanid": mediainfo.douban_id,
|
||||
# 季号
|
||||
"season": meta.season_seq,
|
||||
# 季年份根据season值获取
|
||||
"season_year": mediainfo.season_years.get(
|
||||
int(meta.season_seq),
|
||||
None) if (mediainfo.season_years and meta.season_seq) else None,
|
||||
# 集号
|
||||
"episode": meta.episode_seqs,
|
||||
# 季集 SxxExx
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.db.sitestatistic_oper import SiteStatisticOper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.modules.indexer.haidan import HaiDanSpider
|
||||
from app.modules.indexer.mtorrent import MTorrentSpider
|
||||
from app.modules.indexer.spider import TorrentSpider
|
||||
from app.modules.indexer.tnode import TNodeSpider
|
||||
@@ -118,6 +119,11 @@ class IndexerModule(_ModuleBase):
|
||||
mtype=mtype,
|
||||
page=page
|
||||
)
|
||||
elif site.get('parser') == "Haidan":
|
||||
error_flag, result = HaiDanSpider(site).search(
|
||||
keyword=search_word,
|
||||
mtype=mtype
|
||||
)
|
||||
else:
|
||||
error_flag, result = self.__spider_search(
|
||||
search_word=search_word,
|
||||
|
||||
167
app/modules/indexer/haidan.py
Normal file
167
app/modules/indexer/haidan.py
Normal file
@@ -0,0 +1,167 @@
|
||||
import urllib.parse
|
||||
from typing import Tuple, List
|
||||
|
||||
from ruamel.yaml import CommentedMap
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.log import logger
|
||||
from app.schemas import MediaType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
class HaiDanSpider:
|
||||
"""
|
||||
haidan.video API
|
||||
"""
|
||||
_indexerid = None
|
||||
_domain = None
|
||||
_url = None
|
||||
_name = ""
|
||||
_proxy = None
|
||||
_cookie = None
|
||||
_ua = None
|
||||
_size = 100
|
||||
_searchurl = "%storrents.php"
|
||||
_detailurl = "%sdetails.php?group_id=%s&torrent_id=%s"
|
||||
_timeout = 15
|
||||
|
||||
# 电影分类
|
||||
_movie_category = ['401', '404', '405']
|
||||
_tv_category = ['402', '403', '404', '405']
|
||||
|
||||
# 足销状态 1-普通,2-免费,3-2X,4-2X免费,5-50%,6-2X50%,7-30%
|
||||
_dl_state = {
|
||||
"1": 1,
|
||||
"2": 0,
|
||||
"3": 1,
|
||||
"4": 0,
|
||||
"5": 0.5,
|
||||
"6": 0.5,
|
||||
"7": 0.3
|
||||
}
|
||||
_up_state = {
|
||||
"1": 1,
|
||||
"2": 1,
|
||||
"3": 2,
|
||||
"4": 2,
|
||||
"5": 1,
|
||||
"6": 2,
|
||||
"7": 1
|
||||
}
|
||||
|
||||
def __init__(self, indexer: CommentedMap):
|
||||
self.systemconfig = SystemConfigOper()
|
||||
if indexer:
|
||||
self._indexerid = indexer.get('id')
|
||||
self._url = indexer.get('domain')
|
||||
self._domain = StringUtils.get_url_domain(self._url)
|
||||
self._searchurl = self._searchurl % self._url
|
||||
self._name = indexer.get('name')
|
||||
if indexer.get('proxy'):
|
||||
self._proxy = settings.PROXY
|
||||
self._cookie = indexer.get('cookie')
|
||||
self._ua = indexer.get('ua')
|
||||
self._timeout = indexer.get('timeout') or 15
|
||||
|
||||
def search(self, keyword: str, mtype: MediaType = None) -> Tuple[bool, List[dict]]:
|
||||
"""
|
||||
搜索
|
||||
"""
|
||||
|
||||
def __dict_to_query(_params: dict):
|
||||
"""
|
||||
将数组转换为逗号分隔的字符串
|
||||
"""
|
||||
for key, value in _params.items():
|
||||
if isinstance(value, list):
|
||||
_params[key] = ','.join(map(str, value))
|
||||
return urllib.parse.urlencode(params)
|
||||
|
||||
# 检查cookie
|
||||
if not self._cookie:
|
||||
return True, []
|
||||
|
||||
if not mtype:
|
||||
categories = []
|
||||
elif mtype == MediaType.TV:
|
||||
categories = self._tv_category
|
||||
else:
|
||||
categories = self._movie_category
|
||||
|
||||
# 搜索类型
|
||||
if keyword.startswith('tt'):
|
||||
search_area = '4'
|
||||
else:
|
||||
search_area = '0'
|
||||
|
||||
params = {
|
||||
"isapi": "1",
|
||||
"search_area": search_area, # 0-标题 1-简介(较慢)3-发种用户名 4-IMDb
|
||||
"search": keyword,
|
||||
"search_mode": "0", # 0-与 1-或 2-精准
|
||||
"cat": categories
|
||||
}
|
||||
res = RequestUtils(
|
||||
cookies=self._cookie,
|
||||
ua=self._ua,
|
||||
proxies=self._proxy,
|
||||
timeout=self._timeout
|
||||
).get_res(url=f"{self._searchurl}?{__dict_to_query(params)}")
|
||||
torrents = []
|
||||
if res and res.status_code == 200:
|
||||
result = res.json()
|
||||
code = result.get('code')
|
||||
if code != 0:
|
||||
logger.warn(f"{self._name} 搜索失败:{result.get('msg')}")
|
||||
return True, []
|
||||
data = result.get('data') or {}
|
||||
for tid, item in data.items():
|
||||
category_value = result.get('category')
|
||||
if category_value in self._tv_category \
|
||||
and category_value not in self._movie_category:
|
||||
category = MediaType.TV.value
|
||||
elif category_value in self._movie_category:
|
||||
category = MediaType.MOVIE.value
|
||||
else:
|
||||
category = MediaType.UNKNOWN.value
|
||||
torrent = {
|
||||
'title': item.get('name'),
|
||||
'description': item.get('small_descr'),
|
||||
'enclosure': item.get('url'),
|
||||
'pubdate': StringUtils.format_timestamp(item.get('added')),
|
||||
'size': int(item.get('size') or '0'),
|
||||
'seeders': int(item.get('seeders') or '0'),
|
||||
'peers': int(item.get("leechers") or '0'),
|
||||
'grabs': int(item.get("times_completed") or '0'),
|
||||
'downloadvolumefactor': self.__get_downloadvolumefactor(item.get('sp_state')),
|
||||
'uploadvolumefactor': self.__get_uploadvolumefactor(item.get('sp_state')),
|
||||
'page_url': self._detailurl % (self._url, item.get('group_id'), tid),
|
||||
'labels': [],
|
||||
'category': category
|
||||
}
|
||||
torrents.append(torrent)
|
||||
elif res is not None:
|
||||
logger.warn(f"{self._name} 搜索失败,错误码:{res.status_code}")
|
||||
return True, []
|
||||
else:
|
||||
logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}")
|
||||
return True, []
|
||||
return False, torrents
|
||||
|
||||
def __get_downloadvolumefactor(self, discount: str) -> float:
|
||||
"""
|
||||
获取下载系数
|
||||
"""
|
||||
if discount:
|
||||
return self._dl_state.get(discount, 1)
|
||||
return 1
|
||||
|
||||
def __get_uploadvolumefactor(self, discount: str) -> float:
|
||||
"""
|
||||
获取上传系数
|
||||
"""
|
||||
if discount:
|
||||
return self._up_state.get(discount, 1)
|
||||
return 1
|
||||
@@ -193,7 +193,6 @@ class MTorrentSpider:
|
||||
'id': torrent_id
|
||||
},
|
||||
'header': {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': f'{self._ua}',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'x-api-key': self._apikey
|
||||
|
||||
@@ -265,25 +265,59 @@ class Plex:
|
||||
season_episodes[episode.seasonNumber].append(episode.index)
|
||||
return videos.key, season_episodes
|
||||
|
||||
def get_remote_image_by_id(self, item_id: str, image_type: str) -> Optional[str]:
|
||||
def get_remote_image_by_id(self, item_id: str, image_type: str, depth: int = 0) -> Optional[str]:
|
||||
"""
|
||||
根据ItemId从Plex查询图片地址
|
||||
:param item_id: 在Emby中的ID
|
||||
:param item_id: 在Plex中的ID
|
||||
:param image_type: 图片的类型,Poster或者Backdrop等
|
||||
:param depth: 当前递归深度,默认为0
|
||||
:return: 图片对应在TMDB中的URL
|
||||
"""
|
||||
if not self._plex:
|
||||
if not self._plex or depth > 2 or not item_id:
|
||||
return None
|
||||
try:
|
||||
if image_type == "Poster":
|
||||
images = self._plex.fetchItems('/library/metadata/%s/posters' % item_id,
|
||||
cls=media.Poster)
|
||||
image_url = None
|
||||
ekey = f"/library/metadata/{item_id}"
|
||||
item = self._plex.fetchItem(ekey=ekey)
|
||||
if not item:
|
||||
return None
|
||||
# 如果配置了外网播放地址以及Token,则默认从Plex媒体服务器获取图片,否则返回有外网地址的图片资源
|
||||
if settings.PLEX_PLAY_HOST and settings.PLEX_TOKEN:
|
||||
query = {"X-Plex-Token": settings.PLEX_TOKEN}
|
||||
if image_type == "Poster":
|
||||
if item.thumb:
|
||||
image_url = RequestUtils.combine_url(host=settings.PLEX_PLAY_HOST, path=item.thumb, query=query)
|
||||
else:
|
||||
# 默认使用art也就是Backdrop进行处理
|
||||
if item.art:
|
||||
image_url = RequestUtils.combine_url(host=settings.PLEX_PLAY_HOST, path=item.art, query=query)
|
||||
# 这里对episode进行特殊处理,实际上episode的Backdrop是Poster
|
||||
# 也有个别情况,比如机智的凡人小子episode就是Poster,因此这里把episode的优先级降低,默认还是取art
|
||||
if not image_url and item.TYPE == "episode" and item.thumb:
|
||||
image_url = RequestUtils.combine_url(host=settings.PLEX_PLAY_HOST, path=item.thumb, query=query)
|
||||
else:
|
||||
images = self._plex.fetchItems('/library/metadata/%s/arts' % item_id,
|
||||
cls=media.Art)
|
||||
for image in images:
|
||||
if hasattr(image, 'key') and image.key.startswith('http'):
|
||||
return image.key
|
||||
if image_type == "Poster":
|
||||
images = self._plex.fetchItems(ekey=f"{ekey}/posters",
|
||||
cls=media.Poster)
|
||||
else:
|
||||
# 默认使用art也就是Backdrop进行处理
|
||||
images = self._plex.fetchItems(ekey=f"{ekey}/arts",
|
||||
cls=media.Art)
|
||||
# 这里对episode进行特殊处理,实际上episode的Backdrop是Poster
|
||||
# 也有个别情况,比如机智的凡人小子episode就是Poster,因此这里把episode的优先级降低,默认还是取art
|
||||
if not images and item.TYPE == "episode":
|
||||
images = self._plex.fetchItems(ekey=f"{ekey}/posters",
|
||||
cls=media.Poster)
|
||||
for image in images:
|
||||
if hasattr(image, "key") and image.key.startswith("http"):
|
||||
image_url = image.key
|
||||
break
|
||||
# 如果最后还是找不到,则递归父级进行查找
|
||||
if not image_url and hasattr(item, "parentRatingKey"):
|
||||
return self.get_remote_image_by_id(item_id=item.parentRatingKey,
|
||||
image_type=image_type,
|
||||
depth=depth + 1)
|
||||
return image_url
|
||||
except Exception as e:
|
||||
logger.error(f"获取封面出错:" + str(e))
|
||||
return None
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import re
|
||||
import threading
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
from typing import Optional, List, Dict
|
||||
@@ -87,6 +88,8 @@ class Telegram:
|
||||
|
||||
try:
|
||||
if text:
|
||||
# 对text进行Markdown特殊字符转义
|
||||
text = re.sub(r"([_`])", r"\\\1", text)
|
||||
caption = f"*{title}*\n{text}"
|
||||
else:
|
||||
caption = f"*{title}*"
|
||||
@@ -199,13 +202,15 @@ class Telegram:
|
||||
"""
|
||||
|
||||
if image:
|
||||
req = RequestUtils(proxies=settings.PROXY).get_res(image)
|
||||
if req is None:
|
||||
res = RequestUtils(proxies=settings.PROXY).get_res(image)
|
||||
if res is None:
|
||||
raise Exception("获取图片失败")
|
||||
if req.content:
|
||||
image_file = Path(settings.TEMP_PATH) / Path(image).name
|
||||
image_file.write_bytes(req.content)
|
||||
if res.content:
|
||||
# 使用随机标识构建图片文件的完整路径,并写入图片内容到文件
|
||||
image_file = Path(settings.TEMP_PATH) / str(uuid.uuid4())
|
||||
image_file.write_bytes(res.content)
|
||||
photo = InputFile(image_file)
|
||||
# 发送图片到Telegram
|
||||
ret = self._bot.send_photo(chat_id=userid or self._telegram_chat_id,
|
||||
photo=photo,
|
||||
caption=caption,
|
||||
|
||||
@@ -414,9 +414,9 @@ class TheMovieDbModule(_ModuleBase):
|
||||
:param season: 季
|
||||
"""
|
||||
season_info = self.tmdb.get_tv_season_detail(tmdbid=tmdbid, season=season)
|
||||
if not season_info:
|
||||
if not season_info or not season_info.get("episodes"):
|
||||
return []
|
||||
return [schemas.TmdbEpisode(**episode) for episode in season_info.get("episodes", [])]
|
||||
return [schemas.TmdbEpisode(**episode) for episode in season_info.get("episodes")]
|
||||
|
||||
def scheduler_job(self) -> None:
|
||||
"""
|
||||
|
||||
@@ -168,7 +168,7 @@ class TmdbScraper:
|
||||
if self._force_nfo or not file_path.with_name("season.nfo").exists():
|
||||
self.__gen_tv_season_nfo_file(seasoninfo=seasoninfo,
|
||||
season=meta.begin_season,
|
||||
season_path=file_path)
|
||||
season_path=file_path.parent)
|
||||
# TMDB季图片
|
||||
poster_name, poster_url = self.get_season_poster(seasoninfo, meta.begin_season)
|
||||
if poster_name and poster_url:
|
||||
|
||||
@@ -100,28 +100,57 @@ class WeChat:
|
||||
"""
|
||||
message_url = self._send_msg_url % self.__get_access_token()
|
||||
if text:
|
||||
conent = "%s\n%s" % (title, text.replace("\n\n", "\n"))
|
||||
content = "%s\n%s" % (title, text.replace("\n\n", "\n"))
|
||||
else:
|
||||
conent = title
|
||||
content = title
|
||||
|
||||
if link:
|
||||
conent = f"{conent}\n点击查看:{link}"
|
||||
content = f"{content}\n点击查看:{link}"
|
||||
|
||||
if not userid:
|
||||
userid = "@all"
|
||||
|
||||
req_json = {
|
||||
"touser": userid,
|
||||
"msgtype": "text",
|
||||
"agentid": self._appid,
|
||||
"text": {
|
||||
"content": conent
|
||||
},
|
||||
"safe": 0,
|
||||
"enable_id_trans": 0,
|
||||
"enable_duplicate_check": 0
|
||||
}
|
||||
return self.__post_request(message_url, req_json)
|
||||
# Check if content exceeds 2048 bytes and split if necessary
|
||||
if len(content.encode('utf-8')) > 2048:
|
||||
content_chunks = []
|
||||
current_chunk = ""
|
||||
for line in content.splitlines():
|
||||
if len(current_chunk.encode('utf-8')) + len(line.encode('utf-8')) > 2048:
|
||||
content_chunks.append(current_chunk.strip())
|
||||
current_chunk = ""
|
||||
current_chunk += line + "\n"
|
||||
if current_chunk:
|
||||
content_chunks.append(current_chunk.strip())
|
||||
|
||||
# Send each chunk as a separate message
|
||||
for chunk in content_chunks:
|
||||
req_json = {
|
||||
"touser": userid,
|
||||
"msgtype": "text",
|
||||
"agentid": self._appid,
|
||||
"text": {
|
||||
"content": chunk
|
||||
},
|
||||
"safe": 0,
|
||||
"enable_id_trans": 0,
|
||||
"enable_duplicate_check": 0
|
||||
}
|
||||
result = self.__post_request(message_url, req_json)
|
||||
else:
|
||||
req_json = {
|
||||
"touser": userid,
|
||||
"msgtype": "text",
|
||||
"agentid": self._appid,
|
||||
"text": {
|
||||
"content": content
|
||||
},
|
||||
"safe": 0,
|
||||
"enable_id_trans": 0,
|
||||
"enable_duplicate_check": 0
|
||||
}
|
||||
return self.__post_request(message_url, req_json)
|
||||
|
||||
return result
|
||||
|
||||
def __send_image_message(self, title: str, text: str, image_url: str,
|
||||
userid: str = None, link: str = None) -> Optional[bool]:
|
||||
|
||||
@@ -424,17 +424,17 @@ class Scheduler(metaclass=Singleton):
|
||||
"plugin_name": plugin_name,
|
||||
"running": False,
|
||||
}
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
service["trigger"],
|
||||
id=sid,
|
||||
name=service["name"],
|
||||
**service["kwargs"],
|
||||
kwargs={
|
||||
'job_id': job_id
|
||||
}
|
||||
)
|
||||
logger.info(f"注册插件{plugin_name}服务:{service['name']} - {service['trigger']}")
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
service["trigger"],
|
||||
id=sid,
|
||||
name=service["name"],
|
||||
**service["kwargs"],
|
||||
kwargs={
|
||||
'job_id': job_id
|
||||
}
|
||||
)
|
||||
logger.info(f"注册插件{plugin_name}服务:{service['name']} - {service['trigger']}")
|
||||
except Exception as e:
|
||||
logger.error(f"注册插件{plugin_name}服务失败:{str(e)} - {service}")
|
||||
SchedulerChain().messagehelper.put(title=f"插件 {plugin_name} 服务注册失败",
|
||||
|
||||
@@ -15,3 +15,4 @@ from .tmdb import *
|
||||
from .transfer import *
|
||||
from .file import *
|
||||
from .filetransfer import *
|
||||
from .exception import *
|
||||
|
||||
14
app/schemas/exception.py
Normal file
14
app/schemas/exception.py
Normal file
@@ -0,0 +1,14 @@
|
||||
class ImmediateException(Exception):
|
||||
"""
|
||||
用于立即抛出异常而不重试的特殊异常类。
|
||||
当不希望使用重试机制时,可以抛出此异常。
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class APIRateLimitException(ImmediateException):
|
||||
"""
|
||||
用于表示API速率限制的异常类。
|
||||
当API调用触发速率限制时,可以抛出此异常以立即终止操作并报告错误。
|
||||
"""
|
||||
pass
|
||||
@@ -46,6 +46,8 @@ class Plugin(BaseModel):
|
||||
history: Optional[dict] = {}
|
||||
# 添加时间,值越小表示越靠后发布
|
||||
add_time: Optional[int] = 0
|
||||
# 插件公钥
|
||||
plugin_public_key: Optional[str] = None
|
||||
|
||||
|
||||
class PluginDashboard(Plugin):
|
||||
|
||||
@@ -6,6 +6,8 @@ from typing import Any
|
||||
from Crypto import Random
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
from app.schemas.exception import ImmediateException
|
||||
|
||||
|
||||
def retry(ExceptionToCheck: Any,
|
||||
tries: int = 3, delay: int = 3, backoff: int = 2, logger: Any = None):
|
||||
@@ -23,6 +25,8 @@ def retry(ExceptionToCheck: Any,
|
||||
while mtries > 1:
|
||||
try:
|
||||
return f(*args, **kwargs)
|
||||
except ImmediateException:
|
||||
raise
|
||||
except ExceptionToCheck as e:
|
||||
msg = f"{str(e)}, {mdelay} 秒后重试 ..."
|
||||
if logger:
|
||||
|
||||
91
app/utils/crypto.py
Normal file
91
app/utils/crypto.py
Normal file
@@ -0,0 +1,91 @@
|
||||
import base64
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization, hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa, padding
|
||||
|
||||
|
||||
class RSAUtils:
|
||||
|
||||
@staticmethod
|
||||
def generate_rsa_key_pair() -> (str, str):
|
||||
"""
|
||||
生成RSA密钥对并返回Base64编码的公钥和私钥(DER格式)
|
||||
|
||||
:return: Tuple containing Base64 encoded public key and private key
|
||||
"""
|
||||
# 生成RSA密钥对
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=2048,
|
||||
)
|
||||
|
||||
public_key = private_key.public_key()
|
||||
|
||||
# 导出私钥为DER格式
|
||||
private_key_der = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
)
|
||||
|
||||
# 导出公钥为DER格式
|
||||
public_key_der = public_key.public_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
|
||||
# 将DER格式的密钥编码为Base64
|
||||
private_key_b64 = base64.b64encode(private_key_der).decode('utf-8')
|
||||
public_key_b64 = base64.b64encode(public_key_der).decode('utf-8')
|
||||
|
||||
return private_key_b64, public_key_b64
|
||||
|
||||
@staticmethod
|
||||
def verify_rsa_keys(private_key: str, public_key: str) -> bool:
|
||||
"""
|
||||
使用 RSA 验证公钥和私钥是否匹配
|
||||
|
||||
:param private_key: 私钥字符串 (Base64 编码,无标识符)
|
||||
:param public_key: 公钥字符串 (Base64 编码,无标识符)
|
||||
:return: 如果匹配则返回 True,否则返回 False
|
||||
"""
|
||||
if not private_key or not public_key:
|
||||
return False
|
||||
|
||||
try:
|
||||
# 解码 Base64 编码的公钥和私钥
|
||||
public_key_bytes = base64.b64decode(public_key)
|
||||
private_key_bytes = base64.b64decode(private_key)
|
||||
|
||||
# 加载公钥
|
||||
public_key = serialization.load_der_public_key(public_key_bytes, backend=default_backend())
|
||||
|
||||
# 加载私钥
|
||||
private_key = serialization.load_der_private_key(private_key_bytes, password=None,
|
||||
backend=default_backend())
|
||||
|
||||
# 测试加解密
|
||||
message = b'test'
|
||||
encrypted_message = public_key.encrypt(
|
||||
message,
|
||||
padding.OAEP(
|
||||
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
||||
algorithm=hashes.SHA256(),
|
||||
label=None
|
||||
)
|
||||
)
|
||||
|
||||
decrypted_message = private_key.decrypt(
|
||||
encrypted_message,
|
||||
padding.OAEP(
|
||||
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
||||
algorithm=hashes.SHA256(),
|
||||
label=None
|
||||
)
|
||||
)
|
||||
|
||||
return message == decrypted_message
|
||||
except Exception as e:
|
||||
print(f"RSA 密钥验证失败: {e}")
|
||||
return False
|
||||
@@ -1,5 +1,5 @@
|
||||
from typing import Union, Any, Optional
|
||||
from urllib.parse import urljoin
|
||||
from urllib.parse import urljoin, urlparse, parse_qs, urlencode, urlunparse
|
||||
|
||||
import requests
|
||||
import urllib3
|
||||
@@ -255,3 +255,37 @@ class RequestUtils:
|
||||
return endpoint
|
||||
host = RequestUtils.standardize_base_url(host)
|
||||
return urljoin(host, endpoint) if host else endpoint
|
||||
|
||||
@staticmethod
|
||||
def combine_url(host: str, path: Optional[str] = None, query: Optional[dict] = None) -> Optional[str]:
|
||||
"""
|
||||
使用给定的主机头、路径和查询参数组合生成完整的URL。
|
||||
:param host: str, 主机头,例如 https://example.com
|
||||
:param path: Optional[str], 包含路径和可能已经包含的查询参数的端点,例如 /path/to/resource?current=1
|
||||
:param query: Optional[dict], 可选,额外的查询参数,例如 {"key": "value"}
|
||||
:return: str, 完整的请求URL字符串
|
||||
"""
|
||||
try:
|
||||
# 如果路径为空,则默认为 '/'
|
||||
if path is None:
|
||||
path = '/'
|
||||
host = RequestUtils.standardize_base_url(host)
|
||||
# 使用 urljoin 合并 host 和 path
|
||||
url = urljoin(host, path)
|
||||
# 解析当前 URL 的组成部分
|
||||
url_parts = urlparse(url)
|
||||
# 解析已存在的查询参数,并与额外的查询参数合并
|
||||
query_params = parse_qs(url_parts.query)
|
||||
if query:
|
||||
for key, value in query.items():
|
||||
query_params[key] = value
|
||||
|
||||
# 重新构建查询字符串
|
||||
query_string = urlencode(query_params, doseq=True)
|
||||
# 构建完整的 URL
|
||||
new_url_parts = url_parts._replace(query=query_string)
|
||||
complete_url = urlunparse(new_url_parts)
|
||||
return str(complete_url)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error combining URL: {e}")
|
||||
return None
|
||||
|
||||
@@ -186,7 +186,7 @@ class StringUtils:
|
||||
忽略特殊字符
|
||||
"""
|
||||
# 需要忽略的特殊字符
|
||||
CONVERT_EMPTY_CHARS = r"[、.。,,·::;;!!'’\"“”()()\[\]【】「」\-——\+\|\\_/&#~~]"
|
||||
CONVERT_EMPTY_CHARS = r"[、.。,,·::;;!!'’\"“”()()\[\]【】「」\-—―\+\|\\_/&#~~]"
|
||||
if not text:
|
||||
return text
|
||||
if not isinstance(text, list):
|
||||
|
||||
@@ -13,10 +13,14 @@ SUPERUSER=admin
|
||||
BIG_MEMORY_MODE=false
|
||||
# 是否启用DOH域名解析,启用后对于api.themovie.org等域名通过DOH解析,避免域名DNS被污染
|
||||
DOH_ENABLE=true
|
||||
# 使用 DOH 解析的域名列表,多个域名使用`,`分隔
|
||||
DOH_DOMAINS=api.themoviedb.org,api.tmdb.org,webservice.fanart.tv,api.github.com,github.com,raw.githubusercontent.com,api.telegram.org
|
||||
# DOH 解析服务器列表,多个服务器使用`,`分隔
|
||||
DOH_RESOLVERS=1.0.0.1,1.1.1.1,9.9.9.9,149.112.112.112
|
||||
# 元数据识别缓存过期时间,数字型,单位小时,0为系统默认(大内存模式为7天,滞则为3天),调大该值可减少themoviedb的访问次数
|
||||
META_CACHE_EXPIRE=0
|
||||
# 自动检查和更新站点资源包(索引、认证等)
|
||||
AUTO_UPDATE_RESOURCE=true
|
||||
AUTO_UPDATE_RESOURCE=false
|
||||
# 【*】API密钥,建议更换复杂字符串,有Jellyseerr/Overseerr、媒体服务器Webhook等配置以及部分支持API_TOKEN的API中使用
|
||||
API_TOKEN=moviepilot
|
||||
# 登录页面电影海报,tmdb/bing,tmdb要求能正常连接api.themoviedb.org
|
||||
|
||||
@@ -9,7 +9,6 @@ import json
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
from app.core.config import Settings
|
||||
|
||||
|
||||
@@ -58,5 +58,5 @@ pystray~=0.19.5
|
||||
pyotp~=2.9.0
|
||||
Pinyin2Hanzi~=0.1.1
|
||||
pywebpush~=2.0.0
|
||||
py115~=0.0.4
|
||||
py115j~=0.0.6
|
||||
oss2~=2.18.6
|
||||
@@ -1 +1 @@
|
||||
APP_VERSION = 'v1.9.8'
|
||||
APP_VERSION = 'v1.9.19'
|
||||
|
||||
Reference in New Issue
Block a user