Compare commits

...

49 Commits

Author SHA1 Message Date
jxxghp
2f1e55fa1e 增加搜索次数统计和强制休眠机制以优化搜索性能 2025-07-21 12:25:52 +08:00
jxxghp
776f629771 fix User-Agent 2025-07-20 15:50:45 +08:00
jxxghp
d9e9edb2c4 Update version.py 2025-07-20 13:32:54 +08:00
jxxghp
753c074e59 fix #4625 2025-07-20 12:45:53 +08:00
jxxghp
d92c82775a fix #4637 2025-07-20 12:28:12 +08:00
jxxghp
215cc09c1f fix 2025-07-20 11:50:44 +08:00
jxxghp
7f302c13c7 fix #4632 2025-07-20 09:14:47 +08:00
jxxghp
de6a094d10 fix display 2025-07-20 08:49:21 +08:00
jxxghp
a94e1a8314 Merge pull request #4631 from ChanningHe/fix-telegram-msg 2025-07-18 21:22:17 +08:00
ChanningHe
f5efdd665b fix: 清理Telegram消息中的@bot部分以确保一致性处理 2025-07-18 21:59:04 +09:00
jxxghp
43e25e8717 fix share cache 2025-07-18 17:36:28 +08:00
ChanningHe
a8026fefc1 fix: 在Telegram chat中只有被at时检测 2025-07-18 17:55:43 +09:00
ChanningHe
fdb36957c9 fix: Telegram 机器人消息无法推送到群组,只能推送到userid 2025-07-18 17:40:06 +09:00
jxxghp
ea433ff807 add site api 2025-07-18 08:04:05 +08:00
jxxghp
8902fb50d6 更新 context.py 2025-07-16 22:22:45 +08:00
jxxghp
b6aa013eb3 v2.6.6 2025-07-16 20:25:43 +08:00
jxxghp
034b43bf70 fix context 2025-07-16 19:59:06 +08:00
jxxghp
59e9032286 add subscribe share statistic api 2025-07-16 08:47:54 +08:00
jxxghp
52a98efd0a add subscribe share statistic api 2025-07-16 08:31:28 +08:00
jxxghp
90cc91aa7f Merge pull request #4614 from Aqr-K/feature-ua 2025-07-15 06:47:34 +08:00
Aqr-K
1973a26e83 fix: 去除冗余代码,简化写法 2025-07-14 22:19:48 +08:00
Aqr-K
6519ad25ca fix is_aarch 2025-07-14 22:17:04 +08:00
Aqr-K
cacfde8166 fix 2025-07-14 22:14:52 +08:00
Aqr-K
df85873726 feat(ua): add cup_arch , USER_AGENT value add cup_arch 2025-07-14 22:04:09 +08:00
jxxghp
dfea294cc9 fix ua 2025-07-14 13:42:49 +08:00
jxxghp
d35b855404 fix ua 2025-07-14 13:30:18 +08:00
jxxghp
7a1cbf70e3 feat:特定默认UA 2025-07-14 12:35:08 +08:00
jxxghp
f260990b86 更新 version.py 2025-07-13 15:14:10 +08:00
jxxghp
6affbe9b55 fix #4558 2025-07-13 15:04:41 +08:00
jxxghp
dbe3a10697 fix 2025-07-13 14:53:39 +08:00
jxxghp
3c25306a5d fix #4590 2025-07-13 14:43:48 +08:00
jxxghp
17f4d49731 fix #4594 2025-07-13 14:24:41 +08:00
jxxghp
e213b5cc64 Merge branch 'v2' of https://github.com/jxxghp/MoviePilot into v2 2025-07-13 14:14:26 +08:00
jxxghp
65e5dad44b 优化移动模式下的种子和残留目录删除逻辑 2025-07-13 14:14:24 +08:00
jxxghp
62ad38ea5d Merge pull request #4605 from wikrin/torrent_optimize 2025-07-13 13:25:35 +08:00
Attente
f98f4c1f77 refactor(helper): 优化 TorrentHelper 类
- 添加检查临时目录中是否存在种子文件
- 修改 match_torrent 方法参数类型
- 优化种子文件下载和处理逻辑
2025-07-13 13:16:36 +08:00
jxxghp
e9f02b58b7 Merge pull request #4604 from cddjr/fix_4602 2025-07-13 06:51:36 +08:00
景大侠
05495e481d fix #4602 2025-07-13 01:10:07 +08:00
jxxghp
5bb2167b78 Merge pull request #4603 from cddjr/fix_nettest 2025-07-12 18:34:54 +08:00
景大侠
b4e0ed66cf 完善网络连通性测试的错误描述 2025-07-12 18:15:19 +08:00
jxxghp
70a0563435 add server_type return 2025-07-12 14:52:18 +08:00
jxxghp
955912b832 fix plex 2025-07-12 14:44:45 +08:00
jxxghp
b65ee75b3d Merge pull request #4601 from cddjr/minimal_deps 2025-07-11 21:46:13 +08:00
景大侠
f642493a38 fix 2025-07-11 21:25:10 +08:00
jxxghp
7f1bfb1e07 Merge pull request #4599 from jtcymc/v2 2025-07-11 21:12:16 +08:00
景大侠
8931e2e016 fix 仅安装用户需要使用的插件依赖 2025-07-11 21:04:33 +08:00
shaw
0465fa77c2 fix(filemanager): 检查目标媒体库目录是否设置
- 在文件整理过程中,增加对目标媒体库目录是否设置的检查- 如果目标媒体库目录未设置,返回错误信息并中断整理过程
- 优化了错误处理逻辑,提高了系统的稳定性和可靠性
2025-07-11 20:02:12 +08:00
jxxghp
575d503cb9 Merge pull request #4598 from cddjr/fix_4586 2025-07-11 18:12:57 +08:00
景大侠
a4fdbdb9ad fix 极空间、Unraid误报网络文件系统 2025-07-11 18:03:19 +08:00
37 changed files with 630 additions and 141 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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]]):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import os
class DisplayHelper(metaclass=Singleton):
def __init__(self):
self._display = None
if not SystemUtils.is_docker():
return
try:

View File

@@ -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():

View File

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

View File

@@ -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: 需要匹配的媒体信息

View File

@@ -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]:
"""
获取工作流分享数据

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"),

View File

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

View File

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

View File

@@ -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:
"""
发送消息

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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]:
"""

View File

@@ -1,2 +1,2 @@
APP_VERSION = 'v2.6.4'
FRONTEND_VERSION = 'v2.6.4'
APP_VERSION = 'v2.6.7'
FRONTEND_VERSION = 'v2.6.7'