mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-13 23:16:46 +00:00
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f1e55fa1e | ||
|
|
776f629771 | ||
|
|
d9e9edb2c4 | ||
|
|
753c074e59 | ||
|
|
d92c82775a | ||
|
|
215cc09c1f | ||
|
|
7f302c13c7 | ||
|
|
de6a094d10 | ||
|
|
a94e1a8314 | ||
|
|
f5efdd665b | ||
|
|
43e25e8717 | ||
|
|
a8026fefc1 | ||
|
|
fdb36957c9 | ||
|
|
ea433ff807 | ||
|
|
8902fb50d6 | ||
|
|
b6aa013eb3 | ||
|
|
034b43bf70 | ||
|
|
59e9032286 | ||
|
|
52a98efd0a | ||
|
|
90cc91aa7f | ||
|
|
1973a26e83 | ||
|
|
6519ad25ca | ||
|
|
cacfde8166 | ||
|
|
df85873726 | ||
|
|
dfea294cc9 | ||
|
|
d35b855404 | ||
|
|
7a1cbf70e3 | ||
|
|
f260990b86 | ||
|
|
6affbe9b55 | ||
|
|
dbe3a10697 | ||
|
|
3c25306a5d | ||
|
|
17f4d49731 | ||
|
|
e213b5cc64 | ||
|
|
65e5dad44b | ||
|
|
62ad38ea5d | ||
|
|
f98f4c1f77 | ||
|
|
e9f02b58b7 | ||
|
|
05495e481d | ||
|
|
5bb2167b78 | ||
|
|
b4e0ed66cf | ||
|
|
70a0563435 | ||
|
|
955912b832 | ||
|
|
b65ee75b3d | ||
|
|
f642493a38 | ||
|
|
7f1bfb1e07 | ||
|
|
8931e2e016 | ||
|
|
0465fa77c2 | ||
|
|
575d503cb9 | ||
|
|
a4fdbdb9ad |
@@ -1,6 +1,5 @@
|
||||
from typing import List, Any, Dict, Optional
|
||||
|
||||
from app.helper.sites import SitesHelper
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.background import BackgroundTasks
|
||||
@@ -22,6 +21,7 @@ from app.db.models.siteuserdata import SiteUserData
|
||||
from app.db.site_oper import SiteOper
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.db.user_oper import get_current_active_superuser
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas.types import SystemConfigKey, EventType
|
||||
from app.utils.string import StringUtils
|
||||
@@ -422,6 +422,14 @@ def site_mapping(_: User = Depends(get_current_active_superuser)):
|
||||
return schemas.Response(success=False, message=f"获取映射失败:{str(e)}")
|
||||
|
||||
|
||||
@router.get("/supporting", summary="获取支持的站点列表", response_model=dict)
|
||||
def support_sites(_: User = Depends(get_current_active_superuser)):
|
||||
"""
|
||||
获取支持的站点列表
|
||||
"""
|
||||
return SitesHelper().get_indexsites()
|
||||
|
||||
|
||||
@router.get("/{site_id}", summary="站点详情", response_model=schemas.Site)
|
||||
def read_site(
|
||||
site_id: int,
|
||||
|
||||
@@ -560,6 +560,15 @@ def popular_subscribes(
|
||||
return SubscribeHelper().get_shares(name=name, page=page, count=count)
|
||||
|
||||
|
||||
@router.get("/share/statistics", summary="查询订阅分享统计", response_model=List[schemas.SubscribeShareStatistics])
|
||||
def subscribe_share_statistics(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询订阅分享统计
|
||||
返回每个分享人分享的媒体数量以及总的复用人次
|
||||
"""
|
||||
return SubscribeHelper().get_share_statistics()
|
||||
|
||||
|
||||
@router.get("/{subscribe_id}", summary="订阅详情", response_model=schemas.Subscribe)
|
||||
def read_subscribe(
|
||||
subscribe_id: int,
|
||||
|
||||
@@ -95,7 +95,7 @@ def fetch_image(
|
||||
# 请求远程图片
|
||||
referer = "https://movie.douban.com/" if "doubanio.com" in url else None
|
||||
proxies = settings.PROXY if proxy else None
|
||||
response = RequestUtils(ua=settings.USER_AGENT, proxies=proxies, referer=referer,
|
||||
response = RequestUtils(ua=settings.NORMAL_USER_AGENT, proxies=proxies, referer=referer,
|
||||
accept_type="image/avif,image/webp,image/apng,*/*").get_res(url=url)
|
||||
if not response:
|
||||
raise HTTPException(status_code=502, detail="Failed to fetch the image from the remote server")
|
||||
@@ -465,43 +465,68 @@ def nettest(
|
||||
# 记录开始的毫秒数
|
||||
start_time = datetime.now()
|
||||
headers = None
|
||||
if "github" in url or "{GITHUB_PROXY}" in url:
|
||||
# 当前使用的加速代理
|
||||
proxy_name = ""
|
||||
if "github" in url:
|
||||
# 这是github的连通性测试
|
||||
headers = settings.GITHUB_HEADERS
|
||||
if "{GITHUB_PROXY}" in url:
|
||||
url = url.replace(
|
||||
"{GITHUB_PROXY}", UrlUtils.standardize_base_url(settings.GITHUB_PROXY or "")
|
||||
)
|
||||
headers = settings.GITHUB_HEADERS
|
||||
if settings.GITHUB_PROXY:
|
||||
proxy_name = "Github加速代理"
|
||||
if "{PIP_PROXY}" in url:
|
||||
url = url.replace(
|
||||
"{PIP_PROXY}",
|
||||
UrlUtils.standardize_base_url(
|
||||
settings.PIP_PROXY or "https://pypi.org/simple/"
|
||||
),
|
||||
)
|
||||
if settings.PIP_PROXY:
|
||||
proxy_name = "PIP加速代理"
|
||||
url = url.replace("{TMDBAPIKEY}", settings.TMDB_API_KEY)
|
||||
url = url.replace(
|
||||
"{PIP_PROXY}",
|
||||
UrlUtils.standardize_base_url(settings.PIP_PROXY or "https://pypi.org/simple/"),
|
||||
)
|
||||
result = RequestUtils(
|
||||
proxies=settings.PROXY if proxy else None,
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
ua=settings.USER_AGENT,
|
||||
ua=settings.NORMAL_USER_AGENT,
|
||||
).get_res(url)
|
||||
# 计时结束的毫秒数
|
||||
end_time = datetime.now()
|
||||
time = round((end_time - start_time).total_seconds() * 1000)
|
||||
# 计算相关秒数
|
||||
if result is None:
|
||||
return schemas.Response(success=False, message="无法连接", data={"time": time})
|
||||
return schemas.Response(
|
||||
success=False, message=f"{proxy_name}无法连接", data={"time": time}
|
||||
)
|
||||
elif result.status_code == 200:
|
||||
if include and not re.search(r"%s" % include, result.text, re.IGNORECASE):
|
||||
# 通常是被加速代理跳转到其它页面了
|
||||
logger.error(f"{url} 的响应内容不匹配包含规则 {include}")
|
||||
if proxy_name:
|
||||
message = f"{proxy_name}已失效,请检查配置"
|
||||
else:
|
||||
message = f"无效响应,不匹配 {include}"
|
||||
return schemas.Response(
|
||||
success=False,
|
||||
message=f"无效响应,不匹配 {include}",
|
||||
message=message,
|
||||
data={"time": time},
|
||||
)
|
||||
return schemas.Response(success=True, data={"time": time})
|
||||
else:
|
||||
return schemas.Response(
|
||||
success=False, message=f"错误码:{result.status_code}", data={"time": time}
|
||||
)
|
||||
if proxy_name:
|
||||
# 加速代理失败
|
||||
message = f"{proxy_name}已失效,错误码:{result.status_code}"
|
||||
else:
|
||||
message = f"错误码:{result.status_code}"
|
||||
if "github" in url:
|
||||
# 非加速代理访问github
|
||||
if result.status_code == 401:
|
||||
message = "Github Token已失效,请检查配置"
|
||||
elif result.status_code in {403, 429}:
|
||||
message = "触发限流,请配置Github Token"
|
||||
return schemas.Response(success=False, message=message, data={"time": time})
|
||||
|
||||
|
||||
@router.get("/modulelist", summary="查询已加载的模块ID列表", response_model=schemas.Response)
|
||||
|
||||
@@ -38,11 +38,7 @@ def query_name(path: str, filetype: str,
|
||||
return schemas.Response(success=False, message="未识别到新名称")
|
||||
if filetype == "dir":
|
||||
media_path = DirectoryHelper.get_media_root_path(
|
||||
rename_format=(
|
||||
settings.TV_RENAME_FORMAT
|
||||
if mediainfo.type == MediaType.TV
|
||||
else settings.MOVIE_RENAME_FORMAT
|
||||
),
|
||||
rename_format=settings.RENAME_FORMAT(mediainfo.type),
|
||||
rename_path=Path(new_path),
|
||||
)
|
||||
if media_path:
|
||||
|
||||
@@ -432,8 +432,10 @@ class MediaChain(ChainBase):
|
||||
"""
|
||||
if not _fileitem or not _content or not _path:
|
||||
return
|
||||
# 保存文件到临时目录,文件名随机
|
||||
tmp_file = settings.TEMP_PATH / f"{_path.name}.{StringUtils.generate_random_str(10)}"
|
||||
# 保存文件到临时目录
|
||||
tmp_dir = settings.TEMP_PATH / StringUtils.generate_random_str(10)
|
||||
tmp_dir.mkdir(parents=True, exist_ok=True)
|
||||
tmp_file = tmp_dir / _path.name
|
||||
tmp_file.write_bytes(_content)
|
||||
# 获取文件的父目录
|
||||
try:
|
||||
@@ -452,7 +454,7 @@ class MediaChain(ChainBase):
|
||||
"""
|
||||
try:
|
||||
logger.info(f"正在下载图片:{_url} ...")
|
||||
r = RequestUtils(proxies=settings.PROXY, ua=settings.USER_AGENT).get_res(url=_url)
|
||||
r = RequestUtils(proxies=settings.PROXY, ua=settings.NORMAL_USER_AGENT).get_res(url=_url)
|
||||
if r:
|
||||
return r.content
|
||||
else:
|
||||
|
||||
@@ -130,7 +130,7 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
# 请求远程图片
|
||||
referer = "https://movie.douban.com/" if "doubanio.com" in url else None
|
||||
proxies = settings.PROXY if not referer else None
|
||||
response = RequestUtils(ua=settings.USER_AGENT, proxies=proxies, referer=referer).get_res(url=url)
|
||||
response = RequestUtils(ua=settings.NORMAL_USER_AGENT, proxies=proxies, referer=referer).get_res(url=url)
|
||||
if not response:
|
||||
logger.debug(f"Empty response for URL: {url}")
|
||||
return
|
||||
|
||||
@@ -178,8 +178,7 @@ class StorageChain(ChainBase):
|
||||
|
||||
if mtype:
|
||||
# 重命名格式
|
||||
rename_format = settings.TV_RENAME_FORMAT \
|
||||
if mtype == MediaType.TV else settings.MOVIE_RENAME_FORMAT
|
||||
rename_format = settings.RENAME_FORMAT(mtype)
|
||||
media_path = DirectoryHelper.get_media_root_path(
|
||||
rename_format, rename_path=Path(fileitem.path)
|
||||
)
|
||||
|
||||
@@ -451,9 +451,9 @@ class SubscribeChain(ChainBase):
|
||||
self._rlock.release()
|
||||
logger.debug(f"search Lock released at {datetime.now()}")
|
||||
|
||||
# 如果不是大内存模式,进行垃圾回收
|
||||
if not settings.BIG_MEMORY_MODE:
|
||||
gc.collect()
|
||||
# 如果不是大内存模式,进行垃圾回收
|
||||
if not settings.BIG_MEMORY_MODE:
|
||||
gc.collect()
|
||||
|
||||
def update_subscribe_priority(self, subscribe: Subscribe, meta: MetaBase,
|
||||
mediainfo: MediaInfo, downloads: Optional[List[Context]]):
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Union, Optional
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import settings
|
||||
from app.core.plugin import PluginManager
|
||||
from app.log import logger
|
||||
from app.schemas import Notification, MessageChannel
|
||||
from app.utils.http import RequestUtils
|
||||
@@ -136,13 +137,6 @@ class SystemChain(ChainBase):
|
||||
shutil.rmtree(target_path)
|
||||
shutil.copytree(item, target_path)
|
||||
logger.info(f"已恢复插件目录: {item.name}")
|
||||
# 安装依赖
|
||||
requirements_file = target_path / "requirements.txt"
|
||||
if requirements_file.exists():
|
||||
logger.info(f"正在安装插件 {item.name} 的依赖...")
|
||||
success, message = PluginHelper.pip_install_with_fallback(requirements_file)
|
||||
if not success:
|
||||
logger.warn(f"插件 {item.name} 依赖安装失败: {message}")
|
||||
restored_count += 1
|
||||
# 如果是文件
|
||||
elif item.is_file():
|
||||
@@ -155,6 +149,9 @@ class SystemChain(ChainBase):
|
||||
|
||||
logger.info(f"插件恢复完成,共恢复 {restored_count} 个项目")
|
||||
|
||||
# 安装缺少的依赖
|
||||
PluginManager.install_plugin_missing_dependencies()
|
||||
|
||||
# 删除备份目录
|
||||
try:
|
||||
shutil.rmtree(backup_dir)
|
||||
|
||||
@@ -140,6 +140,16 @@ class TorrentsChain(ChainBase):
|
||||
:param stype: 强制指定缓存类型,spider:爬虫缓存,rss:rss缓存
|
||||
:param sites: 强制指定站点ID列表,为空则读取设置的订阅站点
|
||||
"""
|
||||
|
||||
def __is_no_cache_site(_domain: str) -> bool:
|
||||
"""
|
||||
判断站点是否不需要缓存
|
||||
"""
|
||||
for url_key in settings.NO_CACHE_SITE_KEY.split(','):
|
||||
if url_key in _domain:
|
||||
return True
|
||||
return False
|
||||
|
||||
# 刷新类型
|
||||
if not stype:
|
||||
stype = settings.SUBSCRIBE_MODE
|
||||
@@ -169,7 +179,15 @@ class TorrentsChain(ChainBase):
|
||||
domains.append(domain)
|
||||
if stype == "spider":
|
||||
# 刷新首页种子
|
||||
torrents: List[TorrentInfo] = self.browse(domain=domain)
|
||||
torrents: List[TorrentInfo] = []
|
||||
# 读取第0页和第1页
|
||||
for page in range(2):
|
||||
page_torrents = self.browse(domain=domain, page=page)
|
||||
if page_torrents:
|
||||
torrents.extend(page_torrents)
|
||||
else:
|
||||
# 如果某一页没有数据,说明已经到最后一页,停止获取
|
||||
break
|
||||
else:
|
||||
# 刷新RSS种子
|
||||
torrents: List[TorrentInfo] = self.rss(domain=domain)
|
||||
@@ -178,11 +196,16 @@ class TorrentsChain(ChainBase):
|
||||
# 取前N条
|
||||
torrents = torrents[:settings.CONF.refresh]
|
||||
if torrents:
|
||||
# 过滤出没有处理过的种子 - 优化:使用集合查找,避免重复创建字符串列表
|
||||
cached_signatures = {f'{t.torrent_info.title}{t.torrent_info.description}'
|
||||
for t in torrents_cache.get(domain) or []}
|
||||
torrents = [torrent for torrent in torrents
|
||||
if f'{torrent.title}{torrent.description}' not in cached_signatures]
|
||||
if __is_no_cache_site(domain):
|
||||
# 不需要缓存的站点,直接处理
|
||||
logger.info(f'{indexer.get("name")} 有 {len(torrents)} 个种子 (不缓存)')
|
||||
torrents_cache[domain] = []
|
||||
else:
|
||||
# 过滤出没有处理过的种子 - 优化:使用集合查找,避免重复创建字符串列表
|
||||
cached_signatures = {f'{t.torrent_info.title}{t.torrent_info.description}'
|
||||
for t in torrents_cache.get(domain) or []}
|
||||
torrents = [torrent for torrent in torrents
|
||||
if f'{torrent.title}{torrent.description}' not in cached_signatures]
|
||||
if torrents:
|
||||
logger.info(f'{indexer.get("name")} 有 {len(torrents)} 个新种子')
|
||||
else:
|
||||
|
||||
@@ -498,18 +498,41 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
if transferinfo.transfer_type in ["move"]:
|
||||
# 所有成功的业务
|
||||
tasks = self.jobview.success_tasks(task.mediainfo, task.meta.begin_season)
|
||||
# 记录已处理的种子hash
|
||||
processed_hashes = set()
|
||||
storagechain = StorageChain()
|
||||
downloadhistoryoper = DownloadHistoryOper()
|
||||
for t in tasks:
|
||||
# 下载器hash
|
||||
if t.download_hash and t.download_hash not in processed_hashes:
|
||||
processed_hashes.add(t.download_hash)
|
||||
if self.remove_torrents(t.download_hash, downloader=t.downloader):
|
||||
logger.info(f"移动模式删除种子成功:{t.download_hash} ")
|
||||
# 删除残留目录
|
||||
if t.fileitem:
|
||||
storagechain.delete_media_file(t.fileitem, delete_self=False)
|
||||
if not t.download_hash:
|
||||
continue
|
||||
# 获取种子保存目录
|
||||
seed_dir_path = self.__get_torrent_save_path(download_hash=t.download_hash,
|
||||
downloader=t.downloader)
|
||||
if not seed_dir_path:
|
||||
# 如果无法从下载器获取,则尝试从历史记录获取
|
||||
download_history = downloadhistoryoper.get_by_hash(t.download_hash)
|
||||
if download_history and download_history.path:
|
||||
seed_dir_path = download_history.path
|
||||
else:
|
||||
logger.warn(f"无法获取种子 {t.download_hash} 的保存路径")
|
||||
continue
|
||||
|
||||
# 检查种子目录下是否还有有效媒体文件
|
||||
seed_dir_item = storagechain.get_file_item(storage=t.fileitem.storage,
|
||||
path=Path(seed_dir_path))
|
||||
if seed_dir_item and seed_dir_item.type == "dir":
|
||||
remain_files = storagechain.list_files(seed_dir_item, recursion=True)
|
||||
has_media = any(
|
||||
f.extension and f.extension.lower() in [ext.lstrip('.') for ext in self.all_exts]
|
||||
for f in remain_files if f.type == "file"
|
||||
)
|
||||
if not has_media:
|
||||
if self.remove_torrents(t.download_hash, downloader=t.downloader):
|
||||
logger.info(f"移动模式删除种子成功:{t.download_hash} ")
|
||||
# 删除残留目录
|
||||
storagechain.delete_media_file(seed_dir_item, delete_self=False)
|
||||
else:
|
||||
logger.info(
|
||||
f"种子目录 {seed_dir_path} 还有未整理的媒体文件,暂不删除种子和残留目录")
|
||||
# 整理完成且有成功的任务时
|
||||
if self.jobview.is_finished(task):
|
||||
__do_finished()
|
||||
@@ -1433,3 +1456,20 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
season_episode=season_episode,
|
||||
username=username
|
||||
)
|
||||
|
||||
def __get_torrent_save_path(self, download_hash: str, downloader: str) -> Optional[str]:
|
||||
"""
|
||||
从下载器获取种子的保存路径
|
||||
:param download_hash: 种子Hash
|
||||
:param downloader: 下载器名称
|
||||
:return: 种子保存路径,如果获取失败返回None
|
||||
"""
|
||||
try:
|
||||
# 通过下载器获取种子信息
|
||||
torrents = self.list_torrents(hashs=download_hash, downloader=downloader)
|
||||
if not torrents:
|
||||
return None
|
||||
return torrents[0].path
|
||||
except Exception as e:
|
||||
logger.error(f"获取种子 {download_hash} 保存路径失败:{e}")
|
||||
return None
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import threading
|
||||
@@ -11,8 +13,10 @@ from dotenv import set_key
|
||||
from pydantic import BaseModel, BaseSettings, validator, Field
|
||||
|
||||
from app.log import logger, log_settings, LogConfigModel
|
||||
from app.schemas import MediaType
|
||||
from app.utils.system import SystemUtils
|
||||
from app.utils.url import UrlUtils
|
||||
from version import APP_VERSION
|
||||
|
||||
|
||||
class SystemConfModel(BaseModel):
|
||||
@@ -211,6 +215,8 @@ class ConfigModel(BaseModel):
|
||||
SITEDATA_REFRESH_INTERVAL: int = 6
|
||||
# 读取和发送站点消息
|
||||
SITE_MESSAGE: bool = True
|
||||
# 不能缓存站点资源的站点域名,多个使用,分隔
|
||||
NO_CACHE_SITE_KEY: str = "m-team"
|
||||
# 种子标签
|
||||
TORRENT_TAG: str = "MOVIEPILOT"
|
||||
# 下载站点字幕
|
||||
@@ -229,8 +235,6 @@ class ConfigModel(BaseModel):
|
||||
COOKIECLOUD_INTERVAL: Optional[int] = 60 * 24
|
||||
# CookieCloud同步黑名单,多个域名,分割
|
||||
COOKIECLOUD_BLACKLIST: Optional[str] = None
|
||||
# CookieCloud对应的浏览器UA
|
||||
USER_AGENT: str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.1774.57"
|
||||
# 电影重命名格式
|
||||
MOVIE_RENAME_FORMAT: str = "{{title}}{% if year %} ({{year}}){% endif %}" \
|
||||
"/{{title}}{% if year %} ({{year}}){% endif %}{% if part %}-{{part}}{% endif %}{% if videoFormat %} - {{videoFormat}}{% endif %}" \
|
||||
@@ -506,6 +510,20 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
"""
|
||||
return "v2"
|
||||
|
||||
@property
|
||||
def USER_AGENT(self) -> str:
|
||||
"""
|
||||
全局用户代理字符串
|
||||
"""
|
||||
return f"{self.PROJECT_NAME}/{APP_VERSION[1:]} ({platform.system()} {platform.release()}; {SystemUtils.cpu_arch()})"
|
||||
|
||||
@property
|
||||
def NORMAL_USER_AGENT(self) -> str:
|
||||
"""
|
||||
默认浏览器用户代理字符串
|
||||
"""
|
||||
return "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"
|
||||
|
||||
@property
|
||||
def INNER_CONFIG_PATH(self):
|
||||
return self.ROOT_PATH / "config"
|
||||
@@ -602,7 +620,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
if self.GITHUB_TOKEN:
|
||||
return {
|
||||
"Authorization": f"Bearer {self.GITHUB_TOKEN}",
|
||||
"User-Agent": self.USER_AGENT,
|
||||
"User-Agent": self.NORMAL_USER_AGENT,
|
||||
}
|
||||
return {}
|
||||
|
||||
@@ -631,7 +649,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
continue
|
||||
headers[repo_info] = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"User-Agent": self.USER_AGENT,
|
||||
"User-Agent": self.NORMAL_USER_AGENT,
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"处理令牌对 '{token_pair}' 时出错: {e}")
|
||||
@@ -651,6 +669,23 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
return None
|
||||
return UrlUtils.combine_url(host=self.APP_DOMAIN, path=url)
|
||||
|
||||
def RENAME_FORMAT(self, media_type: MediaType):
|
||||
"""
|
||||
获取指定类型的重命名格式
|
||||
|
||||
:param media_type: MediaType.TV 或 MediaType.Movie
|
||||
:return: 重命名格式
|
||||
"""
|
||||
rename_format = (
|
||||
self.TV_RENAME_FORMAT
|
||||
if media_type == MediaType.TV
|
||||
else self.MOVIE_RENAME_FORMAT
|
||||
)
|
||||
# 规范重命名格式
|
||||
rename_format = rename_format.replace("\\", "/")
|
||||
rename_format = re.sub(r'/+', '/', rename_format)
|
||||
return rename_format.strip("/")
|
||||
|
||||
|
||||
# 实例化配置
|
||||
settings = Settings()
|
||||
|
||||
@@ -193,7 +193,7 @@ class MediaInfo:
|
||||
# LOGO
|
||||
logo_path: str = None
|
||||
# 评分
|
||||
vote_average: float = 0.0
|
||||
vote_average: float = None
|
||||
# 描述
|
||||
overview: str = None
|
||||
# 风格ID
|
||||
@@ -237,9 +237,9 @@ class MediaInfo:
|
||||
# 流媒体平台
|
||||
networks: list = field(default_factory=list)
|
||||
# 集数
|
||||
number_of_episodes: int = 0
|
||||
number_of_episodes: int = None
|
||||
# 季数
|
||||
number_of_seasons: int = 0
|
||||
number_of_seasons: int = None
|
||||
# 原产国
|
||||
origin_country: list = field(default_factory=list)
|
||||
# 原名
|
||||
@@ -255,9 +255,9 @@ class MediaInfo:
|
||||
# 标签
|
||||
tagline: str = None
|
||||
# 评价数量
|
||||
vote_count: int = 0
|
||||
vote_count: int = None
|
||||
# 流行度
|
||||
popularity: int = 0
|
||||
popularity: int = None
|
||||
# 时长
|
||||
runtime: int = None
|
||||
# 下一集
|
||||
@@ -474,7 +474,16 @@ class MediaInfo:
|
||||
self.names = info.get('names') or []
|
||||
# 剩余属性赋值
|
||||
for key, value in info.items():
|
||||
if hasattr(self, key) and not getattr(self, key):
|
||||
if not value:
|
||||
continue
|
||||
if not hasattr(self, key):
|
||||
continue
|
||||
current_value = getattr(self, key)
|
||||
if current_value:
|
||||
continue
|
||||
if current_value is None:
|
||||
setattr(self, key, value)
|
||||
elif type(current_value) == type(value):
|
||||
setattr(self, key, value)
|
||||
|
||||
def set_douban_info(self, info: dict):
|
||||
@@ -606,7 +615,16 @@ class MediaInfo:
|
||||
self.production_countries = [{"id": country, "name": country} for country in info.get("countries") or []]
|
||||
# 剩余属性赋值
|
||||
for key, value in info.items():
|
||||
if not value:
|
||||
continue
|
||||
if not hasattr(self, key):
|
||||
continue
|
||||
current_value = getattr(self, key)
|
||||
if current_value:
|
||||
continue
|
||||
if current_value is None:
|
||||
setattr(self, key, value)
|
||||
elif type(current_value) == type(value):
|
||||
setattr(self, key, value)
|
||||
|
||||
def set_bangumi_info(self, info: dict):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
@@ -8,6 +9,8 @@ from app.log import logger
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
JINJA2_VAR_PATTERN = re.compile(r"\{\{.*?\}\}", re.DOTALL)
|
||||
|
||||
|
||||
class DirectoryHelper:
|
||||
"""
|
||||
@@ -120,26 +123,32 @@ class DirectoryHelper:
|
||||
:param rename_path: 重命名后的路径
|
||||
:return: 媒体文件根路径
|
||||
"""
|
||||
if not rename_format:
|
||||
logger.error("重命名格式不能为空")
|
||||
return None
|
||||
# 计算重命名中的文件夹层数
|
||||
rename_list = rename_format.split("/")
|
||||
rename_format_level = len(rename_list) - 1
|
||||
if rename_format_level <= 0:
|
||||
# 无效重命名格式
|
||||
logger.error(f"重命名格式 {rename_format} 不正确")
|
||||
return None
|
||||
# 查找标题参数所在层
|
||||
for level, name in enumerate(rename_list):
|
||||
matchs = JINJA2_VAR_PATTERN.findall(name)
|
||||
if not matchs:
|
||||
continue
|
||||
# 处理特例,有的人重命名的第一层是年份、分辨率
|
||||
if "{{title}}" in name:
|
||||
if any("title" in m for m in matchs):
|
||||
# 找出含标题的这一层作为媒体根目录
|
||||
rename_format_level -= level
|
||||
break
|
||||
else:
|
||||
# 假定第一层目录是媒体根目录
|
||||
logger.warn(f"重命名格式 {rename_format} 缺少 {{{{title}}}}")
|
||||
logger.warn(f"重命名格式 {rename_format} 缺少标题参数")
|
||||
if rename_format_level > len(rename_path.parents):
|
||||
# 通常因为路径以/结尾,被Path规范化删除了
|
||||
logger.error(f"路径 {rename_path} 不匹配重命名格式 {rename_format}")
|
||||
return None
|
||||
if rename_format_level <= 0:
|
||||
# 所有媒体文件都存在一个目录内的特殊需求
|
||||
rename_format_level = 1
|
||||
# 媒体根路径
|
||||
media_root = rename_path.parents[rename_format_level - 1]
|
||||
return media_root
|
||||
|
||||
@@ -10,6 +10,7 @@ import os
|
||||
class DisplayHelper(metaclass=Singleton):
|
||||
|
||||
def __init__(self):
|
||||
self._display = None
|
||||
if not SystemUtils.is_docker():
|
||||
return
|
||||
try:
|
||||
|
||||
@@ -649,10 +649,20 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
"""
|
||||
dependencies = {}
|
||||
try:
|
||||
install_plugins = {
|
||||
plugin_id.lower() # 对应插件的小写目录名
|
||||
for plugin_id in SystemConfigOper().get(
|
||||
SystemConfigKey.UserInstalledPlugins
|
||||
) or []
|
||||
}
|
||||
for plugin_dir in PLUGIN_DIR.iterdir():
|
||||
if plugin_dir.is_dir():
|
||||
requirements_file = plugin_dir / "requirements.txt"
|
||||
if requirements_file.exists():
|
||||
if plugin_dir.name not in install_plugins:
|
||||
# 这个插件不在安装列表中 忽略它的依赖
|
||||
logger.debug(f"忽略插件 {plugin_dir.name} 的依赖")
|
||||
continue
|
||||
# 解析当前插件的 requirements.txt,获取依赖项
|
||||
plugin_deps = self.__parse_requirements(requirements_file)
|
||||
for pkg_name, version_specifiers in plugin_deps.items():
|
||||
|
||||
@@ -29,6 +29,8 @@ class SubscribeHelper(metaclass=WeakSingleton):
|
||||
|
||||
_sub_shares = f"{settings.MP_SERVER_HOST}/subscribe/shares"
|
||||
|
||||
_sub_share_statistic = f"{settings.MP_SERVER_HOST}/subscribe/share/statistics"
|
||||
|
||||
_sub_fork = f"{settings.MP_SERVER_HOST}/subscribe/fork/%s"
|
||||
|
||||
_shares_cache_region = "subscribe_share"
|
||||
@@ -199,7 +201,7 @@ class SubscribeHelper(metaclass=WeakSingleton):
|
||||
else:
|
||||
return False, res.json().get("message")
|
||||
|
||||
@cached(region=_shares_cache_region)
|
||||
@cached(region=_shares_cache_region, maxsize=1, ttl=1800, skip_empty=True)
|
||||
def get_shares(self, name: Optional[str] = None, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
获取订阅分享数据
|
||||
@@ -215,6 +217,18 @@ class SubscribeHelper(metaclass=WeakSingleton):
|
||||
return res.json()
|
||||
return []
|
||||
|
||||
@cached(region=_shares_cache_region, maxsize=1, ttl=1800, skip_empty=True)
|
||||
def get_share_statistics(self) -> List[dict]:
|
||||
"""
|
||||
获取订阅分享统计数据
|
||||
"""
|
||||
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
return []
|
||||
res = RequestUtils(proxies=settings.PROXY, timeout=15).get_res(self._sub_share_statistic)
|
||||
if res and res.status_code == 200:
|
||||
return res.json()
|
||||
return []
|
||||
|
||||
def get_user_uuid(self) -> str:
|
||||
"""
|
||||
获取用户uuid
|
||||
|
||||
@@ -39,6 +39,22 @@ class TorrentHelper(metaclass=WeakSingleton):
|
||||
"""
|
||||
if url.startswith("magnet:"):
|
||||
return None, url, "", [], f"磁力链接"
|
||||
# 构建 torrent 种子文件的存储路径
|
||||
file_path = (Path(settings.TEMP_PATH) / StringUtils.md5_hash(url)).with_suffix(".torrent")
|
||||
if file_path.exists():
|
||||
try:
|
||||
# 获取种子目录和文件清单
|
||||
folder_name, file_list = self.get_torrent_info(file_path)
|
||||
# 无法获取信息,则认为缓存文件无效
|
||||
if not folder_name and not file_list:
|
||||
raise ValueError("无效的缓存种子文件")
|
||||
# 获取种子数据
|
||||
content = file_path.read_bytes()
|
||||
# 成功拿到种子数据
|
||||
return file_path, content, folder_name, file_list, ""
|
||||
except Exception as err:
|
||||
logger.error(f"处理缓存的种子文件 {file_path} 时出错: {err},将重新下载")
|
||||
file_path.unlink(missing_ok=True)
|
||||
# 请求种子文件
|
||||
req = RequestUtils(
|
||||
ua=ua,
|
||||
@@ -105,10 +121,6 @@ class TorrentHelper(metaclass=WeakSingleton):
|
||||
if req.content:
|
||||
# 检查是不是种子文件,如果不是仍然抛出异常
|
||||
try:
|
||||
# 读取种子文件名
|
||||
file_name = self.get_url_filename(req, url)
|
||||
# 种子文件路径
|
||||
file_path = Path(settings.TEMP_PATH) / file_name
|
||||
# 保存到文件
|
||||
file_path.write_bytes(req.content)
|
||||
# 获取种子目录和文件清单
|
||||
@@ -307,7 +319,7 @@ class TorrentHelper(metaclass=WeakSingleton):
|
||||
self._invalid_torrents.append(url)
|
||||
|
||||
@staticmethod
|
||||
def match_torrent(mediainfo: MediaInfo, torrent_meta: MetaInfo, torrent: TorrentInfo) -> bool:
|
||||
def match_torrent(mediainfo: MediaInfo, torrent_meta: MetaBase, torrent: TorrentInfo) -> bool:
|
||||
"""
|
||||
检查种子是否匹配媒体信息
|
||||
:param mediainfo: 需要匹配的媒体信息
|
||||
|
||||
@@ -105,7 +105,7 @@ class WorkflowHelper(metaclass=WeakSingleton):
|
||||
else:
|
||||
return False, res.json().get("message")
|
||||
|
||||
@cached(region=_shares_cache_region)
|
||||
@cached(region=_shares_cache_region, maxsize=1, skip_empty=True)
|
||||
def get_shares(self, name: Optional[str] = None, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
获取工作流分享数据
|
||||
|
||||
@@ -234,7 +234,7 @@ class DoubanApi(metaclass=WeakSingleton):
|
||||
if '_ts' in params:
|
||||
params.pop('_ts')
|
||||
resp = RequestUtils(
|
||||
ua=settings.USER_AGENT,
|
||||
ua=settings.NORMAL_USER_AGENT,
|
||||
session=self._session,
|
||||
).post_res(url=req_url, data=params)
|
||||
if resp is not None and resp.status_code == 400 and "rate_limit" in resp.text:
|
||||
|
||||
@@ -166,7 +166,8 @@ class Emby:
|
||||
type=library_type,
|
||||
image=image,
|
||||
link=f'{self._playhost or self._host}web/index.html'
|
||||
f'#!/videos?serverId={self.serverid}&parentId={library.get("Id")}'
|
||||
f'#!/videos?serverId={self.serverid}&parentId={library.get("Id")}',
|
||||
server_type= "emby"
|
||||
)
|
||||
)
|
||||
return libraries
|
||||
@@ -1167,7 +1168,8 @@ class Emby:
|
||||
type=item_type,
|
||||
image=image,
|
||||
link=link,
|
||||
percent=item.get("UserData", {}).get("PlayedPercentage")
|
||||
percent=item.get("UserData", {}).get("PlayedPercentage"),
|
||||
server_type='emby'
|
||||
))
|
||||
return ret_resume
|
||||
else:
|
||||
@@ -1219,7 +1221,8 @@ class Emby:
|
||||
type=item_type,
|
||||
image=image,
|
||||
link=link,
|
||||
BackdropImageTags=item.get("BackdropImageTags")
|
||||
BackdropImageTags=item.get("BackdropImageTags"),
|
||||
server_type='emby'
|
||||
))
|
||||
return ret_latest
|
||||
else:
|
||||
|
||||
@@ -140,8 +140,7 @@ class FileManagerModule(_ModuleBase):
|
||||
"""
|
||||
handler = TransHandler()
|
||||
# 重命名格式
|
||||
rename_format = settings.TV_RENAME_FORMAT \
|
||||
if mediainfo.type == MediaType.TV else settings.MOVIE_RENAME_FORMAT
|
||||
rename_format = settings.RENAME_FORMAT(mediainfo.type)
|
||||
# 获取集信息
|
||||
episodes_info: Optional[List[TmdbEpisode]] = None
|
||||
if mediainfo.type == MediaType.TV:
|
||||
@@ -429,6 +428,12 @@ class FileManagerModule(_ModuleBase):
|
||||
message=f"{target_path} 不是有效目录")
|
||||
# 获取目标路径
|
||||
if target_directory:
|
||||
# 目标媒体库目录未设置
|
||||
if not target_directory.library_path:
|
||||
logger.error(f"目标媒体库目录未设置,无法整理文件,源路径:{fileitem.path}")
|
||||
return TransferInfo(success=False,
|
||||
fileitem=fileitem,
|
||||
message="目标媒体库目录未设置")
|
||||
# 整理方式
|
||||
if not transfer_type:
|
||||
transfer_type = target_directory.transfer_type
|
||||
@@ -528,8 +533,7 @@ class FileManagerModule(_ModuleBase):
|
||||
# 媒体分类路径
|
||||
dir_path = handler.get_dest_dir(mediainfo=mediainfo, target_dir=dest_dir)
|
||||
# 重命名格式
|
||||
rename_format = settings.TV_RENAME_FORMAT \
|
||||
if mediainfo.type == MediaType.TV else settings.MOVIE_RENAME_FORMAT
|
||||
rename_format = settings.RENAME_FORMAT(mediainfo.type)
|
||||
# 元数据补上常用属性,尽可能确保重命名后的路径不出现空白
|
||||
meta = MetaInfo(mediainfo.title)
|
||||
if meta.type == MediaType.UNKNOWN and mediainfo.type is not None:
|
||||
@@ -554,7 +558,7 @@ class FileManagerModule(_ModuleBase):
|
||||
if not media_path:
|
||||
# 忽略
|
||||
continue
|
||||
if dir_path.is_relative_to(media_path):
|
||||
if dir_path != media_path and dir_path.is_relative_to(media_path):
|
||||
# 兜底检查,避免不必要的扫盘
|
||||
logger.warn(f"{media_path} 是媒体库目录 {dir_path} 的父目录,忽略获取媒体文件列表,请检查重命名格式!")
|
||||
continue
|
||||
|
||||
@@ -38,7 +38,7 @@ class Alist(StorageBase, metaclass=WeakSingleton):
|
||||
"""
|
||||
初始化
|
||||
"""
|
||||
self.__generate_token.clear_cache() # noqa
|
||||
self.__generate_token.clear_cache() # noqa
|
||||
|
||||
@property
|
||||
def __get_base_url(self) -> str:
|
||||
@@ -376,10 +376,46 @@ class Alist(StorageBase, metaclass=WeakSingleton):
|
||||
"""
|
||||
return self.get_folder(Path(fileitem.path).parent)
|
||||
|
||||
def __is_empty_dir(self, fileitem: schemas.FileItem) -> bool:
|
||||
"""
|
||||
判断目录是否为空
|
||||
"""
|
||||
if fileitem.type != "dir":
|
||||
return False
|
||||
# 获取目录内容
|
||||
items = self.list(fileitem)
|
||||
return len(items) == 0
|
||||
|
||||
def delete(self, fileitem: schemas.FileItem) -> bool:
|
||||
"""
|
||||
删除文件
|
||||
删除文件或目录,空目录用专用API
|
||||
"""
|
||||
# 如果是空目录,优先用 remove_empty_directory
|
||||
if fileitem.type == "dir" and self.__is_empty_dir(fileitem):
|
||||
resp = RequestUtils(
|
||||
headers=self.__get_header_with_token()
|
||||
).post_res(
|
||||
self.__get_api_url("/api/fs/remove_empty_directory"),
|
||||
json={
|
||||
"src_dir": fileitem.path,
|
||||
},
|
||||
)
|
||||
if resp is None:
|
||||
logger.warn(f"【OpenList】请求删除空目录 {fileitem.path} 失败,无法连接alist服务")
|
||||
return False
|
||||
if resp.status_code != 200:
|
||||
logger.warn(
|
||||
f"【OpenList】请求删除空目录 {fileitem.path} 失败,状态码:{resp.status_code}"
|
||||
)
|
||||
return False
|
||||
result = resp.json()
|
||||
if result["code"] != 200:
|
||||
logger.warn(
|
||||
f'【OpenList】删除空目录 {fileitem.path} 失败,错误信息:{result["message"]}'
|
||||
)
|
||||
return False
|
||||
return True
|
||||
# 其它情况(文件或非空目录)
|
||||
resp = RequestUtils(
|
||||
headers=self.__get_header_with_token()
|
||||
).post_res(
|
||||
@@ -389,20 +425,6 @@ class Alist(StorageBase, metaclass=WeakSingleton):
|
||||
"names": [fileitem.name],
|
||||
},
|
||||
)
|
||||
"""
|
||||
{
|
||||
"names": [
|
||||
"string"
|
||||
],
|
||||
"dir": "string"
|
||||
}
|
||||
======================================
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": null
|
||||
}
|
||||
"""
|
||||
if resp is None:
|
||||
logger.warn(f"【OpenList】请求删除文件 {fileitem.path} 失败,无法连接alist服务")
|
||||
return False
|
||||
@@ -411,7 +433,6 @@ class Alist(StorageBase, metaclass=WeakSingleton):
|
||||
f"【OpenList】请求删除文件 {fileitem.path} 失败,状态码:{resp.status_code}"
|
||||
)
|
||||
return False
|
||||
|
||||
result = resp.json()
|
||||
if result["code"] != 200:
|
||||
logger.warn(
|
||||
|
||||
@@ -106,8 +106,7 @@ class TransHandler:
|
||||
try:
|
||||
|
||||
# 重命名格式
|
||||
rename_format = settings.TV_RENAME_FORMAT \
|
||||
if mediainfo.type == MediaType.TV else settings.MOVIE_RENAME_FORMAT
|
||||
rename_format = settings.RENAME_FORMAT(mediainfo.type)
|
||||
|
||||
# 判断是否为文件夹
|
||||
if fileitem.type == "dir":
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import random
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
@@ -114,6 +116,7 @@ class IndexerModule(_ModuleBase):
|
||||
|
||||
# 搜索多个关键字
|
||||
error_flag = False
|
||||
search_count = 0
|
||||
for search_word in keywords:
|
||||
# 可能为关键字或ttxxxx
|
||||
if search_word \
|
||||
@@ -129,6 +132,11 @@ class IndexerModule(_ModuleBase):
|
||||
logger.warn(msg)
|
||||
continue
|
||||
|
||||
if search_count > 0:
|
||||
# 强制休眠 1-10 秒
|
||||
logger.info(f"站点 {site.get('name')} 已搜索 {search_count} 次,强制休眠 1-10 秒 ...")
|
||||
time.sleep(random.randint(1, 10))
|
||||
|
||||
# 去除搜索关键字中的特殊字符
|
||||
if search_word:
|
||||
search_word = StringUtils.clear(search_word, replace_word=" ", allow_space=True)
|
||||
@@ -188,6 +196,8 @@ class IndexerModule(_ModuleBase):
|
||||
break
|
||||
except Exception as err:
|
||||
logger.error(f"{site.get('name')} 搜索出错:{str(err)}")
|
||||
finally:
|
||||
search_count += 1
|
||||
|
||||
# 索引花费的时间
|
||||
seconds = (datetime.now() - start_time).seconds
|
||||
@@ -201,10 +211,10 @@ class IndexerModule(_ModuleBase):
|
||||
|
||||
# 返回结果
|
||||
if not result_array or len(result_array) == 0:
|
||||
logger.warn(f"{site.get('name')} 未搜索到数据,耗时 {seconds} 秒")
|
||||
logger.warn(f"{site.get('name')} 未搜索到数据,共搜索 {search_count} 次,耗时 {seconds} 秒")
|
||||
return []
|
||||
else:
|
||||
logger.info(f"{site.get('name')} 搜索完成,耗时 {seconds} 秒,返回数据:{len(result_array)}")
|
||||
logger.info(f"{site.get('name')} 搜索完成,共搜索 {search_count} 次,耗时 {seconds} 秒,返回数据:{len(result_array)}")
|
||||
# TorrentInfo
|
||||
torrents = [TorrentInfo(site=site.get("id"),
|
||||
site_name=site.get("name"),
|
||||
|
||||
@@ -168,7 +168,8 @@ class Jellyfin:
|
||||
path=library.get("Path"),
|
||||
type=library_type,
|
||||
image=image,
|
||||
link=link
|
||||
link=link,
|
||||
server_type="jellyfin"
|
||||
))
|
||||
return libraries
|
||||
|
||||
@@ -934,7 +935,8 @@ class Jellyfin:
|
||||
type=item_type,
|
||||
image=image,
|
||||
link=link,
|
||||
percent=item.get("UserData", {}).get("PlayedPercentage")
|
||||
percent=item.get("UserData", {}).get("PlayedPercentage"),
|
||||
server_type='jellyfin',
|
||||
))
|
||||
return ret_resume
|
||||
else:
|
||||
@@ -986,7 +988,8 @@ class Jellyfin:
|
||||
type=item_type,
|
||||
image=image,
|
||||
link=link,
|
||||
BackdropImageTags=item.get("BackdropImageTags")
|
||||
BackdropImageTags=item.get("BackdropImageTags"),
|
||||
server_type='jellyfin'
|
||||
))
|
||||
return ret_latest
|
||||
else:
|
||||
|
||||
@@ -154,7 +154,8 @@ class Plex:
|
||||
type=library_type,
|
||||
image_list=image_list,
|
||||
link=f"{self._playhost or self._host}web/index.html#!/media/{self._plex.machineIdentifier}"
|
||||
f"/com.plexapp.plugins.library?source={library.key}"
|
||||
f"/com.plexapp.plugins.library?source={library.key}&X-Plex-Token={self._token}",
|
||||
server_type='plex'
|
||||
)
|
||||
)
|
||||
return libraries
|
||||
@@ -387,6 +388,8 @@ class Plex:
|
||||
for path, lib_key in result_dict.items():
|
||||
logger.info(f"刷新媒体库:{lib_key} - {path}")
|
||||
self._plex.query(f'/library/sections/{lib_key}/refresh?path={quote_plus(str(Path(path).parent))}')
|
||||
return None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def __find_librarie(path: Path, libraries: List[Any]) -> Tuple[str, str]:
|
||||
@@ -541,6 +544,7 @@ class Plex:
|
||||
continue
|
||||
except Exception as err:
|
||||
logger.error(f"获取媒体库列表出错:{str(err)}")
|
||||
return None
|
||||
|
||||
def get_webhook_message(self, form: any) -> Optional[schemas.WebhookEventInfo]:
|
||||
"""
|
||||
@@ -718,7 +722,7 @@ class Plex:
|
||||
拼装媒体播放链接
|
||||
:param item_id: 媒体的的ID
|
||||
"""
|
||||
return f'{self._playhost or self._host}web/index.html#!/server/{self._plex.machineIdentifier}/details?key={item_id}'
|
||||
return f'{self._playhost or self._host}web/index.html#!/server/{self._plex.machineIdentifier}/details?key={item_id}&X-Plex-Token={self._token}'
|
||||
|
||||
def get_resume(self, num: Optional[int] = 12) -> Optional[List[schemas.MediaServerPlayItem]]:
|
||||
"""
|
||||
@@ -752,7 +756,8 @@ class Plex:
|
||||
type=item_type,
|
||||
image=image,
|
||||
link=link,
|
||||
percent=item.viewOffset / item.duration * 100 if item.viewOffset and item.duration else 0
|
||||
percent=item.viewOffset / item.duration * 100 if item.viewOffset and item.duration else 0,
|
||||
server_type='plex'
|
||||
))
|
||||
return ret_resume[:num]
|
||||
|
||||
@@ -820,7 +825,8 @@ class Plex:
|
||||
subtitle=item.year,
|
||||
type=item_type,
|
||||
image=image,
|
||||
link=link
|
||||
link=link,
|
||||
server_type='plex'
|
||||
))
|
||||
offset += num
|
||||
return ret_resume[:num]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
from typing import Dict
|
||||
from typing import Optional, Union, List, Tuple, Any
|
||||
|
||||
@@ -187,28 +188,33 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _handle_text_message(msg: dict, client_config: NotificationConf, client: Telegram) -> Optional[CommingMessage]:
|
||||
def _handle_text_message(self, msg: dict,
|
||||
client_config: NotificationConf, client: Telegram) -> Optional[CommingMessage]:
|
||||
"""
|
||||
处理普通文本消息
|
||||
"""
|
||||
text = msg.get("text")
|
||||
user_id = msg.get("from", {}).get("id")
|
||||
user_name = msg.get("from", {}).get("username")
|
||||
# Extract chat_id to enable correct reply targeting
|
||||
chat_id = msg.get("chat", {}).get("id")
|
||||
|
||||
if text and user_id:
|
||||
logger.info(f"收到来自 {client_config.name} 的Telegram消息:"
|
||||
f"userid={user_id}, username={user_name}, text={text}")
|
||||
f"userid={user_id}, username={user_name}, chat_id={chat_id}, text={text}")
|
||||
|
||||
# Clean bot mentions from text to ensure consistent processing
|
||||
cleaned_text = self._clean_bot_mention(text, client.bot_username if client else None)
|
||||
|
||||
# 检查权限
|
||||
admin_users = client_config.config.get("TELEGRAM_ADMINS")
|
||||
user_list = client_config.config.get("TELEGRAM_USERS")
|
||||
chat_id = client_config.config.get("TELEGRAM_CHAT_ID")
|
||||
config_chat_id = client_config.config.get("TELEGRAM_CHAT_ID")
|
||||
|
||||
if text.startswith("/"):
|
||||
if cleaned_text.startswith("/"):
|
||||
if admin_users \
|
||||
and str(user_id) not in admin_users.split(',') \
|
||||
and str(user_id) != chat_id:
|
||||
and str(user_id) != config_chat_id:
|
||||
client.send_msg(title="只有管理员才有权限执行此命令", userid=user_id)
|
||||
return None
|
||||
else:
|
||||
@@ -223,10 +229,38 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
||||
source=client_config.name,
|
||||
userid=user_id,
|
||||
username=user_name,
|
||||
text=text
|
||||
text=cleaned_text, # Use cleaned text
|
||||
chat_id=str(chat_id) if chat_id else None
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _clean_bot_mention(text: str, bot_username: Optional[str]) -> str:
|
||||
"""
|
||||
清理消息中的@bot部分,确保文本处理一致性
|
||||
:param text: 原始消息文本
|
||||
:param bot_username: bot用户名
|
||||
:return: 清理后的文本
|
||||
"""
|
||||
if not text or not bot_username:
|
||||
return text
|
||||
|
||||
# Remove @bot_username from the beginning and any position in text
|
||||
cleaned = text
|
||||
mention_pattern = f"@{bot_username}"
|
||||
|
||||
# Remove mention at the beginning with optional following space
|
||||
if cleaned.startswith(mention_pattern):
|
||||
cleaned = cleaned[len(mention_pattern):].lstrip()
|
||||
|
||||
# Remove mention at any other position
|
||||
cleaned = cleaned.replace(mention_pattern, "").strip()
|
||||
|
||||
# Clean up multiple spaces
|
||||
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
|
||||
|
||||
return cleaned
|
||||
|
||||
def post_message(self, message: Notification) -> None:
|
||||
"""
|
||||
发送消息
|
||||
|
||||
@@ -25,6 +25,8 @@ class Telegram:
|
||||
_event = Event()
|
||||
_bot: telebot.TeleBot = None
|
||||
_callback_handlers: Dict[str, Callable] = {} # 存储回调处理器
|
||||
_user_chat_mapping: Dict[str, str] = {} # userid -> chat_id mapping for reply targeting
|
||||
_bot_username: Optional[str] = None # Bot username for mention detection
|
||||
|
||||
def __init__(self, TELEGRAM_TOKEN: Optional[str] = None, TELEGRAM_CHAT_ID: Optional[str] = None, **kwargs):
|
||||
"""
|
||||
@@ -49,6 +51,15 @@ class Telegram:
|
||||
_bot = telebot.TeleBot(self._telegram_token, parse_mode="Markdown")
|
||||
# 记录句柄
|
||||
self._bot = _bot
|
||||
# 获取并存储bot用户名用于@检测
|
||||
try:
|
||||
bot_info = _bot.get_me()
|
||||
self._bot_username = bot_info.username
|
||||
logger.info(f"Telegram bot用户名: @{self._bot_username}")
|
||||
except Exception as e:
|
||||
logger.error(f"获取bot信息失败: {e}")
|
||||
self._bot_username = None
|
||||
|
||||
# 标记渠道来源
|
||||
if kwargs.get("name"):
|
||||
self._ds_url = f"{self._ds_url}&source={kwargs.get('name')}"
|
||||
@@ -59,7 +70,12 @@ class Telegram:
|
||||
|
||||
@_bot.message_handler(func=lambda message: True)
|
||||
def echo_all(message):
|
||||
RequestUtils(timeout=15).post_res(self._ds_url, json=message.json)
|
||||
# Update user-chat mapping when receiving messages
|
||||
self._update_user_chat_mapping(message.from_user.id, message.chat.id)
|
||||
|
||||
# Check if we should process this message
|
||||
if self._should_process_message(message):
|
||||
RequestUtils(timeout=15).post_res(self._ds_url, json=message.json)
|
||||
|
||||
@_bot.callback_query_handler(func=lambda call: True)
|
||||
def callback_query(call):
|
||||
@@ -67,6 +83,9 @@ class Telegram:
|
||||
处理按钮点击回调
|
||||
"""
|
||||
try:
|
||||
# Update user-chat mapping for callbacks too
|
||||
self._update_user_chat_mapping(call.from_user.id, call.message.chat.id)
|
||||
|
||||
# 解析回调数据
|
||||
callback_data = call.data
|
||||
user_id = str(call.from_user.id)
|
||||
@@ -94,8 +113,8 @@ class Telegram:
|
||||
# 发送给主程序处理
|
||||
RequestUtils(timeout=15).post_res(self._ds_url, json=callback_json)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理按钮回调失败:{str(e)}")
|
||||
except Exception as err:
|
||||
logger.error(f"处理按钮回调失败:{str(err)}")
|
||||
_bot.answer_callback_query(call.id, "处理失败,请重试")
|
||||
|
||||
def run_polling():
|
||||
@@ -112,6 +131,76 @@ class Telegram:
|
||||
self._polling_thread.start()
|
||||
logger.info("Telegram消息接收服务启动")
|
||||
|
||||
@property
|
||||
def bot_username(self) -> Optional[str]:
|
||||
"""
|
||||
获取Bot用户名
|
||||
:return: Bot用户名或None
|
||||
"""
|
||||
return self._bot_username
|
||||
|
||||
def _update_user_chat_mapping(self, userid: int, chat_id: int) -> None:
|
||||
"""
|
||||
更新用户与聊天的映射关系
|
||||
:param userid: 用户ID
|
||||
:param chat_id: 聊天ID
|
||||
"""
|
||||
if userid and chat_id:
|
||||
self._user_chat_mapping[str(userid)] = str(chat_id)
|
||||
|
||||
def _get_user_chat_id(self, userid: str) -> Optional[str]:
|
||||
"""
|
||||
获取用户对应的聊天ID
|
||||
:param userid: 用户ID
|
||||
:return: 聊天ID或None
|
||||
"""
|
||||
return self._user_chat_mapping.get(str(userid)) if userid else None
|
||||
|
||||
def _should_process_message(self, message) -> bool:
|
||||
"""
|
||||
判断是否应该处理这条消息
|
||||
:param message: Telegram消息对象
|
||||
:return: 是否处理
|
||||
"""
|
||||
# 私聊消息总是处理
|
||||
if message.chat.type == 'private':
|
||||
logger.debug(f"处理私聊消息:用户 {message.from_user.id}")
|
||||
return True
|
||||
|
||||
# 群聊中的命令消息总是处理(以/开头)
|
||||
if message.text and message.text.startswith('/'):
|
||||
logger.debug(f"处理群聊命令消息:{message.text[:20]}...")
|
||||
return True
|
||||
|
||||
# 群聊中检查是否@了机器人
|
||||
if message.chat.type in ['group', 'supergroup']:
|
||||
if not self._bot_username:
|
||||
# 如果没有获取到bot用户名,为了安全起见处理所有消息
|
||||
logger.debug("未获取到bot用户名,处理所有群聊消息")
|
||||
return True
|
||||
|
||||
# 检查消息文本中是否包含@bot_username
|
||||
if message.text and f"@{self._bot_username}" in message.text:
|
||||
logger.debug(f"检测到@{self._bot_username},处理群聊消息")
|
||||
return True
|
||||
|
||||
# 检查消息实体中是否有提及bot
|
||||
if message.entities:
|
||||
for entity in message.entities:
|
||||
if entity.type == 'mention':
|
||||
mention_text = message.text[entity.offset:entity.offset + entity.length]
|
||||
if mention_text == f"@{self._bot_username}":
|
||||
logger.debug(f"通过实体检测到@{self._bot_username},处理群聊消息")
|
||||
return True
|
||||
|
||||
# 群聊中没有@机器人,不处理
|
||||
logger.debug(f"群聊消息未@机器人,跳过处理:{message.text[:30] if message.text else 'No text'}...")
|
||||
return False
|
||||
|
||||
# 其他类型的聊天默认处理
|
||||
logger.debug(f"处理其他类型聊天消息:{message.chat.type}")
|
||||
return True
|
||||
|
||||
def get_state(self) -> bool:
|
||||
"""
|
||||
获取状态
|
||||
@@ -153,10 +242,8 @@ class Telegram:
|
||||
if link:
|
||||
caption = f"{caption}\n[查看详情]({link})"
|
||||
|
||||
if userid:
|
||||
chat_id = userid
|
||||
else:
|
||||
chat_id = self._telegram_chat_id
|
||||
# Determine target chat_id with improved logic using user mapping
|
||||
chat_id = self._determine_target_chat_id(userid, original_chat_id)
|
||||
|
||||
# 创建按钮键盘
|
||||
reply_markup = None
|
||||
@@ -175,6 +262,29 @@ class Telegram:
|
||||
logger.error(f"发送消息失败:{msg_e}")
|
||||
return False
|
||||
|
||||
def _determine_target_chat_id(self, userid: Optional[str] = None,
|
||||
original_chat_id: Optional[str] = None) -> str:
|
||||
"""
|
||||
确定目标聊天ID,使用用户映射确保回复到正确的聊天
|
||||
:param userid: 用户ID
|
||||
:param original_chat_id: 原消息的聊天ID
|
||||
:return: 目标聊天ID
|
||||
"""
|
||||
# 1. 优先使用原消息的聊天ID (编辑消息场景)
|
||||
if original_chat_id:
|
||||
return original_chat_id
|
||||
|
||||
# 2. 如果有userid,尝试从映射中获取用户的聊天ID
|
||||
if userid:
|
||||
mapped_chat_id = self._get_user_chat_id(userid)
|
||||
if mapped_chat_id:
|
||||
return mapped_chat_id
|
||||
# 如果映射中没有,回退到使用userid作为聊天ID (私聊场景)
|
||||
return userid
|
||||
|
||||
# 3. 最后使用默认聊天ID
|
||||
return self._telegram_chat_id
|
||||
|
||||
def send_medias_msg(self, medias: List[MediaInfo], userid: Optional[str] = None,
|
||||
title: Optional[str] = None, link: Optional[str] = None,
|
||||
buttons: Optional[List[List[Dict]]] = None,
|
||||
@@ -216,10 +326,8 @@ class Telegram:
|
||||
if link:
|
||||
caption = f"{caption}\n[查看详情]({link})"
|
||||
|
||||
if userid:
|
||||
chat_id = userid
|
||||
else:
|
||||
chat_id = self._telegram_chat_id
|
||||
# Determine target chat_id with improved logic using user mapping
|
||||
chat_id = self._determine_target_chat_id(userid, original_chat_id)
|
||||
|
||||
# 创建按钮键盘
|
||||
reply_markup = None
|
||||
@@ -278,10 +386,8 @@ class Telegram:
|
||||
if link:
|
||||
caption = f"{caption}\n[查看详情]({link})"
|
||||
|
||||
if userid:
|
||||
chat_id = userid
|
||||
else:
|
||||
chat_id = self._telegram_chat_id
|
||||
# Determine target chat_id with improved logic using user mapping
|
||||
chat_id = self._determine_target_chat_id(userid, original_chat_id)
|
||||
|
||||
# 创建按钮键盘
|
||||
reply_markup = None
|
||||
@@ -418,7 +524,7 @@ class Telegram:
|
||||
:param reply_markup: 内联键盘
|
||||
"""
|
||||
if image:
|
||||
res = RequestUtils(proxies=settings.PROXY, ua=settings.USER_AGENT).get_res(image)
|
||||
res = RequestUtils(proxies=settings.PROXY, ua=settings.NORMAL_USER_AGENT).get_res(image)
|
||||
if res is None:
|
||||
raise Exception("获取图片失败")
|
||||
if res.content:
|
||||
|
||||
@@ -514,7 +514,7 @@ class TmdbApi:
|
||||
return {}
|
||||
logger.info("正在从TheDbMovie网站查询:%s ..." % name)
|
||||
tmdb_url = "https://www.themoviedb.org/search?query=%s" % quote(name)
|
||||
res = RequestUtils(timeout=5, ua=settings.USER_AGENT, proxies=settings.PROXY).get_res(url=tmdb_url)
|
||||
res = RequestUtils(timeout=5, ua=settings.NORMAL_USER_AGENT, proxies=settings.PROXY).get_res(url=tmdb_url)
|
||||
if res is None:
|
||||
return None
|
||||
if res.status_code == 429:
|
||||
|
||||
@@ -163,6 +163,7 @@ class TrimeMedia:
|
||||
for img_path in library.posters or []
|
||||
],
|
||||
link=f"{self._playhost or self._api.host}/library/{library.guid}",
|
||||
server_type='trimemedia'
|
||||
)
|
||||
)
|
||||
return libraries
|
||||
@@ -458,6 +459,7 @@ class TrimeMedia:
|
||||
if item.duration and item.ts is not None
|
||||
else 0
|
||||
),
|
||||
server_type='trimemedia',
|
||||
)
|
||||
|
||||
def get_items(
|
||||
|
||||
@@ -391,6 +391,14 @@ class Monitor(metaclass=Singleton):
|
||||
capture_output=True, text=True, timeout=5)
|
||||
if result.returncode == 0:
|
||||
output = result.stdout.lower()
|
||||
# 以下本地文件系统含有fuse关键字
|
||||
local_fs = [
|
||||
"fuse.shfs", # Unraid
|
||||
"zfuse.zfsv", # 极空间(zfuse.zfsv2、zfuse.zfsv3、...)
|
||||
# TBD
|
||||
]
|
||||
if any(fs in output for fs in local_fs):
|
||||
return False
|
||||
network_fs = ['nfs', 'cifs', 'smbfs', 'fuse', 'sshfs', 'ftpfs']
|
||||
return any(fs in output for fs in network_fs)
|
||||
elif system == 'Darwin':
|
||||
|
||||
@@ -72,6 +72,8 @@ class MediaServerLibrary(BaseModel):
|
||||
image_list: Optional[List[str]] = None
|
||||
# 跳转链接
|
||||
link: Optional[str] = None
|
||||
# 服务器类型
|
||||
server_type: Optional[str] = None
|
||||
|
||||
|
||||
class MediaServerItemUserState(BaseModel):
|
||||
@@ -175,3 +177,4 @@ class MediaServerPlayItem(BaseModel):
|
||||
link: Optional[str] = None
|
||||
percent: Optional[float] = None
|
||||
BackdropImageTags: Optional[list] = Field(default_factory=list)
|
||||
server_type: Optional[str] = None
|
||||
|
||||
@@ -138,6 +138,15 @@ class SubscribeShare(BaseModel):
|
||||
count: Optional[int] = 0
|
||||
|
||||
|
||||
class SubscribeShareStatistics(BaseModel):
|
||||
# 分享人
|
||||
share_user: Optional[str] = None
|
||||
# 分享数量
|
||||
share_count: Optional[int] = 0
|
||||
# 总复用人次
|
||||
total_reuse_count: Optional[int] = 0
|
||||
|
||||
|
||||
class SubscribeDownloadFileInfo(BaseModel):
|
||||
# 种子名称
|
||||
torrent_title: Optional[str] = None
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import sys
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import chardet
|
||||
@@ -8,6 +10,7 @@ import urllib3
|
||||
from requests import Response, Session
|
||||
from urllib3.exceptions import InsecureRequestWarning
|
||||
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
|
||||
urllib3.disable_warnings(InsecureRequestWarning)
|
||||
@@ -86,6 +89,7 @@ class AutoCloseResponse:
|
||||
def __exit__(self, *args):
|
||||
self.close()
|
||||
|
||||
|
||||
class RequestUtils:
|
||||
|
||||
def __init__(self,
|
||||
@@ -106,6 +110,10 @@ class RequestUtils:
|
||||
if headers:
|
||||
self._headers = headers
|
||||
else:
|
||||
if ua and ua == settings.USER_AGENT:
|
||||
caller_name = self.__get_caller()
|
||||
if caller_name:
|
||||
ua = f"{settings.USER_AGENT} Plugin/{caller_name}"
|
||||
self._headers = {
|
||||
"User-Agent": ua,
|
||||
"Content-Type": content_type,
|
||||
@@ -120,6 +128,43 @@ class RequestUtils:
|
||||
else:
|
||||
self._cookies = None
|
||||
|
||||
@staticmethod
|
||||
def __get_caller():
|
||||
"""
|
||||
获取调用者的名称,识别是否为插件调用
|
||||
"""
|
||||
# 调用者名称
|
||||
caller_name = None
|
||||
|
||||
try:
|
||||
frame = sys._getframe(3) # noqa
|
||||
except (AttributeError, ValueError):
|
||||
return None
|
||||
|
||||
while frame:
|
||||
filepath = Path(frame.f_code.co_filename)
|
||||
parts = filepath.parts
|
||||
if "app" in parts:
|
||||
if not caller_name and "plugins" in parts:
|
||||
try:
|
||||
plugins_index = parts.index("plugins")
|
||||
if plugins_index + 1 < len(parts):
|
||||
plugin_candidate = parts[plugins_index + 1]
|
||||
if plugin_candidate != "__init__.py":
|
||||
caller_name = plugin_candidate
|
||||
break
|
||||
except ValueError:
|
||||
pass
|
||||
if "main.py" in parts:
|
||||
break
|
||||
elif len(parts) != 1:
|
||||
break
|
||||
try:
|
||||
frame = frame.f_back
|
||||
except AttributeError:
|
||||
break
|
||||
return caller_name
|
||||
|
||||
def request(self, method: str, url: str, raise_exception: bool = False, **kwargs) -> Optional[Response]:
|
||||
"""
|
||||
发起HTTP请求
|
||||
|
||||
@@ -68,35 +68,57 @@ class SystemUtils:
|
||||
"""
|
||||
if SystemUtils.is_windows():
|
||||
return False
|
||||
return True if "synology" in SystemUtils.execute('uname -a') else False
|
||||
return "synology" in SystemUtils.execute('uname -a')
|
||||
|
||||
@staticmethod
|
||||
def is_windows() -> bool:
|
||||
"""
|
||||
判断是否为Windows系统
|
||||
"""
|
||||
return True if os.name == "nt" else False
|
||||
return os.name == "nt"
|
||||
|
||||
@staticmethod
|
||||
def is_frozen() -> bool:
|
||||
"""
|
||||
判断是否为冻结的二进制文件
|
||||
"""
|
||||
return True if getattr(sys, 'frozen', False) else False
|
||||
return getattr(sys, 'frozen', False)
|
||||
|
||||
@staticmethod
|
||||
def is_macos() -> bool:
|
||||
"""
|
||||
判断是否为MacOS系统
|
||||
"""
|
||||
return True if platform.system() == 'Darwin' else False
|
||||
return platform.system() == 'Darwin'
|
||||
|
||||
@staticmethod
|
||||
def is_aarch64() -> bool:
|
||||
"""
|
||||
判断是否为ARM64架构
|
||||
"""
|
||||
return True if platform.machine() == 'aarch64' else False
|
||||
return platform.machine().lower() in ('aarch64', 'arm64')
|
||||
|
||||
@staticmethod
|
||||
def is_aarch() -> bool:
|
||||
"""
|
||||
判断是否为ARM32架构
|
||||
"""
|
||||
arch_name = platform.machine().lower()
|
||||
return arch_name.startswith(('arm', 'aarch')) and arch_name not in ('aarch64', 'arm64')
|
||||
|
||||
@staticmethod
|
||||
def is_x86_64() -> bool:
|
||||
"""
|
||||
判断是否为AMD64架构
|
||||
"""
|
||||
return platform.machine().lower() in ('amd64', 'x86_64')
|
||||
|
||||
@staticmethod
|
||||
def is_x86_32() -> bool:
|
||||
"""
|
||||
判断是否为AMD32架构
|
||||
"""
|
||||
return platform.machine().lower() in ('i386', 'i686', 'x86', '386', 'x86_32')
|
||||
|
||||
@staticmethod
|
||||
def platform() -> str:
|
||||
@@ -112,6 +134,22 @@ class SystemUtils:
|
||||
else:
|
||||
return "Linux"
|
||||
|
||||
@staticmethod
|
||||
def cpu_arch() -> str:
|
||||
"""
|
||||
获取CPU架构
|
||||
"""
|
||||
if SystemUtils.is_x86_64():
|
||||
return "x86_64"
|
||||
elif SystemUtils.is_x86_32():
|
||||
return "x86_32"
|
||||
elif SystemUtils.is_aarch64():
|
||||
return "Arm64"
|
||||
elif SystemUtils.is_aarch():
|
||||
return "Arm32"
|
||||
else:
|
||||
return platform.machine()
|
||||
|
||||
@staticmethod
|
||||
def copy(src: Path, dest: Path) -> Tuple[int, str]:
|
||||
"""
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
APP_VERSION = 'v2.6.4'
|
||||
FRONTEND_VERSION = 'v2.6.4'
|
||||
APP_VERSION = 'v2.6.7'
|
||||
FRONTEND_VERSION = 'v2.6.7'
|
||||
|
||||
Reference in New Issue
Block a user