mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-08 21:02:44 +08:00
Compare commits
176 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6156b9a481 | ||
|
|
8c516c5691 | ||
|
|
bf9a149898 | ||
|
|
277cde8db2 | ||
|
|
e06bdaf53e | ||
|
|
da367bd138 | ||
|
|
d336bcbf1f | ||
|
|
a8aedba6ff | ||
|
|
9ede86c6a3 | ||
|
|
1468f2b082 | ||
|
|
e04ae70f89 | ||
|
|
7f7d2c9ba8 | ||
|
|
d73deef8dc | ||
|
|
f93a1540af | ||
|
|
c8bd9cb716 | ||
|
|
2ed13c7e5b | ||
|
|
647c0929c5 | ||
|
|
a61533a131 | ||
|
|
bc5e682308 | ||
|
|
25a481df12 | ||
|
|
764c10fae4 | ||
|
|
d8249d4e38 | ||
|
|
0e3e42b398 | ||
|
|
7d3b64dcf9 | ||
|
|
2c8d525796 | ||
|
|
4869f071ab | ||
|
|
3029eeaf6f | ||
|
|
33fb692aee | ||
|
|
6a075d144f | ||
|
|
aa23315599 | ||
|
|
8d0bb35505 | ||
|
|
32e76bc6ce | ||
|
|
6c02766000 | ||
|
|
52ef390464 | ||
|
|
43a557601e | ||
|
|
82ff7fc090 | ||
|
|
db40b5105b | ||
|
|
b2a379b84b | ||
|
|
97cbd816fe | ||
|
|
7de3bb2a91 | ||
|
|
3a8a2bcab4 | ||
|
|
eb1adbe992 | ||
|
|
b55966d42b | ||
|
|
451ca9cb5a | ||
|
|
1e2c607ced | ||
|
|
5ff7da0d19 | ||
|
|
8e06c6f8e6 | ||
|
|
4497cd3904 | ||
|
|
2945679a94 | ||
|
|
1eaf7e3c85 | ||
|
|
8146b680c6 | ||
|
|
99e667382f | ||
|
|
4c03759d3f | ||
|
|
8593a6cdd0 | ||
|
|
cd18c31618 | ||
|
|
f29c918700 | ||
|
|
0f0c3e660b | ||
|
|
1cf4639db3 | ||
|
|
f5da9b5780 | ||
|
|
e4c87c8a96 | ||
|
|
4b4bf153f0 | ||
|
|
ec227d0d56 | ||
|
|
53c8c50779 | ||
|
|
07b4c8b462 | ||
|
|
f3cfc5b9f0 | ||
|
|
634e5a4c55 | ||
|
|
332b154f15 | ||
|
|
b446d4db28 | ||
|
|
ce0397a140 | ||
|
|
f278cccef3 | ||
|
|
cbf1dbcd2e | ||
|
|
037c6b02fa | ||
|
|
5f44e4322d | ||
|
|
6cebe97d6d | ||
|
|
82ec146446 | ||
|
|
3928c352c6 | ||
|
|
0ba36d21a9 | ||
|
|
6152727e9b | ||
|
|
53c02fa706 | ||
|
|
c7800df801 | ||
|
|
562c1de0c9 | ||
|
|
e2c90639f3 | ||
|
|
92e175a8d1 | ||
|
|
cf7bca75f6 | ||
|
|
24a173f075 | ||
|
|
8d695dda55 | ||
|
|
93eec6c4b8 | ||
|
|
a2cc1a2926 | ||
|
|
11729d0eca | ||
|
|
978819be38 | ||
|
|
23c9862eb3 | ||
|
|
a9f18ea3ef | ||
|
|
574257edf8 | ||
|
|
bb4438ac42 | ||
|
|
0baf6e5fe7 | ||
|
|
d8a53da8ee | ||
|
|
9555ac6305 | ||
|
|
4dd5ea8e2f | ||
|
|
8068523d88 | ||
|
|
27dd681d9f | ||
|
|
152f814fb6 | ||
|
|
2700e639f1 | ||
|
|
c440ce3045 | ||
|
|
2829a3cb4e | ||
|
|
a487091be8 | ||
|
|
e7524774da | ||
|
|
3918c876c5 | ||
|
|
f07f87735c | ||
|
|
b7566e8fe8 | ||
|
|
73eba90f2f | ||
|
|
62e74f6fd1 | ||
|
|
4375e48840 | ||
|
|
a1d6e94e90 | ||
|
|
1f44e13ff0 | ||
|
|
d2992f9ced | ||
|
|
950337bccc | ||
|
|
757c3be359 | ||
|
|
269ab9adfc | ||
|
|
bd241a5164 | ||
|
|
3d92b57f24 | ||
|
|
70d8cb3697 | ||
|
|
9e4ec5841c | ||
|
|
682f4fe608 | ||
|
|
ce8a077e07 | ||
|
|
d5f63bcdb3 | ||
|
|
5c3756fd1b | ||
|
|
99939e1a3d | ||
|
|
56742ace11 | ||
|
|
742cb7a8da | ||
|
|
98327d1750 | ||
|
|
b944306302 | ||
|
|
02ab1d4111 | ||
|
|
28552fb0ce | ||
|
|
bf52fcb2ec | ||
|
|
bab1f73480 | ||
|
|
c06001d921 | ||
|
|
0fa49bb9c6 | ||
|
|
bf23fe6ce2 | ||
|
|
7c6137b742 | ||
|
|
3823a7c9b6 | ||
|
|
a944975be2 | ||
|
|
6da65d3b03 | ||
|
|
0d938f2dca | ||
|
|
4fa9bb3c1f | ||
|
|
2f5b22a81f | ||
|
|
fcd5ca3fda | ||
|
|
c18247f3b1 | ||
|
|
f8fbfdbba7 | ||
|
|
21addfb947 | ||
|
|
8672bd12c4 | ||
|
|
be8054e81e | ||
|
|
82f46c6010 | ||
|
|
95a827e8a2 | ||
|
|
c534e3dcb8 | ||
|
|
9f5e1b8dd7 | ||
|
|
c86ed20c34 | ||
|
|
c32c37e66a | ||
|
|
7b100d3cdb | ||
|
|
95a2362885 | ||
|
|
d8b14b9a9f | ||
|
|
c45953f63a | ||
|
|
e3d3087a5d | ||
|
|
e162bd1168 | ||
|
|
db5d81d7f0 | ||
|
|
f737f1287b | ||
|
|
1ffa5178db | ||
|
|
49cb43488c | ||
|
|
fd7a6f8ddd | ||
|
|
7979ce0f0a | ||
|
|
2ba5d9484d | ||
|
|
23b981c5ac | ||
|
|
86ab2c8c05 | ||
|
|
9ea0bc609a | ||
|
|
5366c2844a | ||
|
|
eac4d703c7 | ||
|
|
8ed87294e2 |
2
.github/ISSUE_TEMPLATE/rfc.yml
vendored
2
.github/ISSUE_TEMPLATE/rfc.yml
vendored
@@ -10,7 +10,7 @@ body:
|
||||
目的是让协作的开发者间清晰的知道「要做什么」和「具体会怎么做」,以及所有的开发者都能公开透明的参与讨论;
|
||||
以便评估和讨论产生的影响 (遗漏的考虑、向后兼容性、与现有功能的冲突),
|
||||
因此提案侧重在对解决问题的 **方案、设计、步骤** 的描述上。
|
||||
|
||||
|
||||
如果仅希望讨论是否添加或改进某功能本身,请使用 -> [Issue: 功能改进](https://github.com/jxxghp/MoviePilot/issues/new?assignees=&labels=feature+request&projects=&template=feature_request.yml&title=%5BFeature+Request%5D%3A+)
|
||||
- type: textarea
|
||||
id: background
|
||||
|
||||
2
.github/workflows/issues.yml
vendored
2
.github/workflows/issues.yml
vendored
@@ -27,4 +27,6 @@ jobs:
|
||||
# 忽略所有的 Pull Request,只处理 Issue
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
# 排除带有RFC标签的issue
|
||||
exempt-issue-labels: "RFC"
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
24
.github/workflows/pylint.yml
vendored
24
.github/workflows/pylint.yml
vendored
@@ -8,17 +8,17 @@ jobs:
|
||||
pylint:
|
||||
runs-on: ubuntu-latest
|
||||
name: Pylint Code Quality Check
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt', '**/requirements.in') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip setuptools wheel
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
else
|
||||
echo "⚠️ 未找到依赖文件,仅安装 pylint"
|
||||
fi
|
||||
|
||||
|
||||
- name: Verify pylint config
|
||||
run: |
|
||||
# 检查项目中的pylint配置文件是否存在
|
||||
@@ -57,35 +57,35 @@ jobs:
|
||||
run: |
|
||||
# 运行pylint,检查主要的Python文件
|
||||
echo "🚀 运行 Pylint 错误检查..."
|
||||
|
||||
|
||||
# 检查主要目录 - 只关注错误,如果有错误则退出
|
||||
echo "📂 检查 app/ 目录..."
|
||||
pylint app/ --output-format=colorized --reports=yes --score=yes
|
||||
|
||||
|
||||
# 检查根目录的Python文件
|
||||
echo "📂 检查根目录 Python 文件..."
|
||||
for file in $(find . -name "*.py" -not -path "./.*" -not -path "./.venv/*" -not -path "./build/*" -not -path "./dist/*" -not -path "./tests/*" -not -path "./docs/*" -not -path "./__pycache__/*" -maxdepth 1); do
|
||||
echo "检查文件: $file"
|
||||
pylint "$file" --output-format=colorized || exit 1
|
||||
done
|
||||
|
||||
|
||||
# 生成详细报告
|
||||
echo "📊 生成 Pylint 详细报告..."
|
||||
pylint app/ --output-format=json > pylint-report.json || true
|
||||
|
||||
|
||||
# 显示评分(仅供参考)
|
||||
echo "📈 Pylint 评分(仅供参考):"
|
||||
pylint app/ --score=yes --reports=no | tail -2 || true
|
||||
|
||||
|
||||
- name: Upload pylint report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: pylint-report
|
||||
path: pylint-report.json
|
||||
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "🎉 Pylint 检查完成!"
|
||||
echo "✅ 没有发现语法错误或严重问题"
|
||||
echo "📊 详细报告已保存为构建工件"
|
||||
echo "📊 详细报告已保存为构建工件"
|
||||
@@ -12,7 +12,7 @@ jobs=0
|
||||
# 只关注错误级别的问题,禁用警告、约定和重构建议
|
||||
# E = Error (错误) - 会导致构建失败
|
||||
# W = Warning (警告) - 仅显示,不会失败
|
||||
# R = Refactor (重构建议) - 仅显示,不会失败
|
||||
# R = Refactor (重构建议) - 仅显示,不会失败
|
||||
# C = Convention (约定) - 仅显示,不会失败
|
||||
# I = Information (信息) - 仅显示,不会失败
|
||||
|
||||
@@ -80,4 +80,4 @@ ignore-imports=yes
|
||||
|
||||
[TYPECHECK]
|
||||
# 生成缺失成员提示的类列表
|
||||
generated-members=requests.packages.urllib3
|
||||
generated-members=requests.packages.urllib3
|
||||
@@ -166,3 +166,19 @@ def memory2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
获取当前内存使用率 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
return memory()
|
||||
|
||||
|
||||
@router.get("/network", summary="获取当前网络流量", response_model=List[int])
|
||||
def network(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
获取当前网络流量(上行和下行流量,单位:bytes/s)
|
||||
"""
|
||||
return SystemUtils.network_usage()
|
||||
|
||||
|
||||
@router.get("/network2", summary="获取当前网络流量(API_TOKEN)", response_model=List[int])
|
||||
def network2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
"""
|
||||
获取当前网络流量 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
return network()
|
||||
|
||||
@@ -44,6 +44,8 @@ def download(
|
||||
# 种子信息
|
||||
torrentinfo = TorrentInfo()
|
||||
torrentinfo.from_dict(torrent_in.dict())
|
||||
# 手动下载始终使用选择的下载器
|
||||
torrentinfo.site_downloader = downloader
|
||||
# 上下文
|
||||
context = Context(
|
||||
meta_info=metainfo,
|
||||
@@ -51,7 +53,7 @@ def download(
|
||||
torrent_info=torrentinfo
|
||||
)
|
||||
did = DownloadChain().download_single(context=context, username=current_user.name,
|
||||
downloader=downloader, save_path=save_path, source="Manual")
|
||||
save_path=save_path, source="Manual")
|
||||
if not did:
|
||||
return schemas.Response(success=False, message="任务添加失败")
|
||||
return schemas.Response(success=True, data={
|
||||
@@ -94,22 +96,22 @@ def add(
|
||||
|
||||
@router.get("/start/{hashString}", summary="开始任务", response_model=schemas.Response)
|
||||
def start(
|
||||
hashString: str,
|
||||
hashString: str, name: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
开如下载任务
|
||||
"""
|
||||
ret = DownloadChain().set_downloading(hashString, "start")
|
||||
ret = DownloadChain().set_downloading(hashString, "start", name=name)
|
||||
return schemas.Response(success=True if ret else False)
|
||||
|
||||
|
||||
@router.get("/stop/{hashString}", summary="暂停任务", response_model=schemas.Response)
|
||||
def stop(hashString: str,
|
||||
def stop(hashString: str, name: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
暂停下载任务
|
||||
"""
|
||||
ret = DownloadChain().set_downloading(hashString, "stop")
|
||||
ret = DownloadChain().set_downloading(hashString, "stop", name=name)
|
||||
return schemas.Response(success=True if ret else False)
|
||||
|
||||
|
||||
@@ -125,10 +127,10 @@ def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
|
||||
|
||||
@router.delete("/{hashString}", summary="删除下载任务", response_model=schemas.Response)
|
||||
def delete(hashString: str,
|
||||
def delete(hashString: str, name: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
删除下载任务
|
||||
"""
|
||||
ret = DownloadChain().remove_downloading(hashString)
|
||||
ret = DownloadChain().remove_downloading(hashString, name=name)
|
||||
return schemas.Response(success=True if ret else False)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import List, Any, Optional
|
||||
|
||||
import jieba
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -57,6 +58,8 @@ def transfer_history(title: Optional[str] = None,
|
||||
status = True
|
||||
|
||||
if title:
|
||||
words = jieba.cut(title, HMM=False)
|
||||
title = "%".join(words)
|
||||
total = TransferHistory.count_by_title(db, title=title, status=status)
|
||||
result = TransferHistory.list_by_title(db, title=title, page=page,
|
||||
count=count, status=status)
|
||||
|
||||
@@ -147,7 +147,7 @@ def all_plugins(_: schemas.TokenPayload = Depends(get_current_active_superuser),
|
||||
installed_plugins = [plugin for plugin in local_plugins if plugin.installed]
|
||||
if state == "installed":
|
||||
return installed_plugins
|
||||
|
||||
|
||||
# 未安装的本地插件
|
||||
not_installed_plugins = [plugin for plugin in local_plugins if not plugin.installed]
|
||||
# 在线插件
|
||||
@@ -178,7 +178,7 @@ def all_plugins(_: schemas.TokenPayload = Depends(get_current_active_superuser),
|
||||
if state == "market":
|
||||
# 返回未安装的插件
|
||||
return market_plugins
|
||||
|
||||
|
||||
# 返回所有插件
|
||||
return installed_plugins + market_plugins
|
||||
|
||||
@@ -348,7 +348,7 @@ def plugin_static_file(plugin_id: str, filepath: str):
|
||||
获取插件静态文件
|
||||
"""
|
||||
# 基础安全检查
|
||||
if ".." in filepath or ".." in filepath:
|
||||
if ".." in filepath or ".." in plugin_id:
|
||||
logger.warning(f"Static File API: Path traversal attempt detected: {plugin_id}/{filepath}")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
|
||||
@@ -523,7 +523,7 @@ def clone_plugin(plugin_id: str,
|
||||
version=clone_data.get("version", ""),
|
||||
icon=clone_data.get("icon", "")
|
||||
)
|
||||
|
||||
|
||||
if success:
|
||||
# 注册插件服务
|
||||
reload_plugin(message)
|
||||
@@ -547,7 +547,7 @@ def _add_clone_to_plugin_folder(original_plugin_id: str, clone_plugin_id: str):
|
||||
config_oper = SystemConfigOper()
|
||||
# 获取插件文件夹配置
|
||||
folders = config_oper.get(SystemConfigKey.PluginFolders) or {}
|
||||
|
||||
|
||||
# 查找原插件所在的文件夹
|
||||
target_folder = None
|
||||
for folder_name, folder_data in folders.items():
|
||||
@@ -561,7 +561,7 @@ def _add_clone_to_plugin_folder(original_plugin_id: str, clone_plugin_id: str):
|
||||
if original_plugin_id in folder_data:
|
||||
target_folder = folder_name
|
||||
break
|
||||
|
||||
|
||||
# 如果找到了原插件所在的文件夹,则将分身插件也添加到该文件夹中
|
||||
if target_folder:
|
||||
folder_data = folders[target_folder]
|
||||
@@ -575,12 +575,12 @@ def _add_clone_to_plugin_folder(original_plugin_id: str, clone_plugin_id: str):
|
||||
if clone_plugin_id not in folder_data:
|
||||
folder_data.append(clone_plugin_id)
|
||||
logger.info(f"已将分身插件 {clone_plugin_id} 添加到文件夹 '{target_folder}' 中")
|
||||
|
||||
|
||||
# 保存更新后的文件夹配置
|
||||
config_oper.set(SystemConfigKey.PluginFolders, folders)
|
||||
else:
|
||||
logger.info(f"原插件 {original_plugin_id} 不在任何文件夹中,分身插件 {clone_plugin_id} 将保持独立")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理插件文件夹时出错:{str(e)}")
|
||||
# 文件夹处理失败不影响插件分身创建的整体流程
|
||||
@@ -595,10 +595,10 @@ def _remove_plugin_from_folders(plugin_id: str):
|
||||
config_oper = SystemConfigOper()
|
||||
# 获取插件文件夹配置
|
||||
folders = config_oper.get(SystemConfigKey.PluginFolders) or {}
|
||||
|
||||
|
||||
# 标记是否有修改
|
||||
modified = False
|
||||
|
||||
|
||||
# 遍历所有文件夹,移除指定插件
|
||||
for folder_name, folder_data in folders.items():
|
||||
if isinstance(folder_data, dict) and 'plugins' in folder_data:
|
||||
@@ -613,13 +613,13 @@ def _remove_plugin_from_folders(plugin_id: str):
|
||||
folder_data.remove(plugin_id)
|
||||
logger.info(f"已从文件夹 '{folder_name}' 中移除插件 {plugin_id}")
|
||||
modified = True
|
||||
|
||||
|
||||
# 如果有修改,保存更新后的文件夹配置
|
||||
if modified:
|
||||
config_oper.set(SystemConfigKey.PluginFolders, folders)
|
||||
else:
|
||||
logger.debug(f"插件 {plugin_id} 不在任何文件夹中,无需移除")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"从文件夹中移除插件时出错:{str(e)}")
|
||||
# 文件夹处理失败不影响插件卸载的整体流程
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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
|
||||
@@ -21,7 +22,6 @@ 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
|
||||
@@ -333,8 +333,8 @@ def read_site_by_domain(
|
||||
return site
|
||||
|
||||
|
||||
@router.get("/statistic/{site_url}", summary="站点统计信息", response_model=schemas.SiteStatistic)
|
||||
def read_site_by_domain(
|
||||
@router.get("/statistic/{site_url}", summary="特定站点统计信息", response_model=schemas.SiteStatistic)
|
||||
def read_statistic_by_domain(
|
||||
site_url: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)
|
||||
@@ -349,6 +349,17 @@ def read_site_by_domain(
|
||||
return schemas.SiteStatistic(domain=domain)
|
||||
|
||||
|
||||
@router.get("/statistic", summary="所有站点统计信息", response_model=List[schemas.SiteStatistic])
|
||||
def read_statistics(
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)
|
||||
) -> Any:
|
||||
"""
|
||||
获取所有站点统计信息
|
||||
"""
|
||||
return SiteStatistic.list(db)
|
||||
|
||||
|
||||
@router.get("/rss", summary="所有订阅站点", response_model=List[schemas.Site])
|
||||
def read_rss_sites(db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> List[dict]:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import tempfile
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
@@ -10,7 +11,7 @@ from typing import Optional, Union, Annotated
|
||||
import aiofiles
|
||||
import pillow_avif # noqa 用于自动注册AVIF支持
|
||||
from PIL import Image
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, Request, Response
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Header, Request, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app import schemas
|
||||
@@ -143,6 +144,7 @@ def fetch_image(
|
||||
def proxy_img(
|
||||
imgurl: str,
|
||||
proxy: bool = False,
|
||||
cache: bool = False,
|
||||
if_none_match: Annotated[str | None, Header()] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_resource_token)
|
||||
) -> Response:
|
||||
@@ -153,7 +155,7 @@ def proxy_img(
|
||||
hosts = [config.config.get("host") for config in MediaServerHelper().get_configs().values() if
|
||||
config and config.config and config.config.get("host")]
|
||||
allowed_domains = set(settings.SECURITY_IMAGE_DOMAINS) | set(hosts)
|
||||
return fetch_image(url=imgurl, proxy=proxy, use_disk_cache=False,
|
||||
return fetch_image(url=imgurl, proxy=proxy, use_disk_cache=cache,
|
||||
if_none_match=if_none_match, allowed_domains=allowed_domains)
|
||||
|
||||
|
||||
@@ -287,8 +289,11 @@ def get_setting(key: str,
|
||||
|
||||
|
||||
@router.post("/setting/{key}", summary="更新系统设置", response_model=schemas.Response)
|
||||
def set_setting(key: str, value: Union[list, dict, bool, int, str] = None,
|
||||
_: User = Depends(get_current_active_superuser)):
|
||||
def set_setting(
|
||||
key: str,
|
||||
value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None,
|
||||
_: User = Depends(get_current_active_superuser),
|
||||
):
|
||||
"""
|
||||
更新系统设置(仅管理员)
|
||||
"""
|
||||
@@ -446,30 +451,55 @@ def ruletest(title: str,
|
||||
|
||||
|
||||
@router.get("/nettest", summary="测试网络连通性")
|
||||
def nettest(url: str,
|
||||
proxy: bool,
|
||||
_: schemas.TokenPayload = Depends(verify_token)):
|
||||
def nettest(
|
||||
url: str,
|
||||
proxy: bool,
|
||||
include: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
):
|
||||
"""
|
||||
测试网络连通性
|
||||
"""
|
||||
# 记录开始的毫秒数
|
||||
start_time = datetime.now()
|
||||
headers = None
|
||||
if "github" in url or "{GITHUB_PROXY}" in url:
|
||||
# 这是github的连通性测试
|
||||
url = url.replace(
|
||||
"{GITHUB_PROXY}", UrlUtils.standardize_base_url(settings.GITHUB_PROXY or "")
|
||||
)
|
||||
headers = settings.GITHUB_HEADERS
|
||||
url = url.replace("{TMDBAPIKEY}", settings.TMDB_API_KEY)
|
||||
result = RequestUtils(proxies=settings.PROXY if proxy else None,
|
||||
ua=settings.USER_AGENT).get_res(url)
|
||||
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,
|
||||
).get_res(url)
|
||||
# 计时结束的毫秒数
|
||||
end_time = datetime.now()
|
||||
time = round((end_time - start_time).total_seconds() * 1000)
|
||||
# 计算相关秒数
|
||||
if result and result.status_code == 200:
|
||||
return schemas.Response(success=True, data={
|
||||
"time": round((end_time - start_time).microseconds / 1000)
|
||||
})
|
||||
elif result:
|
||||
return schemas.Response(success=False, message=f"错误码:{result.status_code}", data={
|
||||
"time": round((end_time - start_time).microseconds / 1000)
|
||||
})
|
||||
if result is None:
|
||||
return schemas.Response(success=False, message="无法连接", 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}")
|
||||
return schemas.Response(
|
||||
success=False,
|
||||
message=f"无效响应,不匹配 {include}",
|
||||
data={"time": time},
|
||||
)
|
||||
return schemas.Response(success=True, data={"time": time})
|
||||
else:
|
||||
return schemas.Response(success=False, message="网络连接失败!")
|
||||
return schemas.Response(
|
||||
success=False, message=f"错误码:{result.status_code}", data={"time": time}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/modulelist", summary="查询已加载的模块ID列表", response_model=schemas.Response)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import base64
|
||||
import re
|
||||
from typing import Any, List, Union
|
||||
from typing import Annotated, Any, List, Union
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, UploadFile, File
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
@@ -164,8 +164,11 @@ def get_config(key: str,
|
||||
|
||||
|
||||
@router.post("/config/{key}", summary="更新用户配置", response_model=schemas.Response)
|
||||
def set_config(key: str, value: Union[list, dict, bool, int, str] = None,
|
||||
current_user: User = Depends(get_current_active_user)):
|
||||
def set_config(
|
||||
key: str,
|
||||
value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
"""
|
||||
更新用户配置
|
||||
"""
|
||||
|
||||
@@ -22,7 +22,7 @@ from app.helper.service import ServiceConfigHelper
|
||||
from app.log import logger
|
||||
from app.schemas import TransferInfo, TransferTorrent, ExistMediaInfo, DownloadingTorrent, CommingMessage, Notification, \
|
||||
WebhookEventInfo, TmdbEpisode, MediaPerson, FileItem, TransferDirectoryConf
|
||||
from app.schemas.types import TorrentStatus, MediaType, MediaImageType, EventType
|
||||
from app.schemas.types import TorrentStatus, MediaType, MediaImageType, EventType, MessageChannel
|
||||
from app.utils.object import ObjectUtils
|
||||
|
||||
|
||||
@@ -94,9 +94,8 @@ class ChainBase(metaclass=ABCMeta):
|
||||
return ret is None
|
||||
|
||||
result = None
|
||||
plugin_modules = self.pluginmanager.get_plugin_modules()
|
||||
# 插件模块
|
||||
for plugin, module_dict in plugin_modules.items():
|
||||
for plugin, module_dict in self.pluginmanager.get_plugin_modules().items():
|
||||
plugin_id, plugin_name = plugin
|
||||
if method in module_dict:
|
||||
func = module_dict[method]
|
||||
@@ -138,10 +137,7 @@ class ChainBase(metaclass=ABCMeta):
|
||||
|
||||
# 系统模块
|
||||
logger.debug(f"请求系统模块执行:{method} ...")
|
||||
modules = self.modulemanager.get_running_modules(method)
|
||||
# 按优先级排序
|
||||
modules = sorted(modules, key=lambda x: x.get_priority())
|
||||
for module in modules:
|
||||
for module in sorted(self.modulemanager.get_running_modules(method), key=lambda x: x.get_priority()):
|
||||
module_id = module.__class__.__name__
|
||||
try:
|
||||
module_name = module.get_name()
|
||||
@@ -612,7 +608,8 @@ class ChainBase(metaclass=ABCMeta):
|
||||
# 发送消息事件
|
||||
self.eventmanager.send_event(etype=EventType.NoticeMessage, data={**message.dict(), "type": message.mtype})
|
||||
# 按原消息发送
|
||||
self.messagequeue.send_message("post_message", message=message)
|
||||
self.messagequeue.send_message("post_message", message=message,
|
||||
immediately=True if message.userid else False)
|
||||
|
||||
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:
|
||||
"""
|
||||
@@ -624,7 +621,8 @@ class ChainBase(metaclass=ABCMeta):
|
||||
note_list = [media.to_dict() for media in medias]
|
||||
self.messagehelper.put(message, role="user", note=note_list, title=message.title)
|
||||
self.messageoper.add(**message.dict(), note=note_list)
|
||||
return self.messagequeue.send_message("post_medias_message", message=message, medias=medias)
|
||||
return self.messagequeue.send_message("post_medias_message", message=message, medias=medias,
|
||||
immediately=True if message.userid else False)
|
||||
|
||||
def post_torrents_message(self, message: Notification, torrents: List[Context]) -> None:
|
||||
"""
|
||||
@@ -636,7 +634,21 @@ class ChainBase(metaclass=ABCMeta):
|
||||
note_list = [torrent.torrent_info.to_dict() for torrent in torrents]
|
||||
self.messagehelper.put(message, role="user", note=note_list, title=message.title)
|
||||
self.messageoper.add(**message.dict(), note=note_list)
|
||||
return self.messagequeue.send_message("post_torrents_message", message=message, torrents=torrents)
|
||||
return self.messagequeue.send_message("post_torrents_message", message=message, torrents=torrents,
|
||||
immediately=True if message.userid else False)
|
||||
|
||||
def delete_message(self, channel: MessageChannel, source: str,
|
||||
message_id: Union[str, int], chat_id: Optional[Union[str, int]] = None) -> bool:
|
||||
"""
|
||||
删除消息
|
||||
:param channel: 消息渠道
|
||||
:param source: 消息源(指定特定的消息模块)
|
||||
:param message_id: 消息ID
|
||||
:param chat_id: 聊天ID(如群组ID)
|
||||
:return: 删除是否成功
|
||||
"""
|
||||
return self.run_module("delete_message", channel=channel, source=source,
|
||||
message_id=message_id, chat_id=chat_id)
|
||||
|
||||
def metadata_img(self, mediainfo: MediaInfo,
|
||||
season: Optional[int] = None, episode: Optional[int] = None) -> Optional[dict]:
|
||||
|
||||
@@ -324,10 +324,12 @@ class DownloadChain(ChainBase):
|
||||
self.post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
source=source if channel else None,
|
||||
mtype=NotificationType.Download,
|
||||
ctype=ContentType.DownloadAdded,
|
||||
image=_media.get_message_image(),
|
||||
link=settings.MP_DOMAIN('/#/downloading'),
|
||||
userid=userid,
|
||||
username=username
|
||||
),
|
||||
meta=_meta,
|
||||
@@ -939,21 +941,21 @@ class DownloadChain(ChainBase):
|
||||
ret_torrents.append(torrent)
|
||||
return ret_torrents
|
||||
|
||||
def set_downloading(self, hash_str, oper: str) -> bool:
|
||||
def set_downloading(self, hash_str, oper: str, name: Optional[str] = None) -> bool:
|
||||
"""
|
||||
控制下载任务 start/stop
|
||||
"""
|
||||
if oper == "start":
|
||||
return self.start_torrents(hashs=[hash_str])
|
||||
return self.start_torrents(hashs=[hash_str], downloader=name)
|
||||
elif oper == "stop":
|
||||
return self.stop_torrents(hashs=[hash_str])
|
||||
return self.stop_torrents(hashs=[hash_str], downloader=name)
|
||||
return False
|
||||
|
||||
def remove_downloading(self, hash_str: str) -> bool:
|
||||
def remove_downloading(self, hash_str: str, name: Optional[str] = None) -> bool:
|
||||
"""
|
||||
删除下载任务
|
||||
"""
|
||||
return self.remove_torrents(hashs=[hash_str])
|
||||
return self.remove_torrents(hashs=[hash_str], downloader=name)
|
||||
|
||||
@eventmanager.register(EventType.DownloadFileDeleted)
|
||||
def download_file_deleted(self, event: Event):
|
||||
|
||||
@@ -19,7 +19,6 @@ from app.utils.string import StringUtils
|
||||
|
||||
recognize_lock = Lock()
|
||||
scraping_lock = Lock()
|
||||
scraping_files = []
|
||||
|
||||
|
||||
class MediaChain(ChainBase):
|
||||
@@ -35,25 +34,25 @@ class MediaChain(ChainBase):
|
||||
switchs = SystemConfigOper().get(SystemConfigKey.ScrapingSwitchs) or {}
|
||||
# 默认配置
|
||||
default_switchs = {
|
||||
'movie_nfo': True, # 电影NFO
|
||||
'movie_poster': True, # 电影海报
|
||||
'movie_backdrop': True, # 电影背景图
|
||||
'movie_logo': True, # 电影Logo
|
||||
'movie_disc': True, # 电影光盘图
|
||||
'movie_banner': True, # 电影横幅图
|
||||
'movie_thumb': True, # 电影缩略图
|
||||
'tv_nfo': True, # 电视剧NFO
|
||||
'tv_poster': True, # 电视剧海报
|
||||
'tv_backdrop': True, # 电视剧背景图
|
||||
'tv_banner': True, # 电视剧横幅图
|
||||
'tv_logo': True, # 电视剧Logo
|
||||
'tv_thumb': True, # 电视剧缩略图
|
||||
'season_nfo': True, # 季NFO
|
||||
'season_poster': True, # 季海报
|
||||
'season_banner': True, # 季横幅图
|
||||
'season_thumb': True, # 季缩略图
|
||||
'episode_nfo': True, # 集NFO
|
||||
'episode_thumb': True # 集缩略图
|
||||
'movie_nfo': True, # 电影NFO
|
||||
'movie_poster': True, # 电影海报
|
||||
'movie_backdrop': True, # 电影背景图
|
||||
'movie_logo': True, # 电影Logo
|
||||
'movie_disc': True, # 电影光盘图
|
||||
'movie_banner': True, # 电影横幅图
|
||||
'movie_thumb': True, # 电影缩略图
|
||||
'tv_nfo': True, # 电视剧NFO
|
||||
'tv_poster': True, # 电视剧海报
|
||||
'tv_backdrop': True, # 电视剧背景图
|
||||
'tv_banner': True, # 电视剧横幅图
|
||||
'tv_logo': True, # 电视剧Logo
|
||||
'tv_thumb': True, # 电视剧缩略图
|
||||
'season_nfo': True, # 季NFO
|
||||
'season_poster': True, # 季海报
|
||||
'season_banner': True, # 季横幅图
|
||||
'season_thumb': True, # 季缩略图
|
||||
'episode_nfo': True, # 集NFO
|
||||
'episode_thumb': True # 集缩略图
|
||||
}
|
||||
# 合并用户配置和默认配置
|
||||
for key, default_value in default_switchs.items():
|
||||
@@ -344,23 +343,49 @@ class MediaChain(ChainBase):
|
||||
return
|
||||
event_data = event.event_data or {}
|
||||
fileitem: FileItem = event_data.get("fileitem")
|
||||
file_list: List[str] = event_data.get("file_list", [])
|
||||
meta: MetaBase = event_data.get("meta")
|
||||
mediainfo: MediaInfo = event_data.get("mediainfo")
|
||||
overwrite = event_data.get("overwrite", False)
|
||||
if not fileitem:
|
||||
return
|
||||
|
||||
# 刮削锁
|
||||
with scraping_lock:
|
||||
if fileitem.path in scraping_files:
|
||||
# 检查文件项是否存在
|
||||
storagechain = StorageChain()
|
||||
if not storagechain.get_item(fileitem):
|
||||
logger.warn(f"文件项不存在:{fileitem.path}")
|
||||
return
|
||||
scraping_files.append(fileitem.path)
|
||||
try:
|
||||
# 执行刮削
|
||||
self.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo, overwrite=overwrite)
|
||||
finally:
|
||||
# 释放锁
|
||||
with scraping_lock:
|
||||
scraping_files.remove(fileitem.path)
|
||||
# 检查是否为目录
|
||||
if fileitem.type == "file":
|
||||
# 单个文件刮削
|
||||
self.scrape_metadata(fileitem=fileitem,
|
||||
mediainfo=mediainfo,
|
||||
init_folder=False,
|
||||
parent=storagechain.get_parent_item(fileitem),
|
||||
overwrite=overwrite)
|
||||
else:
|
||||
# 检查目的目录下是否已经有nfo刮削文件
|
||||
sub_files = storagechain.list_files(fileitem)
|
||||
if any(f.name.endswith('.nfo') for f in sub_files):
|
||||
logger.info(f"目录 {fileitem.path} 已有NFO文件,开始增量刮削...")
|
||||
for file_path in file_list:
|
||||
file_item = storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=Path(file_path))
|
||||
if file_item:
|
||||
# 对于电视剧文件,应该保存到与视频文件相同的目录
|
||||
# 而不是电视剧根目录
|
||||
self.scrape_metadata(fileitem=file_item,
|
||||
mediainfo=mediainfo,
|
||||
init_folder=False,
|
||||
parent=None, # 让函数内部自动获取正确的父目录
|
||||
overwrite=overwrite)
|
||||
else:
|
||||
# 执行全量刮削
|
||||
logger.info(f"开始全量刮削目录 {fileitem.path} ...")
|
||||
self.scrape_metadata(fileitem=fileitem, meta=meta, init_folder=True,
|
||||
mediainfo=mediainfo, overwrite=overwrite)
|
||||
|
||||
def scrape_metadata(self, fileitem: schemas.FileItem,
|
||||
meta: MetaBase = None, mediainfo: MediaInfo = None,
|
||||
@@ -427,7 +452,7 @@ class MediaChain(ChainBase):
|
||||
"""
|
||||
try:
|
||||
logger.info(f"正在下载图片:{_url} ...")
|
||||
r = RequestUtils(proxies=settings.PROXY).get_res(url=_url)
|
||||
r = RequestUtils(proxies=settings.PROXY, ua=settings.USER_AGENT).get_res(url=_url)
|
||||
if r:
|
||||
return r.content
|
||||
else:
|
||||
@@ -436,6 +461,9 @@ class MediaChain(ChainBase):
|
||||
logger.error(f"{_url} 图片下载失败:{str(err)}!")
|
||||
return None
|
||||
|
||||
if not fileitem:
|
||||
return
|
||||
|
||||
# 当前文件路径
|
||||
filepath = Path(fileitem.path)
|
||||
if fileitem.type == "file" \
|
||||
@@ -448,7 +476,7 @@ class MediaChain(ChainBase):
|
||||
if not mediainfo:
|
||||
logger.warn(f"{filepath} 无法识别文件媒体信息!")
|
||||
return
|
||||
|
||||
|
||||
# 获取刮削开关配置
|
||||
scraping_switchs = self._get_scraping_switchs()
|
||||
logger.info(f"开始刮削:{filepath} ...")
|
||||
@@ -464,6 +492,8 @@ class MediaChain(ChainBase):
|
||||
movie_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
||||
if movie_nfo:
|
||||
# 保存或上传nfo文件到上级目录
|
||||
if not parent:
|
||||
parent = storagechain.get_parent_item(fileitem)
|
||||
__save_file(_fileitem=parent, _path=nfo_path, _content=movie_nfo)
|
||||
else:
|
||||
logger.warn(f"{filepath.name} nfo文件生成失败!")
|
||||
@@ -494,8 +524,9 @@ class MediaChain(ChainBase):
|
||||
files = __list_files(_fileitem=fileitem)
|
||||
for file in files:
|
||||
self.scrape_metadata(fileitem=file,
|
||||
meta=meta, mediainfo=mediainfo,
|
||||
init_folder=False, parent=fileitem,
|
||||
mediainfo=mediainfo,
|
||||
init_folder=False,
|
||||
parent=fileitem,
|
||||
overwrite=overwrite)
|
||||
# 生成目录内图片文件
|
||||
if init_folder:
|
||||
@@ -506,7 +537,9 @@ class MediaChain(ChainBase):
|
||||
# 根据图片类型检查开关
|
||||
if 'poster' in image_name.lower():
|
||||
should_scrape = scraping_switchs.get('movie_poster', True)
|
||||
elif 'backdrop' in image_name.lower() or 'fanart' in image_name.lower():
|
||||
elif ('backdrop' in image_name.lower()
|
||||
or 'fanart' in image_name.lower()
|
||||
or 'background' in image_name.lower()):
|
||||
should_scrape = scraping_switchs.get('movie_backdrop', True)
|
||||
elif 'logo' in image_name.lower():
|
||||
should_scrape = scraping_switchs.get('movie_logo', True)
|
||||
@@ -518,7 +551,7 @@ class MediaChain(ChainBase):
|
||||
should_scrape = scraping_switchs.get('movie_thumb', True)
|
||||
else:
|
||||
should_scrape = True # 未知类型默认刮削
|
||||
|
||||
|
||||
if should_scrape:
|
||||
image_path = filepath.with_name(image_name)
|
||||
if overwrite or not storagechain.get_file_item(storage=fileitem.storage,
|
||||
@@ -585,11 +618,11 @@ class MediaChain(ChainBase):
|
||||
else:
|
||||
logger.info("集缩略图刮削已关闭,跳过")
|
||||
else:
|
||||
# 当前为目录,处理目录内的文件
|
||||
# 当前为电视剧目录,处理目录内的文件
|
||||
files = __list_files(_fileitem=fileitem)
|
||||
for file in files:
|
||||
self.scrape_metadata(fileitem=file,
|
||||
meta=meta, mediainfo=mediainfo,
|
||||
mediainfo=mediainfo,
|
||||
parent=fileitem if file.type == "file" else None,
|
||||
init_folder=True if file.type == "dir" else False,
|
||||
overwrite=overwrite)
|
||||
@@ -651,13 +684,14 @@ class MediaChain(ChainBase):
|
||||
should_scrape = scraping_switchs.get('season_thumb', True)
|
||||
else:
|
||||
should_scrape = True # 未知类型默认刮削
|
||||
|
||||
|
||||
if should_scrape:
|
||||
image_path = filepath.with_name(image_name)
|
||||
# 只下载当前刮削季的图片
|
||||
image_season = "00" if "specials" in image_name else image_name[6:8]
|
||||
if image_season != str(season_meta.begin_season).rjust(2, '0'):
|
||||
logger.info(f"当前刮削季为:{season_meta.begin_season},跳过文件:{image_path}")
|
||||
logger.info(
|
||||
f"当前刮削季为:{season_meta.begin_season},跳过文件:{image_path}")
|
||||
continue
|
||||
if overwrite or not storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
@@ -700,7 +734,9 @@ class MediaChain(ChainBase):
|
||||
# 根据电视剧图片类型检查开关
|
||||
if 'poster' in image_name.lower():
|
||||
should_scrape = scraping_switchs.get('tv_poster', True)
|
||||
elif 'backdrop' in image_name.lower() or 'fanart' in image_name.lower():
|
||||
elif ('backdrop' in image_name.lower()
|
||||
or 'fanart' in image_name.lower()
|
||||
or 'background' in image_name.lower()):
|
||||
should_scrape = scraping_switchs.get('tv_backdrop', True)
|
||||
elif 'banner' in image_name.lower():
|
||||
should_scrape = scraping_switchs.get('tv_banner', True)
|
||||
@@ -710,7 +746,7 @@ class MediaChain(ChainBase):
|
||||
should_scrape = scraping_switchs.get('tv_thumb', True)
|
||||
else:
|
||||
should_scrape = True # 未知类型默认刮削
|
||||
|
||||
|
||||
if should_scrape:
|
||||
image_path = filepath / image_name
|
||||
if overwrite or not storagechain.get_file_item(storage=fileitem.storage,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -202,16 +202,15 @@ class SearchChain(ChainBase):
|
||||
# 过滤完成
|
||||
progress.update(value=50, text=f'过滤完成,剩余 {len(torrents)} 个资源', key=ProgressKey.Search)
|
||||
|
||||
# 开始匹配
|
||||
_match_torrents = []
|
||||
# 总数
|
||||
_total = len(torrents)
|
||||
# 已处理数
|
||||
_count = 0
|
||||
|
||||
# 开始匹配
|
||||
_match_torrents = []
|
||||
torrenthelper = TorrentHelper()
|
||||
|
||||
if mediainfo:
|
||||
try:
|
||||
# 英文标题应该在别名/原标题中,不需要再匹配
|
||||
logger.info(f"开始匹配结果 标题:{mediainfo.title},原标题:{mediainfo.original_title},别名:{mediainfo.names}")
|
||||
progress.update(value=51, text=f'开始匹配,总 {_total} 个资源 ...', key=ProgressKey.Search)
|
||||
@@ -256,16 +255,18 @@ class SearchChain(ChainBase):
|
||||
progress.update(value=97,
|
||||
text=f'匹配完成,共匹配到 {len(_match_torrents)} 个资源',
|
||||
key=ProgressKey.Search)
|
||||
else:
|
||||
_match_torrents = [(t, MetaInfo(title=t.title, subtitle=t.description)) for t in torrents]
|
||||
|
||||
# 去掉mediainfo中多余的数据
|
||||
mediainfo.clear()
|
||||
|
||||
# 组装上下文
|
||||
contexts = [Context(torrent_info=t[0],
|
||||
media_info=mediainfo,
|
||||
meta_info=t[1]) for t in _match_torrents]
|
||||
# 去掉mediainfo中多余的数据
|
||||
mediainfo.clear()
|
||||
# 组装上下文
|
||||
contexts = [Context(torrent_info=t[0],
|
||||
media_info=mediainfo,
|
||||
meta_info=t[1]) for t in _match_torrents]
|
||||
finally:
|
||||
torrents.clear()
|
||||
del torrents
|
||||
_match_torrents.clear()
|
||||
del _match_torrents
|
||||
|
||||
# 排序
|
||||
progress.update(value=99,
|
||||
@@ -364,6 +365,7 @@ class SearchChain(ChainBase):
|
||||
logger.info(f"站点搜索完成,有效资源数:{len(results)},总耗时 {(end_time - start_time).seconds} 秒")
|
||||
# 结束进度
|
||||
progress.end(ProgressKey.Search)
|
||||
|
||||
# 返回
|
||||
return results
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import base64
|
||||
import gc
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Tuple, Union, Dict
|
||||
@@ -92,10 +93,9 @@ class SiteChain(ChainBase):
|
||||
"""
|
||||
刷新所有站点的用户数据
|
||||
"""
|
||||
sites = SitesHelper().get_indexers()
|
||||
any_site_updated = False
|
||||
result = {}
|
||||
for site in sites:
|
||||
for site in SitesHelper().get_indexers():
|
||||
if global_vars.is_system_stopped:
|
||||
return None
|
||||
if site.get("is_active"):
|
||||
@@ -107,6 +107,11 @@ class SiteChain(ChainBase):
|
||||
EventManager().send_event(EventType.SiteRefreshed, {
|
||||
"site_id": "*"
|
||||
})
|
||||
|
||||
# 如果不是大内存模式,进行垃圾回收
|
||||
if not settings.BIG_MEMORY_MODE:
|
||||
gc.collect()
|
||||
|
||||
return result
|
||||
|
||||
def is_special_site(self, domain: str) -> bool:
|
||||
@@ -266,16 +271,20 @@ class SiteChain(ChainBase):
|
||||
logger.error(f"获取站点页面失败:{url}")
|
||||
return favicon_url, None
|
||||
html = etree.HTML(html_text)
|
||||
if StringUtils.is_valid_html_element(html):
|
||||
fav_link = html.xpath('//head/link[contains(@rel, "icon")]/@href')
|
||||
if fav_link:
|
||||
favicon_url = urljoin(url, fav_link[0])
|
||||
try:
|
||||
if StringUtils.is_valid_html_element(html):
|
||||
fav_link = html.xpath('//head/link[contains(@rel, "icon")]/@href')
|
||||
if fav_link:
|
||||
favicon_url = urljoin(url, fav_link[0])
|
||||
|
||||
res = RequestUtils(cookies=cookie, timeout=15, ua=ua).get_res(url=favicon_url)
|
||||
if res:
|
||||
return favicon_url, base64.b64encode(res.content).decode()
|
||||
else:
|
||||
logger.error(f"获取站点图标失败:{favicon_url}")
|
||||
res = RequestUtils(cookies=cookie, timeout=15, ua=ua).get_res(url=favicon_url)
|
||||
if res:
|
||||
return favicon_url, base64.b64encode(res.content).decode()
|
||||
else:
|
||||
logger.error(f"获取站点图标失败:{favicon_url}")
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
return favicon_url, None
|
||||
|
||||
def sync_cookies(self, manual=False) -> Tuple[bool, str]:
|
||||
@@ -351,9 +360,10 @@ class SiteChain(ChainBase):
|
||||
ua=settings.USER_AGENT
|
||||
).get_res(url=domain_url)
|
||||
if res and res.status_code in [200, 500, 403]:
|
||||
if not indexer.get("public") and not SiteUtils.is_logged_in(res.text):
|
||||
content = res.text
|
||||
if not indexer.get("public") and not SiteUtils.is_logged_in(content):
|
||||
_fail_count += 1
|
||||
if under_challenge(res.text):
|
||||
if under_challenge(content):
|
||||
logger.warn(f"站点 {indexer.get('name')} 被Cloudflare防护,无法登录,无法添加站点")
|
||||
continue
|
||||
logger.warn(
|
||||
@@ -571,8 +581,9 @@ class SiteChain(ChainBase):
|
||||
).get_res(url=site_url)
|
||||
# 判断登录状态
|
||||
if res and res.status_code in [200, 500, 403]:
|
||||
if not public and not SiteUtils.is_logged_in(res.text):
|
||||
if under_challenge(res.text):
|
||||
content = res.text
|
||||
if not public and not SiteUtils.is_logged_in(content):
|
||||
if under_challenge(content):
|
||||
msg = "站点被Cloudflare防护,请打开站点浏览器仿真"
|
||||
elif res.status_code == 200:
|
||||
msg = "Cookie已失效"
|
||||
|
||||
@@ -110,11 +110,17 @@ class StorageChain(ChainBase):
|
||||
"""
|
||||
return self.run_module("get_parent_item", fileitem=fileitem)
|
||||
|
||||
def snapshot_storage(self, storage: str, path: Path) -> Optional[Dict[str, float]]:
|
||||
def snapshot_storage(self, storage: str, path: Path,
|
||||
last_snapshot_time: float = None, max_depth: int = 5) -> Optional[Dict[str, Dict]]:
|
||||
"""
|
||||
快照存储
|
||||
:param storage: 存储类型
|
||||
:param path: 路径
|
||||
:param last_snapshot_time: 上次快照时间,用于增量快照
|
||||
:param max_depth: 最大递归深度,避免过深遍历
|
||||
"""
|
||||
return self.run_module("snapshot_storage", storage=storage, path=path)
|
||||
return self.run_module("snapshot_storage", storage=storage, path=path,
|
||||
last_snapshot_time=last_snapshot_time, max_depth=max_depth)
|
||||
|
||||
def storage_usage(self, storage: str) -> Optional[schemas.StorageUsage]:
|
||||
"""
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import copy
|
||||
import gc
|
||||
import json
|
||||
import random
|
||||
import threading
|
||||
@@ -38,6 +39,8 @@ class SubscribeChain(ChainBase):
|
||||
"""
|
||||
|
||||
_rlock = threading.RLock()
|
||||
# 避免莫名原因导致长时间持有锁
|
||||
_LOCK_TIMOUT = 3600 * 2
|
||||
|
||||
def add(self, title: str, year: str,
|
||||
mtype: MediaType = None,
|
||||
@@ -221,10 +224,13 @@ class SubscribeChain(ChainBase):
|
||||
# 订阅成功按规则发送消息
|
||||
self.post_message(
|
||||
schemas.Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
mtype=NotificationType.Subscribe,
|
||||
ctype=ContentType.SubscribeAdded,
|
||||
image=mediainfo.get_message_image(),
|
||||
link=link,
|
||||
userid=userid,
|
||||
username=username
|
||||
),
|
||||
meta=metainfo,
|
||||
@@ -275,156 +281,179 @@ class SubscribeChain(ChainBase):
|
||||
:param manual: 是否手动搜索
|
||||
:return: 更新订阅状态为R或删除订阅
|
||||
"""
|
||||
with self._rlock:
|
||||
logger.debug(f"search lock acquired at {datetime.now()}")
|
||||
lock_acquired = False
|
||||
try:
|
||||
if lock_acquired := self._rlock.acquire(
|
||||
blocking=True, timeout=self._LOCK_TIMOUT
|
||||
):
|
||||
logger.debug(f"search lock acquired at {datetime.now()}")
|
||||
else:
|
||||
logger.warn("search上锁超时")
|
||||
|
||||
subscribeoper = SubscribeOper()
|
||||
if sid:
|
||||
subscribe = subscribeoper.get(sid)
|
||||
subscribes = [subscribe] if subscribe else []
|
||||
else:
|
||||
subscribes = subscribeoper.list(self.get_states_for_search(state))
|
||||
# 遍历订阅
|
||||
for subscribe in subscribes:
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
mediakey = subscribe.tmdbid or subscribe.doubanid
|
||||
custom_word_list = subscribe.custom_words.split("\n") if subscribe.custom_words else None
|
||||
# 校验当前时间减订阅创建时间是否大于1分钟,否则跳过先,留出编辑订阅的时间
|
||||
if subscribe.date:
|
||||
now = datetime.now()
|
||||
subscribe_time = datetime.strptime(subscribe.date, '%Y-%m-%d %H:%M:%S')
|
||||
if (now - subscribe_time).total_seconds() < 60:
|
||||
logger.debug(f"订阅标题:{subscribe.name} 新增小于1分钟,暂不搜索...")
|
||||
continue
|
||||
# 随机休眠1-5分钟
|
||||
if not sid and state in ['R', 'P']:
|
||||
sleep_time = random.randint(60, 300)
|
||||
logger.info(f'订阅搜索随机休眠 {sleep_time} 秒 ...')
|
||||
time.sleep(sleep_time)
|
||||
try:
|
||||
logger.info(f'开始搜索订阅,标题:{subscribe.name} ...')
|
||||
# 生成元数据
|
||||
meta = MetaInfo(subscribe.name)
|
||||
meta.year = subscribe.year
|
||||
meta.begin_season = subscribe.season or None
|
||||
|
||||
try:
|
||||
# 遍历订阅
|
||||
for subscribe in subscribes:
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
mediakey = subscribe.tmdbid or subscribe.doubanid
|
||||
custom_word_list = subscribe.custom_words.split("\n") if subscribe.custom_words else None
|
||||
# 校验当前时间减订阅创建时间是否大于1分钟,否则跳过先,留出编辑订阅的时间
|
||||
if subscribe.date:
|
||||
now = datetime.now()
|
||||
subscribe_time = datetime.strptime(subscribe.date, '%Y-%m-%d %H:%M:%S')
|
||||
if (now - subscribe_time).total_seconds() < 60:
|
||||
logger.debug(f"订阅标题:{subscribe.name} 新增小于1分钟,暂不搜索...")
|
||||
continue
|
||||
# 随机休眠1-5分钟
|
||||
if not sid and state in ['R', 'P']:
|
||||
sleep_time = random.randint(60, 300)
|
||||
logger.info(f'订阅搜索随机休眠 {sleep_time} 秒 ...')
|
||||
time.sleep(sleep_time)
|
||||
try:
|
||||
meta.type = MediaType(subscribe.type)
|
||||
except ValueError:
|
||||
logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}')
|
||||
continue
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
|
||||
tmdbid=subscribe.tmdbid,
|
||||
doubanid=subscribe.doubanid,
|
||||
episode_group=subscribe.episode_group,
|
||||
cache=False)
|
||||
if not mediainfo:
|
||||
logger.warn(
|
||||
f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}')
|
||||
continue
|
||||
logger.info(f'开始搜索订阅,标题:{subscribe.name} ...')
|
||||
# 生成元数据
|
||||
meta = MetaInfo(subscribe.name)
|
||||
meta.year = subscribe.year
|
||||
meta.begin_season = subscribe.season or None
|
||||
try:
|
||||
meta.type = MediaType(subscribe.type)
|
||||
except ValueError:
|
||||
logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}')
|
||||
continue
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
|
||||
tmdbid=subscribe.tmdbid,
|
||||
doubanid=subscribe.doubanid,
|
||||
episode_group=subscribe.episode_group,
|
||||
cache=False)
|
||||
if not mediainfo:
|
||||
logger.warn(
|
||||
f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}')
|
||||
continue
|
||||
|
||||
# 如果媒体已存在或已下载完毕,跳过当前订阅处理
|
||||
exist_flag, no_exists = self.check_and_handle_existing_media(subscribe=subscribe,
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
mediakey=mediakey)
|
||||
if exist_flag:
|
||||
continue
|
||||
# 如果媒体已存在或已下载完毕,跳过当前订阅处理
|
||||
exist_flag, no_exists = self.check_and_handle_existing_media(subscribe=subscribe,
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
mediakey=mediakey)
|
||||
if exist_flag:
|
||||
continue
|
||||
|
||||
# 站点范围
|
||||
sites = self.get_sub_sites(subscribe)
|
||||
# 站点范围
|
||||
sites = self.get_sub_sites(subscribe)
|
||||
|
||||
# 优先级过滤规则
|
||||
if subscribe.best_version:
|
||||
rule_groups = subscribe.filter_groups \
|
||||
or SystemConfigOper().get(SystemConfigKey.BestVersionFilterRuleGroups) or []
|
||||
else:
|
||||
rule_groups = subscribe.filter_groups \
|
||||
or SystemConfigOper().get(SystemConfigKey.SubscribeFilterRuleGroups) or []
|
||||
|
||||
# 搜索,同时电视剧会过滤掉不需要的剧集
|
||||
contexts = SearchChain().process(mediainfo=mediainfo,
|
||||
keyword=subscribe.keyword,
|
||||
no_exists=no_exists,
|
||||
sites=sites,
|
||||
rule_groups=rule_groups,
|
||||
area="imdbid" if subscribe.search_imdbid else "title",
|
||||
custom_words=custom_word_list,
|
||||
filter_params=self.get_params(subscribe))
|
||||
if not contexts:
|
||||
logger.warn(f'订阅 {subscribe.keyword or subscribe.name} 未搜索到资源')
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta,
|
||||
mediainfo=mediainfo, lefts=no_exists)
|
||||
continue
|
||||
|
||||
# 过滤搜索结果
|
||||
matched_contexts = []
|
||||
for context in contexts:
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
torrent_meta = context.meta_info
|
||||
torrent_info = context.torrent_info
|
||||
torrent_mediainfo = context.media_info
|
||||
|
||||
# 洗版
|
||||
# 优先级过滤规则
|
||||
if subscribe.best_version:
|
||||
# 洗版时,非整季不要
|
||||
if torrent_mediainfo.type == MediaType.TV:
|
||||
if torrent_meta.episode_list:
|
||||
logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季')
|
||||
continue
|
||||
# 洗版时,优先级小于等于已下载优先级的不要
|
||||
if subscribe.current_priority \
|
||||
and torrent_info.pri_order <= subscribe.current_priority:
|
||||
logger.info(
|
||||
f'{subscribe.name} 正在洗版,{torrent_info.title} 优先级低于或等于已下载优先级')
|
||||
continue
|
||||
# 更新订阅自定义属性
|
||||
if subscribe.media_category:
|
||||
torrent_mediainfo.category = subscribe.media_category
|
||||
if subscribe.episode_group:
|
||||
torrent_mediainfo.episode_group = subscribe.episode_group
|
||||
matched_contexts.append(context)
|
||||
rule_groups = subscribe.filter_groups \
|
||||
or SystemConfigOper().get(SystemConfigKey.BestVersionFilterRuleGroups) or []
|
||||
else:
|
||||
rule_groups = subscribe.filter_groups \
|
||||
or SystemConfigOper().get(SystemConfigKey.SubscribeFilterRuleGroups) or []
|
||||
|
||||
if not matched_contexts:
|
||||
logger.warn(f'订阅 {subscribe.name} 没有符合过滤条件的资源')
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta,
|
||||
mediainfo=mediainfo, lefts=no_exists)
|
||||
continue
|
||||
# 搜索,同时电视剧会过滤掉不需要的剧集
|
||||
contexts = SearchChain().process(mediainfo=mediainfo,
|
||||
keyword=subscribe.keyword,
|
||||
no_exists=no_exists,
|
||||
sites=sites,
|
||||
rule_groups=rule_groups,
|
||||
area="imdbid" if subscribe.search_imdbid else "title",
|
||||
custom_words=custom_word_list,
|
||||
filter_params=self.get_params(subscribe))
|
||||
if not contexts:
|
||||
logger.warn(f'订阅 {subscribe.keyword or subscribe.name} 未搜索到资源')
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta,
|
||||
mediainfo=mediainfo, lefts=no_exists)
|
||||
continue
|
||||
|
||||
# 自动下载
|
||||
downloads, lefts = DownloadChain().batch_download(
|
||||
contexts=matched_contexts,
|
||||
no_exists=no_exists,
|
||||
userid=subscribe.username,
|
||||
username=subscribe.username,
|
||||
save_path=subscribe.save_path,
|
||||
downloader=subscribe.downloader,
|
||||
source=self.get_subscribe_source_keyword(subscribe)
|
||||
)
|
||||
# 过滤搜索结果
|
||||
matched_contexts = []
|
||||
try:
|
||||
for context in contexts:
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
torrent_meta = context.meta_info
|
||||
torrent_info = context.torrent_info
|
||||
torrent_mediainfo = context.media_info
|
||||
|
||||
# 同步外部修改,更新订阅信息
|
||||
subscribe = subscribeoper.get(subscribe.id)
|
||||
# 洗版
|
||||
if subscribe.best_version:
|
||||
# 洗版时,非整季不要
|
||||
if torrent_mediainfo.type == MediaType.TV:
|
||||
if torrent_meta.episode_list:
|
||||
logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季')
|
||||
continue
|
||||
# 洗版时,优先级小于等于已下载优先级的不要
|
||||
if subscribe.current_priority \
|
||||
and torrent_info.pri_order <= subscribe.current_priority:
|
||||
logger.info(
|
||||
f'{subscribe.name} 正在洗版,{torrent_info.title} 优先级低于或等于已下载优先级')
|
||||
continue
|
||||
# 更新订阅自定义属性
|
||||
if subscribe.media_category:
|
||||
torrent_mediainfo.category = subscribe.media_category
|
||||
if subscribe.episode_group:
|
||||
torrent_mediainfo.episode_group = subscribe.episode_group
|
||||
matched_contexts.append(context)
|
||||
finally:
|
||||
contexts.clear()
|
||||
del contexts
|
||||
|
||||
# 判断是否应完成订阅
|
||||
if subscribe:
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo,
|
||||
downloads=downloads, lefts=lefts)
|
||||
finally:
|
||||
# 如果状态为N则更新为R
|
||||
if subscribe and subscribe.state == 'N':
|
||||
subscribeoper.update(subscribe.id, {'state': 'R'})
|
||||
if not matched_contexts:
|
||||
logger.warn(f'订阅 {subscribe.name} 没有符合过滤条件的资源')
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta,
|
||||
mediainfo=mediainfo, lefts=no_exists)
|
||||
continue
|
||||
|
||||
# 手动触发时发送系统消息
|
||||
if manual:
|
||||
if subscribes:
|
||||
if sid:
|
||||
self.messagehelper.put(f'{subscribes[0].name} 搜索完成!', title="订阅搜索", role="system")
|
||||
# 自动下载
|
||||
downloads, lefts = DownloadChain().batch_download(
|
||||
contexts=matched_contexts,
|
||||
no_exists=no_exists,
|
||||
username=subscribe.username,
|
||||
save_path=subscribe.save_path,
|
||||
downloader=subscribe.downloader,
|
||||
source=self.get_subscribe_source_keyword(subscribe)
|
||||
)
|
||||
|
||||
# 同步外部修改,更新订阅信息
|
||||
subscribe = subscribeoper.get(subscribe.id)
|
||||
|
||||
# 判断是否应完成订阅
|
||||
if subscribe:
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo,
|
||||
downloads=downloads, lefts=lefts)
|
||||
finally:
|
||||
# 如果状态为N则更新为R
|
||||
if subscribe and subscribe.state == 'N':
|
||||
subscribeoper.update(subscribe.id, {'state': 'R'})
|
||||
|
||||
# 手动触发时发送系统消息
|
||||
if manual:
|
||||
if subscribes:
|
||||
if sid:
|
||||
self.messagehelper.put(f'{subscribes[0].name} 搜索完成!', title="订阅搜索", role="system")
|
||||
else:
|
||||
self.messagehelper.put('所有订阅搜索完成!', title="订阅搜索", role="system")
|
||||
else:
|
||||
self.messagehelper.put('所有订阅搜索完成!', title="订阅搜索", role="system")
|
||||
else:
|
||||
self.messagehelper.put('没有找到订阅!', title="订阅搜索", role="system")
|
||||
logger.debug(f"search Lock released at {datetime.now()}")
|
||||
self.messagehelper.put('没有找到订阅!', title="订阅搜索", role="system")
|
||||
|
||||
finally:
|
||||
subscribes.clear()
|
||||
del subscribes
|
||||
finally:
|
||||
if lock_acquired:
|
||||
self._rlock.release()
|
||||
logger.debug(f"search Lock released at {datetime.now()}")
|
||||
|
||||
# 如果不是大内存模式,进行垃圾回收
|
||||
if not settings.BIG_MEMORY_MODE:
|
||||
gc.collect()
|
||||
|
||||
def update_subscribe_priority(self, subscribe: Subscribe, meta: MetaBase,
|
||||
mediainfo: MediaInfo, downloads: Optional[List[Context]]):
|
||||
@@ -497,6 +526,9 @@ class SubscribeChain(ChainBase):
|
||||
self.match(
|
||||
TorrentsChain().refresh(sites=sites)
|
||||
)
|
||||
# 如果不是大内存模式,进行垃圾回收
|
||||
if not settings.BIG_MEMORY_MODE:
|
||||
gc.collect()
|
||||
|
||||
@staticmethod
|
||||
def get_sub_sites(subscribe: Subscribe) -> List[int]:
|
||||
@@ -525,13 +557,9 @@ class SubscribeChain(ChainBase):
|
||||
获取订阅中涉及的所有站点清单(节约资源)
|
||||
:return: 返回[]代表所有站点命中,返回None代表没有订阅
|
||||
"""
|
||||
# 查询所有订阅
|
||||
subscribes = SubscribeOper().list(self.get_states_for_search('R'))
|
||||
if not subscribes:
|
||||
return None
|
||||
ret_sites = []
|
||||
# 刷新订阅选中的Rss站点
|
||||
for subscribe in subscribes:
|
||||
for subscribe in SubscribeOper().list(self.get_states_for_search('R')):
|
||||
# 刷新选中的站点
|
||||
ret_sites.extend(self.get_sub_sites(subscribe))
|
||||
# 去重
|
||||
@@ -548,10 +576,14 @@ class SubscribeChain(ChainBase):
|
||||
logger.warn('没有缓存资源,无法匹配订阅')
|
||||
return
|
||||
|
||||
with self._rlock:
|
||||
logger.debug(f"match lock acquired at {datetime.now()}")
|
||||
# 所有订阅
|
||||
subscribes = SubscribeOper().list(self.get_states_for_search('R'))
|
||||
lock_acquired = False
|
||||
try:
|
||||
if lock_acquired := self._rlock.acquire(
|
||||
blocking=True, timeout=self._LOCK_TIMOUT
|
||||
):
|
||||
logger.debug(f"match lock acquired at {datetime.now()}")
|
||||
else:
|
||||
logger.warn("match上锁超时")
|
||||
|
||||
# 预识别所有未识别的种子
|
||||
processed_torrents: Dict[str, List[Context]] = {}
|
||||
@@ -574,234 +606,243 @@ class SubscribeChain(ChainBase):
|
||||
# 添加已预处理
|
||||
processed_torrents[domain].append(context)
|
||||
|
||||
# 遍历订阅
|
||||
for subscribe in subscribes:
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
logger.info(f'开始匹配订阅,标题:{subscribe.name} ...')
|
||||
mediakey = subscribe.tmdbid or subscribe.doubanid
|
||||
# 生成元数据
|
||||
meta = MetaInfo(subscribe.name)
|
||||
meta.year = subscribe.year
|
||||
meta.begin_season = subscribe.season or None
|
||||
try:
|
||||
meta.type = MediaType(subscribe.type)
|
||||
except ValueError:
|
||||
logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}')
|
||||
continue
|
||||
# 订阅的站点域名列表
|
||||
domains = []
|
||||
if subscribe.sites:
|
||||
domains = SiteOper().get_domains_by_ids(subscribe.sites)
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
|
||||
tmdbid=subscribe.tmdbid,
|
||||
doubanid=subscribe.doubanid,
|
||||
episode_group=subscribe.episode_group,
|
||||
cache=False)
|
||||
if not mediainfo:
|
||||
logger.warn(
|
||||
f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}')
|
||||
continue
|
||||
|
||||
# 如果媒体已存在或已下载完毕,跳过当前订阅处理
|
||||
exist_flag, no_exists = self.check_and_handle_existing_media(subscribe=subscribe, meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
mediakey=mediakey)
|
||||
if exist_flag:
|
||||
continue
|
||||
|
||||
# 清理多余信息
|
||||
mediainfo.clear()
|
||||
|
||||
# 订阅识别词
|
||||
if subscribe.custom_words:
|
||||
custom_words_list = subscribe.custom_words.split("\n")
|
||||
else:
|
||||
custom_words_list = None
|
||||
|
||||
# 遍历预识别后的种子
|
||||
_match_context = []
|
||||
torrenthelper = TorrentHelper()
|
||||
systemconfig = SystemConfigOper()
|
||||
wordsmatcher = WordsMatcher()
|
||||
for domain, contexts in processed_torrents.items():
|
||||
# 所有订阅
|
||||
subscribes = SubscribeOper().list(self.get_states_for_search('R'))
|
||||
try:
|
||||
for subscribe in subscribes:
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
if domains and domain not in domains:
|
||||
logger.info(f'开始匹配订阅,标题:{subscribe.name} ...')
|
||||
mediakey = subscribe.tmdbid or subscribe.doubanid
|
||||
# 生成元数据
|
||||
meta = MetaInfo(subscribe.name)
|
||||
meta.year = subscribe.year
|
||||
meta.begin_season = subscribe.season or None
|
||||
try:
|
||||
meta.type = MediaType(subscribe.type)
|
||||
except ValueError:
|
||||
logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}')
|
||||
continue
|
||||
logger.debug(f'开始匹配站点:{domain},共缓存了 {len(contexts)} 个种子...')
|
||||
for context in contexts:
|
||||
# 订阅的站点域名列表
|
||||
domains = []
|
||||
if subscribe.sites:
|
||||
domains = SiteOper().get_domains_by_ids(subscribe.sites)
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
|
||||
tmdbid=subscribe.tmdbid,
|
||||
doubanid=subscribe.doubanid,
|
||||
episode_group=subscribe.episode_group,
|
||||
cache=False)
|
||||
if not mediainfo:
|
||||
logger.warn(
|
||||
f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}')
|
||||
continue
|
||||
|
||||
# 如果媒体已存在或已下载完毕,跳过当前订阅处理
|
||||
exist_flag, no_exists = self.check_and_handle_existing_media(subscribe=subscribe, meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
mediakey=mediakey)
|
||||
if exist_flag:
|
||||
continue
|
||||
|
||||
# 清理多余信息
|
||||
mediainfo.clear()
|
||||
|
||||
# 订阅识别词
|
||||
if subscribe.custom_words:
|
||||
custom_words_list = subscribe.custom_words.split("\n")
|
||||
else:
|
||||
custom_words_list = None
|
||||
|
||||
# 遍历预识别后的种子
|
||||
_match_context = []
|
||||
torrenthelper = TorrentHelper()
|
||||
systemconfig = SystemConfigOper()
|
||||
wordsmatcher = WordsMatcher()
|
||||
for domain, contexts in processed_torrents.items():
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
# 提取信息
|
||||
_context = copy.copy(context)
|
||||
torrent_meta = _context.meta_info
|
||||
torrent_mediainfo = _context.media_info
|
||||
torrent_info = _context.torrent_info
|
||||
|
||||
# 不在订阅站点范围的不处理
|
||||
sub_sites = self.get_sub_sites(subscribe)
|
||||
if sub_sites and torrent_info.site not in sub_sites:
|
||||
logger.debug(f"{torrent_info.site_name} - {torrent_info.title} 不符合订阅站点要求")
|
||||
if domains and domain not in domains:
|
||||
continue
|
||||
logger.debug(f'开始匹配站点:{domain},共缓存了 {len(contexts)} 个种子...')
|
||||
for context in contexts:
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
# 提取信息
|
||||
_context = copy.copy(context)
|
||||
torrent_meta = _context.meta_info
|
||||
torrent_mediainfo = _context.media_info
|
||||
torrent_info = _context.torrent_info
|
||||
|
||||
# 有自定义识别词时,需要判断是否需要重新识别
|
||||
if custom_words_list:
|
||||
# 使用org_string,应用一次后理论上不能再次应用
|
||||
_, apply_words = wordsmatcher.prepare(torrent_meta.org_string,
|
||||
custom_words=custom_words_list)
|
||||
if apply_words:
|
||||
# 不在订阅站点范围的不处理
|
||||
sub_sites = self.get_sub_sites(subscribe)
|
||||
if sub_sites and torrent_info.site not in sub_sites:
|
||||
logger.debug(f"{torrent_info.site_name} - {torrent_info.title} 不符合订阅站点要求")
|
||||
continue
|
||||
|
||||
# 有自定义识别词时,需要判断是否需要重新识别
|
||||
if custom_words_list:
|
||||
# 使用org_string,应用一次后理论上不能再次应用
|
||||
_, apply_words = wordsmatcher.prepare(torrent_meta.org_string,
|
||||
custom_words=custom_words_list)
|
||||
if apply_words:
|
||||
logger.info(
|
||||
f'{torrent_info.site_name} - {torrent_info.title} 因订阅存在自定义识别词,重新识别元数据...')
|
||||
# 重新识别元数据
|
||||
torrent_meta = MetaInfo(title=torrent_info.title, subtitle=torrent_info.description,
|
||||
custom_words=custom_words_list)
|
||||
# 更新元数据缓存
|
||||
_context.meta_info = torrent_meta
|
||||
# 重新识别媒体信息
|
||||
torrent_mediainfo = self.recognize_media(meta=torrent_meta,
|
||||
episode_group=subscribe.episode_group)
|
||||
if torrent_mediainfo:
|
||||
# 清理多余信息
|
||||
torrent_mediainfo.clear()
|
||||
# 更新种子缓存
|
||||
_context.media_info = torrent_mediainfo
|
||||
|
||||
# 如果仍然没有识别到媒体信息,尝试标题匹配
|
||||
if not torrent_mediainfo or (
|
||||
not torrent_mediainfo.tmdb_id and not torrent_mediainfo.douban_id):
|
||||
logger.info(
|
||||
f'{torrent_info.site_name} - {torrent_info.title} 因订阅存在自定义识别词,重新识别元数据...')
|
||||
# 重新识别元数据
|
||||
torrent_meta = MetaInfo(title=torrent_info.title, subtitle=torrent_info.description,
|
||||
custom_words=custom_words_list)
|
||||
# 更新元数据缓存
|
||||
_context.meta_info = torrent_meta
|
||||
# 重新识别媒体信息
|
||||
torrent_mediainfo = self.recognize_media(meta=torrent_meta,
|
||||
episode_group=subscribe.episode_group)
|
||||
if torrent_mediainfo:
|
||||
# 清理多余信息
|
||||
torrent_mediainfo.clear()
|
||||
f'{torrent_info.site_name} - {torrent_info.title} 重新识别失败,尝试通过标题匹配...')
|
||||
if torrenthelper.match_torrent(mediainfo=mediainfo,
|
||||
torrent_meta=torrent_meta,
|
||||
torrent=torrent_info):
|
||||
# 匹配成功
|
||||
logger.info(
|
||||
f'{mediainfo.title_year} 通过标题匹配到可选资源:{torrent_info.site_name} - {torrent_info.title}')
|
||||
torrent_mediainfo = mediainfo
|
||||
# 更新种子缓存
|
||||
_context.media_info = torrent_mediainfo
|
||||
_context.media_info = mediainfo
|
||||
else:
|
||||
continue
|
||||
|
||||
# 如果仍然没有识别到媒体信息,尝试标题匹配
|
||||
if not torrent_mediainfo or (not torrent_mediainfo.tmdb_id and not torrent_mediainfo.douban_id):
|
||||
logger.info(
|
||||
f'{torrent_info.site_name} - {torrent_info.title} 重新识别失败,尝试通过标题匹配...')
|
||||
if torrenthelper.match_torrent(mediainfo=mediainfo,
|
||||
torrent_meta=torrent_meta,
|
||||
torrent=torrent_info):
|
||||
# 匹配成功
|
||||
# 直接比对媒体信息
|
||||
if torrent_mediainfo and (torrent_mediainfo.tmdb_id or torrent_mediainfo.douban_id):
|
||||
if torrent_mediainfo.type != mediainfo.type:
|
||||
continue
|
||||
if torrent_mediainfo.tmdb_id \
|
||||
and torrent_mediainfo.tmdb_id != mediainfo.tmdb_id:
|
||||
continue
|
||||
if torrent_mediainfo.douban_id \
|
||||
and torrent_mediainfo.douban_id != mediainfo.douban_id:
|
||||
continue
|
||||
logger.info(
|
||||
f'{mediainfo.title_year} 通过标题匹配到可选资源:{torrent_info.site_name} - {torrent_info.title}')
|
||||
torrent_mediainfo = mediainfo
|
||||
# 更新种子缓存
|
||||
_context.media_info = mediainfo
|
||||
f'{mediainfo.title_year} 通过媒体信ID匹配到可选资源:{torrent_info.site_name} - {torrent_info.title}')
|
||||
else:
|
||||
continue
|
||||
|
||||
# 直接比对媒体信息
|
||||
if torrent_mediainfo and (torrent_mediainfo.tmdb_id or torrent_mediainfo.douban_id):
|
||||
if torrent_mediainfo.type != mediainfo.type:
|
||||
continue
|
||||
if torrent_mediainfo.tmdb_id \
|
||||
and torrent_mediainfo.tmdb_id != mediainfo.tmdb_id:
|
||||
continue
|
||||
if torrent_mediainfo.douban_id \
|
||||
and torrent_mediainfo.douban_id != mediainfo.douban_id:
|
||||
continue
|
||||
logger.info(
|
||||
f'{mediainfo.title_year} 通过媒体信ID匹配到可选资源:{torrent_info.site_name} - {torrent_info.title}')
|
||||
else:
|
||||
continue
|
||||
|
||||
# 如果是电视剧
|
||||
if torrent_mediainfo.type == MediaType.TV:
|
||||
# 有多季的不要
|
||||
if len(torrent_meta.season_list) > 1:
|
||||
logger.debug(f'{torrent_info.title} 有多季,不处理')
|
||||
continue
|
||||
# 比对季
|
||||
if torrent_meta.begin_season:
|
||||
if meta.begin_season != torrent_meta.begin_season:
|
||||
# 如果是电视剧
|
||||
if torrent_mediainfo.type == MediaType.TV:
|
||||
# 有多季的不要
|
||||
if len(torrent_meta.season_list) > 1:
|
||||
logger.debug(f'{torrent_info.title} 有多季,不处理')
|
||||
continue
|
||||
# 比对季
|
||||
if torrent_meta.begin_season:
|
||||
if meta.begin_season != torrent_meta.begin_season:
|
||||
logger.debug(f'{torrent_info.title} 季不匹配')
|
||||
continue
|
||||
elif meta.begin_season != 1:
|
||||
logger.debug(f'{torrent_info.title} 季不匹配')
|
||||
continue
|
||||
elif meta.begin_season != 1:
|
||||
logger.debug(f'{torrent_info.title} 季不匹配')
|
||||
continue
|
||||
# 非洗版
|
||||
if not subscribe.best_version:
|
||||
# 不是缺失的剧集不要
|
||||
if no_exists and no_exists.get(mediakey):
|
||||
# 缺失集
|
||||
no_exists_info = no_exists.get(mediakey).get(subscribe.season)
|
||||
if no_exists_info:
|
||||
# 是否有交集
|
||||
if no_exists_info.episodes and \
|
||||
torrent_meta.episode_list and \
|
||||
not set(no_exists_info.episodes).intersection(
|
||||
set(torrent_meta.episode_list)
|
||||
):
|
||||
logger.debug(
|
||||
f'{torrent_info.title} 对应剧集 {torrent_meta.episode_list} 未包含缺失的剧集'
|
||||
)
|
||||
# 非洗版
|
||||
if not subscribe.best_version:
|
||||
# 不是缺失的剧集不要
|
||||
if no_exists and no_exists.get(mediakey):
|
||||
# 缺失集
|
||||
no_exists_info = no_exists.get(mediakey).get(subscribe.season)
|
||||
if no_exists_info:
|
||||
# 是否有交集
|
||||
if no_exists_info.episodes and \
|
||||
torrent_meta.episode_list and \
|
||||
not set(no_exists_info.episodes).intersection(
|
||||
set(torrent_meta.episode_list)
|
||||
):
|
||||
logger.debug(
|
||||
f'{torrent_info.title} 对应剧集 {torrent_meta.episode_list} 未包含缺失的剧集'
|
||||
)
|
||||
continue
|
||||
else:
|
||||
# 洗版时,非整季不要
|
||||
if meta.type == MediaType.TV:
|
||||
if torrent_meta.episode_list:
|
||||
logger.debug(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季')
|
||||
continue
|
||||
else:
|
||||
# 洗版时,非整季不要
|
||||
if meta.type == MediaType.TV:
|
||||
if torrent_meta.episode_list:
|
||||
logger.debug(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季')
|
||||
continue
|
||||
|
||||
# 匹配订阅附加参数
|
||||
if not torrenthelper.filter_torrent(torrent_info=torrent_info,
|
||||
filter_params=self.get_params(subscribe)):
|
||||
continue
|
||||
|
||||
# 优先级过滤规则
|
||||
if subscribe.best_version:
|
||||
rule_groups = subscribe.filter_groups \
|
||||
or systemconfig.get(SystemConfigKey.BestVersionFilterRuleGroups)
|
||||
else:
|
||||
rule_groups = subscribe.filter_groups \
|
||||
or systemconfig.get(SystemConfigKey.SubscribeFilterRuleGroups)
|
||||
result: List[TorrentInfo] = self.filter_torrents(
|
||||
rule_groups=rule_groups,
|
||||
torrent_list=[torrent_info],
|
||||
mediainfo=torrent_mediainfo)
|
||||
if result is not None and not result:
|
||||
# 不符合过滤规则
|
||||
logger.debug(f"{torrent_info.title} 不匹配过滤规则")
|
||||
continue
|
||||
|
||||
# 洗版时,优先级小于已下载优先级的不要
|
||||
if subscribe.best_version:
|
||||
if subscribe.current_priority \
|
||||
and torrent_info.pri_order <= subscribe.current_priority:
|
||||
logger.info(
|
||||
f'{subscribe.name} 正在洗版,{torrent_info.title} 优先级低于或等于已下载优先级')
|
||||
# 匹配订阅附加参数
|
||||
if not torrenthelper.filter_torrent(torrent_info=torrent_info,
|
||||
filter_params=self.get_params(subscribe)):
|
||||
continue
|
||||
|
||||
# 匹配成功
|
||||
logger.info(f'{mediainfo.title_year} 匹配成功:{torrent_info.title}')
|
||||
# 自定义属性
|
||||
if subscribe.media_category:
|
||||
torrent_mediainfo.category = subscribe.media_category
|
||||
if subscribe.episode_group:
|
||||
torrent_mediainfo.episode_group = subscribe.episode_group
|
||||
_match_context.append(_context)
|
||||
# 优先级过滤规则
|
||||
if subscribe.best_version:
|
||||
rule_groups = subscribe.filter_groups \
|
||||
or systemconfig.get(SystemConfigKey.BestVersionFilterRuleGroups)
|
||||
else:
|
||||
rule_groups = subscribe.filter_groups \
|
||||
or systemconfig.get(SystemConfigKey.SubscribeFilterRuleGroups)
|
||||
result: List[TorrentInfo] = self.filter_torrents(
|
||||
rule_groups=rule_groups,
|
||||
torrent_list=[torrent_info],
|
||||
mediainfo=torrent_mediainfo)
|
||||
if result is not None and not result:
|
||||
# 不符合过滤规则
|
||||
logger.debug(f"{torrent_info.title} 不匹配过滤规则")
|
||||
continue
|
||||
|
||||
if not _match_context:
|
||||
# 未匹配到资源
|
||||
logger.info(f'{mediainfo.title_year} 未匹配到符合条件的资源')
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta,
|
||||
mediainfo=mediainfo, lefts=no_exists)
|
||||
continue
|
||||
# 洗版时,优先级小于已下载优先级的不要
|
||||
if subscribe.best_version:
|
||||
if subscribe.current_priority \
|
||||
and torrent_info.pri_order <= subscribe.current_priority:
|
||||
logger.info(
|
||||
f'{subscribe.name} 正在洗版,{torrent_info.title} 优先级低于或等于已下载优先级')
|
||||
continue
|
||||
|
||||
# 开始批量择优下载
|
||||
logger.info(f'{mediainfo.title_year} 匹配完成,共匹配到{len(_match_context)}个资源')
|
||||
downloads, lefts = DownloadChain().batch_download(contexts=_match_context,
|
||||
no_exists=no_exists,
|
||||
userid=subscribe.username,
|
||||
username=subscribe.username,
|
||||
save_path=subscribe.save_path,
|
||||
downloader=subscribe.downloader,
|
||||
source=self.get_subscribe_source_keyword(subscribe)
|
||||
)
|
||||
# 匹配成功
|
||||
logger.info(f'{mediainfo.title_year} 匹配成功:{torrent_info.title}')
|
||||
# 自定义属性
|
||||
if subscribe.media_category:
|
||||
torrent_mediainfo.category = subscribe.media_category
|
||||
if subscribe.episode_group:
|
||||
torrent_mediainfo.episode_group = subscribe.episode_group
|
||||
_match_context.append(_context)
|
||||
|
||||
# 同步外部修改,更新订阅信息
|
||||
subscribe = SubscribeOper().get(subscribe.id)
|
||||
if not _match_context:
|
||||
# 未匹配到资源
|
||||
logger.info(f'{mediainfo.title_year} 未匹配到符合条件的资源')
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta,
|
||||
mediainfo=mediainfo, lefts=no_exists)
|
||||
continue
|
||||
|
||||
# 判断是否要完成订阅
|
||||
if subscribe:
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo,
|
||||
downloads=downloads, lefts=lefts)
|
||||
# 开始批量择优下载
|
||||
logger.info(f'{mediainfo.title_year} 匹配完成,共匹配到{len(_match_context)}个资源')
|
||||
downloads, lefts = DownloadChain().batch_download(contexts=_match_context,
|
||||
no_exists=no_exists,
|
||||
username=subscribe.username,
|
||||
save_path=subscribe.save_path,
|
||||
downloader=subscribe.downloader,
|
||||
source=self.get_subscribe_source_keyword(subscribe)
|
||||
)
|
||||
|
||||
logger.debug(f"match Lock released at {datetime.now()}")
|
||||
# 同步外部修改,更新订阅信息
|
||||
subscribe = SubscribeOper().get(subscribe.id)
|
||||
|
||||
# 判断是否要完成订阅
|
||||
if subscribe:
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo,
|
||||
downloads=downloads, lefts=lefts)
|
||||
finally:
|
||||
processed_torrents.clear()
|
||||
del processed_torrents
|
||||
subscribes.clear()
|
||||
del subscribes
|
||||
finally:
|
||||
if lock_acquired:
|
||||
self._rlock.release()
|
||||
logger.debug(f"match Lock released at {datetime.now()}")
|
||||
|
||||
def check(self):
|
||||
"""
|
||||
@@ -809,12 +850,8 @@ class SubscribeChain(ChainBase):
|
||||
"""
|
||||
# 查询所有订阅
|
||||
subscribeoper = SubscribeOper()
|
||||
subscribes = subscribeoper.list()
|
||||
if not subscribes:
|
||||
# 没有订阅不运行
|
||||
return
|
||||
# 遍历订阅
|
||||
for subscribe in subscribes:
|
||||
for subscribe in subscribeoper.list():
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
logger.info(f'开始更新订阅元数据:{subscribe.name} ...')
|
||||
@@ -870,11 +907,10 @@ class SubscribeChain(ChainBase):
|
||||
follow_users: List[str] = SystemConfigOper().get(SystemConfigKey.FollowSubscribers)
|
||||
if not follow_users:
|
||||
return
|
||||
share_subs = SubscribeHelper().get_shares()
|
||||
logger.info(f'开始刷新follow用户分享订阅 ...')
|
||||
success_count = 0
|
||||
subscribeoper = SubscribeOper()
|
||||
for share_sub in share_subs:
|
||||
for share_sub in SubscribeHelper().get_shares():
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
uid = share_sub.get("share_uid")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Union, Optional
|
||||
|
||||
@@ -10,6 +11,7 @@ from app.schemas import Notification, MessageChannel
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.system import SystemUtils
|
||||
from app.helper.system import SystemHelper
|
||||
from app.helper.plugin import PluginHelper
|
||||
from version import FRONTEND_VERSION, APP_VERSION
|
||||
|
||||
|
||||
@@ -33,7 +35,7 @@ class SystemChain(ChainBase):
|
||||
重启系统
|
||||
"""
|
||||
from app.core.config import global_vars
|
||||
|
||||
|
||||
if channel and userid:
|
||||
self.post_message(Notification(channel=channel, source=source,
|
||||
title="系统正在重启,请耐心等候!", userid=userid))
|
||||
@@ -42,11 +44,124 @@ class SystemChain(ChainBase):
|
||||
"channel": channel.value,
|
||||
"userid": userid
|
||||
}, self._restart_file)
|
||||
# 主动备份一次插件
|
||||
self.backup_plugins()
|
||||
# 设置停止标志,通知所有模块准备停止
|
||||
global_vars.stop_system()
|
||||
# 重启
|
||||
SystemHelper.restart()
|
||||
|
||||
@staticmethod
|
||||
def backup_plugins():
|
||||
"""
|
||||
备份插件到用户配置目录(仅docker环境)
|
||||
"""
|
||||
|
||||
# 非docker环境不处理
|
||||
if not SystemUtils.is_docker():
|
||||
return
|
||||
|
||||
try:
|
||||
# 使用绝对路径确保准确性
|
||||
plugins_dir = settings.ROOT_PATH / "app" / "plugins"
|
||||
backup_dir = settings.CONFIG_PATH / "plugins_backup"
|
||||
|
||||
if not plugins_dir.exists():
|
||||
logger.info("插件目录不存在,跳过备份")
|
||||
return
|
||||
|
||||
# 确保备份目录存在
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 需要排除的文件和目录
|
||||
exclude_items = {"__init__.py", "__pycache__", ".DS_Store"}
|
||||
|
||||
# 遍历插件目录,备份除排除项外的所有内容
|
||||
for item in plugins_dir.iterdir():
|
||||
if item.name in exclude_items:
|
||||
continue
|
||||
|
||||
target_path = backup_dir / item.name
|
||||
|
||||
# 如果是目录
|
||||
if item.is_dir():
|
||||
if target_path.exists():
|
||||
continue
|
||||
shutil.copytree(item, target_path)
|
||||
logger.info(f"已备份插件目录: {item.name}")
|
||||
# 如果是文件
|
||||
elif item.is_file():
|
||||
if target_path.exists():
|
||||
continue
|
||||
shutil.copy2(item, target_path)
|
||||
logger.info(f"已备份插件文件: {item.name}")
|
||||
|
||||
logger.info(f"插件备份完成,备份位置: {backup_dir}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"插件备份失败: {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def restore_plugins():
|
||||
"""
|
||||
从备份恢复插件到app/plugins目录,恢复完成后删除备份(仅docker环境)
|
||||
"""
|
||||
|
||||
# 非docker环境不处理
|
||||
if not SystemUtils.is_docker():
|
||||
return
|
||||
|
||||
# 使用绝对路径确保准确性
|
||||
plugins_dir = settings.ROOT_PATH / "app" / "plugins"
|
||||
backup_dir = settings.CONFIG_PATH / "plugins_backup"
|
||||
|
||||
if not backup_dir.exists():
|
||||
logger.info("插件备份目录不存在,跳过恢复")
|
||||
return
|
||||
|
||||
# 系统被重置才恢复插件
|
||||
if SystemHelper().is_system_reset():
|
||||
|
||||
# 确保插件目录存在
|
||||
plugins_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 遍历备份目录,恢复所有内容
|
||||
restored_count = 0
|
||||
for item in backup_dir.iterdir():
|
||||
target_path = plugins_dir / item.name
|
||||
try:
|
||||
# 如果是目录,且目录内有内容
|
||||
if item.is_dir() and any(item.iterdir()):
|
||||
if target_path.exists():
|
||||
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():
|
||||
shutil.copy2(item, target_path)
|
||||
logger.info(f"已恢复插件文件: {item.name}")
|
||||
restored_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"恢复插件 {item.name} 时发生错误: {str(e)}")
|
||||
continue
|
||||
|
||||
logger.info(f"插件恢复完成,共恢复 {restored_count} 个项目")
|
||||
|
||||
# 删除备份目录
|
||||
try:
|
||||
shutil.rmtree(backup_dir)
|
||||
logger.info(f"已删除插件备份目录: {backup_dir}")
|
||||
except Exception as e:
|
||||
logger.warning(f"删除备份目录失败: {str(e)}")
|
||||
|
||||
def __get_version_message(self) -> str:
|
||||
"""
|
||||
获取版本信息文本
|
||||
|
||||
@@ -98,6 +98,7 @@ class TorrentsChain(ChainBase):
|
||||
if not site.get("rss"):
|
||||
logger.error(f'站点 {domain} 未配置RSS地址!')
|
||||
return []
|
||||
# 解析RSS
|
||||
rss_items = RssHelper().parse(site.get("rss"), True if site.get("proxy") else False,
|
||||
timeout=int(site.get("timeout") or 30))
|
||||
if rss_items is None:
|
||||
@@ -109,25 +110,28 @@ class TorrentsChain(ChainBase):
|
||||
return []
|
||||
# 组装种子
|
||||
ret_torrents: List[TorrentInfo] = []
|
||||
for item in rss_items:
|
||||
if not item.get("title"):
|
||||
continue
|
||||
torrentinfo = TorrentInfo(
|
||||
site=site.get("id"),
|
||||
site_name=site.get("name"),
|
||||
site_cookie=site.get("cookie"),
|
||||
site_ua=site.get("ua") or settings.USER_AGENT,
|
||||
site_proxy=site.get("proxy"),
|
||||
site_order=site.get("pri"),
|
||||
site_downloader=site.get("downloader"),
|
||||
title=item.get("title"),
|
||||
enclosure=item.get("enclosure"),
|
||||
page_url=item.get("link"),
|
||||
size=item.get("size"),
|
||||
pubdate=item["pubdate"].strftime("%Y-%m-%d %H:%M:%S") if item.get("pubdate") else None,
|
||||
)
|
||||
ret_torrents.append(torrentinfo)
|
||||
|
||||
try:
|
||||
for item in rss_items:
|
||||
if not item.get("title"):
|
||||
continue
|
||||
torrentinfo = TorrentInfo(
|
||||
site=site.get("id"),
|
||||
site_name=site.get("name"),
|
||||
site_cookie=site.get("cookie"),
|
||||
site_ua=site.get("ua") or settings.USER_AGENT,
|
||||
site_proxy=site.get("proxy"),
|
||||
site_order=site.get("pri"),
|
||||
site_downloader=site.get("downloader"),
|
||||
title=item.get("title"),
|
||||
enclosure=item.get("enclosure"),
|
||||
page_url=item.get("link"),
|
||||
size=item.get("size"),
|
||||
pubdate=item["pubdate"].strftime("%Y-%m-%d %H:%M:%S") if item.get("pubdate") else None,
|
||||
)
|
||||
ret_torrents.append(torrentinfo)
|
||||
finally:
|
||||
rss_items.clear()
|
||||
del rss_items
|
||||
return ret_torrents
|
||||
|
||||
def refresh(self, stype: Optional[str] = None, sites: List[int] = None) -> Dict[str, List[Context]]:
|
||||
@@ -152,13 +156,10 @@ class TorrentsChain(ChainBase):
|
||||
torrents_cache[_domain] = [_torrent for _torrent in _torrents
|
||||
if not TorrentHelper().is_invalid(_torrent.torrent_info.enclosure)]
|
||||
|
||||
# 所有站点索引
|
||||
indexers = SitesHelper().get_indexers()
|
||||
# 需要刷新的站点domain
|
||||
domains = []
|
||||
|
||||
# 遍历站点缓存资源
|
||||
for indexer in indexers:
|
||||
for indexer in SitesHelper().get_indexers():
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
# 未开启的站点不刷新
|
||||
@@ -175,7 +176,7 @@ class TorrentsChain(ChainBase):
|
||||
# 按pubdate降序排列
|
||||
torrents.sort(key=lambda x: x.pubdate or '', reverse=True)
|
||||
# 取前N条
|
||||
torrents = torrents[:settings.CONF["refresh"]]
|
||||
torrents = torrents[:settings.CONF.refresh]
|
||||
if torrents:
|
||||
# 过滤出没有处理过的种子 - 优化:使用集合查找,避免重复创建字符串列表
|
||||
cached_signatures = {f'{t.torrent_info.title}{t.torrent_info.description}'
|
||||
@@ -187,36 +188,40 @@ class TorrentsChain(ChainBase):
|
||||
else:
|
||||
logger.info(f'{indexer.get("name")} 没有新种子')
|
||||
continue
|
||||
for torrent in torrents:
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
logger.info(f'处理资源:{torrent.title} ...')
|
||||
# 识别
|
||||
meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
|
||||
if torrent.title != meta.org_string:
|
||||
logger.info(f'种子名称应用识别词后发生改变:{torrent.title} => {meta.org_string}')
|
||||
# 使用站点种子分类,校正类型识别
|
||||
if meta.type != MediaType.TV \
|
||||
and torrent.category == MediaType.TV.value:
|
||||
meta.type = MediaType.TV
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = MediaChain().recognize_by_meta(meta)
|
||||
if not mediainfo:
|
||||
logger.warn(f'{torrent.title} 未识别到媒体信息')
|
||||
# 存储空的媒体信息
|
||||
mediainfo = MediaInfo()
|
||||
# 清理多余数据,减少内存占用
|
||||
mediainfo.clear()
|
||||
# 上下文
|
||||
context = Context(meta_info=meta, media_info=mediainfo, torrent_info=torrent)
|
||||
# 添加到缓存
|
||||
if not torrents_cache.get(domain):
|
||||
torrents_cache[domain] = [context]
|
||||
else:
|
||||
torrents_cache[domain].append(context)
|
||||
# 如果超过了限制条数则移除掉前面的
|
||||
if len(torrents_cache[domain]) > settings.CONF["torrents"]:
|
||||
torrents_cache[domain] = torrents_cache[domain][-settings.CONF["torrents"]:]
|
||||
try:
|
||||
for torrent in torrents:
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
logger.info(f'处理资源:{torrent.title} ...')
|
||||
# 识别
|
||||
meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
|
||||
if torrent.title != meta.org_string:
|
||||
logger.info(f'种子名称应用识别词后发生改变:{torrent.title} => {meta.org_string}')
|
||||
# 使用站点种子分类,校正类型识别
|
||||
if meta.type != MediaType.TV \
|
||||
and torrent.category == MediaType.TV.value:
|
||||
meta.type = MediaType.TV
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = MediaChain().recognize_by_meta(meta)
|
||||
if not mediainfo:
|
||||
logger.warn(f'{torrent.title} 未识别到媒体信息')
|
||||
# 存储空的媒体信息
|
||||
mediainfo = MediaInfo()
|
||||
# 清理多余数据,减少内存占用
|
||||
mediainfo.clear()
|
||||
# 上下文
|
||||
context = Context(meta_info=meta, media_info=mediainfo, torrent_info=torrent)
|
||||
# 添加到缓存
|
||||
if not torrents_cache.get(domain):
|
||||
torrents_cache[domain] = [context]
|
||||
else:
|
||||
torrents_cache[domain].append(context)
|
||||
# 如果超过了限制条数则移除掉前面的
|
||||
if len(torrents_cache[domain]) > settings.CONF.torrents:
|
||||
torrents_cache[domain] = torrents_cache[domain][-settings.CONF.torrents:]
|
||||
finally:
|
||||
torrents.clear()
|
||||
del torrents
|
||||
else:
|
||||
logger.info(f'{indexer.get("name")} 没有获取到种子')
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import gc
|
||||
import queue
|
||||
import re
|
||||
import threading
|
||||
@@ -487,7 +488,9 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
self.eventmanager.send_event(EventType.MetadataScrape, {
|
||||
'meta': task.meta,
|
||||
'mediainfo': task.mediainfo,
|
||||
'fileitem': transferinfo.target_diritem
|
||||
'fileitem': transferinfo.target_diritem,
|
||||
'file_list': transferinfo.file_list_new,
|
||||
'overwrite': False
|
||||
})
|
||||
|
||||
# 移除已完成的任务
|
||||
@@ -788,6 +791,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
for dir_info in download_dirs):
|
||||
return True
|
||||
logger.info("开始整理下载器中已经完成下载的文件 ...")
|
||||
|
||||
# 从下载器获取种子列表
|
||||
torrents: Optional[List[TransferTorrent]] = self.list_torrents(status=TorrentStatus.TRANSFER)
|
||||
if not torrents:
|
||||
@@ -796,70 +800,78 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
|
||||
logger.info(f"获取到 {len(torrents)} 个已完成的下载任务")
|
||||
|
||||
for torrent in torrents:
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
# 文件路径
|
||||
file_path = torrent.path
|
||||
if not file_path.exists():
|
||||
logger.warn(f"文件不存在:{file_path}")
|
||||
continue
|
||||
# 检查是否为下载器监控目录中的文件
|
||||
is_downloader_monitor = False
|
||||
for dir_info in download_dirs:
|
||||
if dir_info.monitor_type != "downloader":
|
||||
continue
|
||||
if not dir_info.download_path:
|
||||
continue
|
||||
if file_path.is_relative_to(Path(dir_info.download_path)):
|
||||
is_downloader_monitor = True
|
||||
try:
|
||||
for torrent in torrents:
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
if not is_downloader_monitor:
|
||||
logger.debug(f"文件 {file_path} 不在下载器监控目录中,不通过下载器进行整理")
|
||||
continue
|
||||
# 查询下载记录识别情况
|
||||
downloadhis: DownloadHistory = DownloadHistoryOper().get_by_hash(torrent.hash)
|
||||
if downloadhis:
|
||||
# 类型
|
||||
try:
|
||||
mtype = MediaType(downloadhis.type)
|
||||
except ValueError:
|
||||
mtype = MediaType.TV
|
||||
# 按TMDBID识别
|
||||
mediainfo = self.recognize_media(mtype=mtype,
|
||||
tmdbid=downloadhis.tmdbid,
|
||||
doubanid=downloadhis.doubanid,
|
||||
episode_group=downloadhis.episode_group)
|
||||
if mediainfo:
|
||||
# 补充图片
|
||||
self.obtain_images(mediainfo)
|
||||
# 更新自定义媒体类别
|
||||
if downloadhis.media_category:
|
||||
mediainfo.category = downloadhis.media_category
|
||||
else:
|
||||
# 非MoviePilot下载的任务,按文件识别
|
||||
mediainfo = None
|
||||
# 文件路径
|
||||
file_path = torrent.path
|
||||
if not file_path.exists():
|
||||
logger.warn(f"文件不存在:{file_path}")
|
||||
continue
|
||||
# 检查是否为下载器监控目录中的文件
|
||||
is_downloader_monitor = False
|
||||
for dir_info in download_dirs:
|
||||
if dir_info.monitor_type != "downloader":
|
||||
continue
|
||||
if not dir_info.download_path:
|
||||
continue
|
||||
if file_path.is_relative_to(Path(dir_info.download_path)):
|
||||
is_downloader_monitor = True
|
||||
break
|
||||
if not is_downloader_monitor:
|
||||
logger.debug(f"文件 {file_path} 不在下载器监控目录中,不通过下载器进行整理")
|
||||
continue
|
||||
# 查询下载记录识别情况
|
||||
downloadhis: DownloadHistory = DownloadHistoryOper().get_by_hash(torrent.hash)
|
||||
if downloadhis:
|
||||
# 类型
|
||||
try:
|
||||
mtype = MediaType(downloadhis.type)
|
||||
except ValueError:
|
||||
mtype = MediaType.TV
|
||||
# 按TMDBID识别
|
||||
mediainfo = self.recognize_media(mtype=mtype,
|
||||
tmdbid=downloadhis.tmdbid,
|
||||
doubanid=downloadhis.doubanid,
|
||||
episode_group=downloadhis.episode_group)
|
||||
if mediainfo:
|
||||
# 补充图片
|
||||
self.obtain_images(mediainfo)
|
||||
# 更新自定义媒体类别
|
||||
if downloadhis.media_category:
|
||||
mediainfo.category = downloadhis.media_category
|
||||
else:
|
||||
# 非MoviePilot下载的任务,按文件识别
|
||||
mediainfo = None
|
||||
|
||||
# 执行实时整理,匹配源目录
|
||||
state, errmsg = self.do_transfer(
|
||||
fileitem=FileItem(
|
||||
storage="local",
|
||||
path=str(file_path).replace("\\", "/"),
|
||||
type="dir" if not file_path.is_file() else "file",
|
||||
name=file_path.name,
|
||||
size=file_path.stat().st_size,
|
||||
extension=file_path.suffix.lstrip('.'),
|
||||
),
|
||||
mediainfo=mediainfo,
|
||||
downloader=torrent.downloader,
|
||||
download_hash=torrent.hash,
|
||||
background=False,
|
||||
)
|
||||
# 执行实时整理,匹配源目录
|
||||
state, errmsg = self.do_transfer(
|
||||
fileitem=FileItem(
|
||||
storage="local",
|
||||
path=str(file_path).replace("\\", "/"),
|
||||
type="dir" if not file_path.is_file() else "file",
|
||||
name=file_path.name,
|
||||
size=file_path.stat().st_size,
|
||||
extension=file_path.suffix.lstrip('.'),
|
||||
),
|
||||
mediainfo=mediainfo,
|
||||
downloader=torrent.downloader,
|
||||
download_hash=torrent.hash,
|
||||
background=False,
|
||||
)
|
||||
|
||||
# 设置下载任务状态
|
||||
if not state:
|
||||
logger.warn(f"整理下载器任务失败:{torrent.hash} - {errmsg}")
|
||||
self.transfer_completed(hashs=torrent.hash, downloader=torrent.downloader)
|
||||
# 设置下载任务状态
|
||||
if not state:
|
||||
logger.warn(f"整理下载器任务失败:{torrent.hash} - {errmsg}")
|
||||
self.transfer_completed(hashs=torrent.hash, downloader=torrent.downloader)
|
||||
finally:
|
||||
torrents.clear()
|
||||
del torrents
|
||||
|
||||
# 如果不是大内存模式,进行垃圾回收
|
||||
if not settings.BIG_MEMORY_MODE:
|
||||
gc.collect()
|
||||
|
||||
# 结束
|
||||
logger.info("所有下载器中下载完成的文件已整理完成")
|
||||
@@ -870,7 +882,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
) -> List[Tuple[FileItem, bool]]:
|
||||
"""
|
||||
获取整理目录或文件列表
|
||||
|
||||
|
||||
:param fileitem: 文件项
|
||||
:param depth: 递归深度,默认为1
|
||||
"""
|
||||
@@ -1032,111 +1044,115 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
|
||||
# 整理所有文件
|
||||
transfer_tasks: List[TransferTask] = []
|
||||
for file_item, bluray_dir in file_items:
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
if continue_callback and not continue_callback():
|
||||
break
|
||||
file_path = Path(file_item.path)
|
||||
# 回收站及隐藏的文件不处理
|
||||
if file_item.path.find('/@Recycle/') != -1 \
|
||||
or file_item.path.find('/#recycle/') != -1 \
|
||||
or file_item.path.find('/.') != -1 \
|
||||
or file_item.path.find('/@eaDir') != -1:
|
||||
logger.debug(f"{file_item.path} 是回收站或隐藏的文件")
|
||||
continue
|
||||
|
||||
# 整理屏蔽词不处理
|
||||
is_blocked = False
|
||||
if transfer_exclude_words:
|
||||
for keyword in transfer_exclude_words:
|
||||
if not keyword:
|
||||
continue
|
||||
if keyword and re.search(r"%s" % keyword, file_item.path, re.IGNORECASE):
|
||||
logger.info(f"{file_item.path} 命中整理屏蔽词 {keyword},不处理")
|
||||
is_blocked = True
|
||||
break
|
||||
if is_blocked:
|
||||
continue
|
||||
|
||||
# 整理成功的不再处理
|
||||
if not force:
|
||||
transferd = TransferHistoryOper().get_by_src(file_item.path, storage=file_item.storage)
|
||||
if transferd:
|
||||
if not transferd.status:
|
||||
all_success = False
|
||||
logger.info(f"{file_item.path} 已整理过,如需重新处理,请删除整理记录。")
|
||||
err_msgs.append(f"{file_item.name} 已整理过")
|
||||
try:
|
||||
for file_item, bluray_dir in file_items:
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
if continue_callback and not continue_callback():
|
||||
break
|
||||
file_path = Path(file_item.path)
|
||||
# 回收站及隐藏的文件不处理
|
||||
if file_item.path.find('/@Recycle/') != -1 \
|
||||
or file_item.path.find('/#recycle/') != -1 \
|
||||
or file_item.path.find('/.') != -1 \
|
||||
or file_item.path.find('/@eaDir') != -1:
|
||||
logger.debug(f"{file_item.path} 是回收站或隐藏的文件")
|
||||
continue
|
||||
|
||||
if not meta:
|
||||
# 文件元数据
|
||||
file_meta = MetaInfoPath(file_path)
|
||||
else:
|
||||
file_meta = meta
|
||||
# 整理屏蔽词不处理
|
||||
is_blocked = False
|
||||
if transfer_exclude_words:
|
||||
for keyword in transfer_exclude_words:
|
||||
if not keyword:
|
||||
continue
|
||||
if keyword and re.search(r"%s" % keyword, file_item.path, re.IGNORECASE):
|
||||
logger.info(f"{file_item.path} 命中整理屏蔽词 {keyword},不处理")
|
||||
is_blocked = True
|
||||
break
|
||||
if is_blocked:
|
||||
continue
|
||||
|
||||
# 合并季
|
||||
if season is not None:
|
||||
file_meta.begin_season = season
|
||||
# 整理成功的不再处理
|
||||
if not force:
|
||||
transferd = TransferHistoryOper().get_by_src(file_item.path, storage=file_item.storage)
|
||||
if transferd:
|
||||
if not transferd.status:
|
||||
all_success = False
|
||||
logger.info(f"{file_item.path} 已整理过,如需重新处理,请删除整理记录。")
|
||||
err_msgs.append(f"{file_item.name} 已整理过")
|
||||
continue
|
||||
|
||||
if not file_meta:
|
||||
all_success = False
|
||||
logger.error(f"{file_path.name} 无法识别有效信息")
|
||||
err_msgs.append(f"{file_path.name} 无法识别有效信息")
|
||||
continue
|
||||
if not meta:
|
||||
# 文件元数据
|
||||
file_meta = MetaInfoPath(file_path)
|
||||
else:
|
||||
file_meta = meta
|
||||
|
||||
# 自定义识别
|
||||
if formaterHandler:
|
||||
# 开始集、结束集、PART
|
||||
begin_ep, end_ep, part = formaterHandler.split_episode(file_name=file_path.name, file_meta=file_meta)
|
||||
if begin_ep is not None:
|
||||
file_meta.begin_episode = begin_ep
|
||||
file_meta.part = part
|
||||
if end_ep is not None:
|
||||
file_meta.end_episode = end_ep
|
||||
# 合并季
|
||||
if season is not None:
|
||||
file_meta.begin_season = season
|
||||
|
||||
# 根据父路径获取下载历史
|
||||
download_history = None
|
||||
downloadhis = DownloadHistoryOper()
|
||||
if bluray_dir:
|
||||
# 蓝光原盘,按目录名查询
|
||||
download_history = downloadhis.get_by_path(str(file_path))
|
||||
else:
|
||||
# 按文件全路径查询
|
||||
download_file = downloadhis.get_file_by_fullpath(str(file_path))
|
||||
if download_file:
|
||||
download_history = downloadhis.get_by_hash(download_file.download_hash)
|
||||
if not file_meta:
|
||||
all_success = False
|
||||
logger.error(f"{file_path.name} 无法识别有效信息")
|
||||
err_msgs.append(f"{file_path.name} 无法识别有效信息")
|
||||
continue
|
||||
|
||||
# 获取下载Hash
|
||||
if download_history and (not downloader or not download_hash):
|
||||
downloader = download_history.downloader
|
||||
download_hash = download_history.download_hash
|
||||
# 自定义识别
|
||||
if formaterHandler:
|
||||
# 开始集、结束集、PART
|
||||
begin_ep, end_ep, part = formaterHandler.split_episode(file_name=file_path.name, file_meta=file_meta)
|
||||
if begin_ep is not None:
|
||||
file_meta.begin_episode = begin_ep
|
||||
file_meta.part = part
|
||||
if end_ep is not None:
|
||||
file_meta.end_episode = end_ep
|
||||
|
||||
# 后台整理
|
||||
transfer_task = TransferTask(
|
||||
fileitem=file_item,
|
||||
meta=file_meta,
|
||||
mediainfo=mediainfo,
|
||||
target_directory=target_directory,
|
||||
target_storage=target_storage,
|
||||
target_path=target_path,
|
||||
transfer_type=transfer_type,
|
||||
scrape=scrape,
|
||||
library_type_folder=library_type_folder,
|
||||
library_category_folder=library_category_folder,
|
||||
downloader=downloader,
|
||||
download_hash=download_hash,
|
||||
download_history=download_history,
|
||||
manual=manual,
|
||||
background=background
|
||||
)
|
||||
if background:
|
||||
self.put_to_queue(task=transfer_task)
|
||||
logger.info(f"{file_path.name} 已添加到整理队列")
|
||||
else:
|
||||
# 加入列表
|
||||
self.__put_to_jobview(transfer_task)
|
||||
transfer_tasks.append(transfer_task)
|
||||
# 根据父路径获取下载历史
|
||||
download_history = None
|
||||
downloadhis = DownloadHistoryOper()
|
||||
if bluray_dir:
|
||||
# 蓝光原盘,按目录名查询
|
||||
download_history = downloadhis.get_by_path(str(file_path))
|
||||
else:
|
||||
# 按文件全路径查询
|
||||
download_file = downloadhis.get_file_by_fullpath(str(file_path))
|
||||
if download_file:
|
||||
download_history = downloadhis.get_by_hash(download_file.download_hash)
|
||||
|
||||
# 获取下载Hash
|
||||
if download_history and (not downloader or not download_hash):
|
||||
downloader = download_history.downloader
|
||||
download_hash = download_history.download_hash
|
||||
|
||||
# 后台整理
|
||||
transfer_task = TransferTask(
|
||||
fileitem=file_item,
|
||||
meta=file_meta,
|
||||
mediainfo=mediainfo,
|
||||
target_directory=target_directory,
|
||||
target_storage=target_storage,
|
||||
target_path=target_path,
|
||||
transfer_type=transfer_type,
|
||||
scrape=scrape,
|
||||
library_type_folder=library_type_folder,
|
||||
library_category_folder=library_category_folder,
|
||||
downloader=downloader,
|
||||
download_hash=download_hash,
|
||||
download_history=download_history,
|
||||
manual=manual,
|
||||
background=background
|
||||
)
|
||||
if background:
|
||||
self.put_to_queue(task=transfer_task)
|
||||
logger.info(f"{file_path.name} 已添加到整理队列")
|
||||
else:
|
||||
# 加入列表
|
||||
self.__put_to_jobview(transfer_task)
|
||||
transfer_tasks.append(transfer_task)
|
||||
finally:
|
||||
file_items.clear()
|
||||
del file_items
|
||||
|
||||
# 实时整理
|
||||
if transfer_tasks:
|
||||
@@ -1155,29 +1171,32 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
progress.update(value=0,
|
||||
text=__process_msg,
|
||||
key=ProgressKey.FileTransfer)
|
||||
|
||||
for transfer_task in transfer_tasks:
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
if continue_callback and not continue_callback():
|
||||
break
|
||||
# 更新进度
|
||||
__process_msg = f"正在整理 ({processed_num + fail_num + 1}/{total_num}){transfer_task.fileitem.name} ..."
|
||||
logger.info(__process_msg)
|
||||
progress.update(value=(processed_num + fail_num) / total_num * 100,
|
||||
text=__process_msg,
|
||||
key=ProgressKey.FileTransfer)
|
||||
state, err_msg = self.__handle_transfer(
|
||||
task=transfer_task,
|
||||
callback=self.__default_callback
|
||||
)
|
||||
if not state:
|
||||
all_success = False
|
||||
logger.warn(f"{transfer_task.fileitem.name} {err_msg}")
|
||||
err_msgs.append(f"{transfer_task.fileitem.name} {err_msg}")
|
||||
fail_num += 1
|
||||
else:
|
||||
processed_num += 1
|
||||
try:
|
||||
for transfer_task in transfer_tasks:
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
if continue_callback and not continue_callback():
|
||||
break
|
||||
# 更新进度
|
||||
__process_msg = f"正在整理 ({processed_num + fail_num + 1}/{total_num}){transfer_task.fileitem.name} ..."
|
||||
logger.info(__process_msg)
|
||||
progress.update(value=(processed_num + fail_num) / total_num * 100,
|
||||
text=__process_msg,
|
||||
key=ProgressKey.FileTransfer)
|
||||
state, err_msg = self.__handle_transfer(
|
||||
task=transfer_task,
|
||||
callback=self.__default_callback
|
||||
)
|
||||
if not state:
|
||||
all_success = False
|
||||
logger.warn(f"{transfer_task.fileitem.name} {err_msg}")
|
||||
err_msgs.append(f"{transfer_task.fileitem.name} {err_msg}")
|
||||
fail_num += 1
|
||||
else:
|
||||
processed_num += 1
|
||||
finally:
|
||||
transfer_tasks.clear()
|
||||
del transfer_tasks
|
||||
|
||||
# 整理结束
|
||||
__end_msg = f"整理队列处理完成,共整理 {total_num} 个文件,失败 {fail_num} 个"
|
||||
@@ -1187,7 +1206,8 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
key=ProgressKey.FileTransfer)
|
||||
progress.end(ProgressKey.FileTransfer)
|
||||
|
||||
return all_success, ",".join(err_msgs)
|
||||
error_msg = "、".join(err_msgs[:2]) + (f",等{len(err_msgs)}个文件错误!" if len(err_msgs) > 2 else "")
|
||||
return all_success, error_msg
|
||||
|
||||
def remote_transfer(self, arg_str: str, channel: MessageChannel,
|
||||
userid: Union[str, int] = None, source: Optional[str] = None):
|
||||
|
||||
@@ -9,7 +9,6 @@ from app.chain.site import SiteChain
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.chain.system import SystemChain
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.core.config import settings
|
||||
from app.core.event import Event as ManagerEvent, eventmanager, Event
|
||||
from app.core.plugin import PluginManager
|
||||
from app.helper.message import MessageHelper
|
||||
@@ -162,10 +161,6 @@ class Command(metaclass=Singleton):
|
||||
"""
|
||||
初始化菜单命令
|
||||
"""
|
||||
if settings.DEV:
|
||||
logger.debug("Development mode active. Skipping command initialization.")
|
||||
return
|
||||
|
||||
# 使用线程池提交后台任务,避免引起阻塞
|
||||
ThreadHelper().submit(self.__init_commands_background, pid)
|
||||
|
||||
@@ -230,6 +225,9 @@ class Command(metaclass=Singleton):
|
||||
添加命令集合
|
||||
"""
|
||||
for cmd, command in source.items():
|
||||
if not command.get("show", True):
|
||||
continue
|
||||
|
||||
command_data = {
|
||||
"type": command_type,
|
||||
"description": command.get("description"),
|
||||
@@ -266,6 +264,7 @@ class Command(metaclass=Singleton):
|
||||
"func": self.send_plugin_event,
|
||||
"description": command.get("desc"),
|
||||
"category": command.get("category"),
|
||||
"show": command.get("show", True),
|
||||
"data": {
|
||||
"etype": command.get("event"),
|
||||
"data": command.get("data")
|
||||
@@ -340,7 +339,8 @@ class Command(metaclass=Singleton):
|
||||
return self._commands.get(cmd, {})
|
||||
|
||||
def register(self, cmd: str, func: Any, data: Optional[dict] = None,
|
||||
desc: Optional[str] = None, category: Optional[str] = None) -> None:
|
||||
desc: Optional[str] = None, category: Optional[str] = None,
|
||||
show: bool = True) -> None:
|
||||
"""
|
||||
注册单个命令
|
||||
"""
|
||||
@@ -349,7 +349,8 @@ class Command(metaclass=Singleton):
|
||||
"func": func,
|
||||
"description": desc,
|
||||
"category": category,
|
||||
"data": data or {}
|
||||
"data": data or {},
|
||||
"show": show
|
||||
}
|
||||
|
||||
def execute(self, cmd: str, data_str: Optional[str] = "",
|
||||
|
||||
@@ -131,7 +131,7 @@ class CacheToolsBackend(CacheBackend):
|
||||
- 不支持按 `key` 独立隔离 TTL 和 Maxsize,仅支持作用于 region 级别
|
||||
"""
|
||||
|
||||
def __init__(self, maxsize: Optional[int] = 1000, ttl: Optional[int] = 1800):
|
||||
def __init__(self, maxsize: Optional[int] = 512, ttl: Optional[int] = 1800):
|
||||
"""
|
||||
初始化缓存实例
|
||||
|
||||
@@ -150,7 +150,7 @@ class CacheToolsBackend(CacheBackend):
|
||||
region = self.get_region(region)
|
||||
return self._region_caches.get(region)
|
||||
|
||||
def set(self, key: str, value: Any, ttl: Optional[int] = None,
|
||||
def set(self, key: str, value: Any, ttl: Optional[int] = None,
|
||||
region: Optional[str] = DEFAULT_CACHE_REGION, **kwargs) -> None:
|
||||
"""
|
||||
设置缓存值支持每个 key 独立配置 TTL 和 Maxsize
|
||||
@@ -357,7 +357,7 @@ class RedisBackend(CacheBackend):
|
||||
region = self.get_region(quote(region))
|
||||
return f"{region}:key:{quote(key)}"
|
||||
|
||||
def set(self, key: str, value: Any, ttl: Optional[int] = None,
|
||||
def set(self, key: str, value: Any, ttl: Optional[int] = None,
|
||||
region: Optional[str] = DEFAULT_CACHE_REGION, **kwargs) -> None:
|
||||
"""
|
||||
设置缓存
|
||||
@@ -454,7 +454,7 @@ class RedisBackend(CacheBackend):
|
||||
self.client.close()
|
||||
|
||||
|
||||
def get_cache_backend(maxsize: Optional[int] = 1000, ttl: Optional[int] = 1800) -> CacheBackend:
|
||||
def get_cache_backend(maxsize: Optional[int] = 512, ttl: Optional[int] = 1800) -> CacheBackend:
|
||||
"""
|
||||
根据配置获取缓存后端实例
|
||||
|
||||
@@ -482,13 +482,13 @@ def get_cache_backend(maxsize: Optional[int] = 1000, ttl: Optional[int] = 1800)
|
||||
return CacheToolsBackend(maxsize=maxsize, ttl=ttl)
|
||||
|
||||
|
||||
def cached(region: Optional[str] = None, maxsize: Optional[int] = 1000, ttl: Optional[int] = 1800,
|
||||
def cached(region: Optional[str] = None, maxsize: Optional[int] = 512, ttl: Optional[int] = 1800,
|
||||
skip_none: Optional[bool] = True, skip_empty: Optional[bool] = False):
|
||||
"""
|
||||
自定义缓存装饰器,支持为每个 key 动态传递 maxsize 和 ttl
|
||||
|
||||
:param region: 缓存的区
|
||||
:param maxsize: 缓存的最大条目数,默认值为 1000
|
||||
:param maxsize: 缓存的最大条目数,默认值为 512
|
||||
:param ttl: 缓存的存活时间,单位秒,默认值为 1800
|
||||
:param skip_none: 跳过 None 缓存,默认为 True
|
||||
:param skip_empty: 跳过空值缓存(如 None, [], {}, "", set()),默认为 False
|
||||
|
||||
@@ -15,6 +15,34 @@ from app.utils.system import SystemUtils
|
||||
from app.utils.url import UrlUtils
|
||||
|
||||
|
||||
class SystemConfModel(BaseModel):
|
||||
"""
|
||||
系统关键资源大小配置
|
||||
"""
|
||||
# 缓存种子数量
|
||||
torrents: int = 0
|
||||
# 订阅刷新处理数量
|
||||
refresh: int = 0
|
||||
# TMDB请求缓存数量
|
||||
tmdb: int = 0
|
||||
# 豆瓣请求缓存数量
|
||||
douban: int = 0
|
||||
# Bangumi请求缓存数量
|
||||
bangumi: int = 0
|
||||
# Fanart请求缓存数量
|
||||
fanart: int = 0
|
||||
# 元数据缓存过期时间(秒)
|
||||
meta: int = 0
|
||||
# 调度器数量
|
||||
scheduler: int = 0
|
||||
# 线程池大小
|
||||
threadpool: int = 0
|
||||
# 数据库连接池大小
|
||||
dbpool: int = 0
|
||||
# 数据库连接池溢出数量
|
||||
dbpooloverflow: int = 0
|
||||
|
||||
|
||||
class ConfigModel(BaseModel):
|
||||
"""
|
||||
Pydantic 配置模型,描述所有配置项及其类型和默认值
|
||||
@@ -57,16 +85,12 @@ class ConfigModel(BaseModel):
|
||||
DB_ECHO: bool = False
|
||||
# 数据库连接池类型,QueuePool, NullPool
|
||||
DB_POOL_TYPE: str = "QueuePool"
|
||||
# 是否在获取连接时进行预先 ping 操作,默认关闭
|
||||
DB_POOL_PRE_PING: bool = False
|
||||
# 数据库连接池的大小,默认 100
|
||||
DB_POOL_SIZE: int = 100
|
||||
# 数据库连接的回收时间(秒),默认 1800 秒
|
||||
DB_POOL_RECYCLE: int = 1800
|
||||
# 数据库连接池获取连接的超时时间(秒),默认 60 秒
|
||||
DB_POOL_TIMEOUT: int = 60
|
||||
# 数据库连接池最大溢出连接数,默认 500
|
||||
DB_MAX_OVERFLOW: int = 500
|
||||
# 是否在获取连接时进行预先 ping 操作
|
||||
DB_POOL_PRE_PING: bool = True
|
||||
# 数据库连接的回收时间(秒)
|
||||
DB_POOL_RECYCLE: int = 300
|
||||
# 数据库连接池获取连接的超时时间(秒)
|
||||
DB_POOL_TIMEOUT: int = 30
|
||||
# SQLite 的 busy_timeout 参数,默认为 60 秒
|
||||
DB_TIMEOUT: int = 60
|
||||
# SQLite 是否启用 WAL 模式,默认开启
|
||||
@@ -125,7 +149,7 @@ class ConfigModel(BaseModel):
|
||||
# 元数据识别缓存过期时间(小时)
|
||||
META_CACHE_EXPIRE: int = 0
|
||||
# 电视剧动漫的分类genre_ids
|
||||
ANIME_GENREIDS: List[int] = [16]
|
||||
ANIME_GENREIDS: List[int] = Field(default=[16])
|
||||
# 用户认证站点
|
||||
AUTH_SITE: str = ""
|
||||
# 重启自动升级
|
||||
@@ -141,6 +165,7 @@ class ConfigModel(BaseModel):
|
||||
"api.github.com,"
|
||||
"github.com,"
|
||||
"raw.githubusercontent.com,"
|
||||
"codeload.github.com,"
|
||||
"api.telegram.org")
|
||||
# DOH 解析服务器列表
|
||||
DOH_RESOLVERS: str = "1.0.0.1,1.1.1.1,9.9.9.9,149.112.112.112"
|
||||
@@ -243,7 +268,7 @@ class ConfigModel(BaseModel):
|
||||
GITHUB_TOKEN: Optional[str] = None
|
||||
# Github代理服务器,格式:https://mirror.ghproxy.com/
|
||||
GITHUB_PROXY: Optional[str] = ''
|
||||
# pip镜像站点,格式:https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
# pip镜像站点,格式:https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
PIP_PROXY: Optional[str] = ''
|
||||
# 指定的仓库Github token,多个仓库使用,分隔,格式:{user1}/{repo1}:ghp_****,{user2}/{repo2}:github_pat_****
|
||||
REPO_GITHUB_TOKEN: Optional[str] = None
|
||||
@@ -252,7 +277,7 @@ class ConfigModel(BaseModel):
|
||||
# 是否启用内存监控
|
||||
MEMORY_ANALYSIS: bool = False
|
||||
# 内存快照间隔(分钟)
|
||||
MEMORY_SNAPSHOT_INTERVAL: int = 60
|
||||
MEMORY_SNAPSHOT_INTERVAL: int = 30
|
||||
# 保留的内存快照文件数量
|
||||
MEMORY_SNAPSHOT_KEEP_COUNT: int = 20
|
||||
# 全局图片缓存,将媒体图片缓存到本地
|
||||
@@ -524,43 +549,37 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
return self.CONFIG_PATH / "cookies"
|
||||
|
||||
@property
|
||||
def CONF(self):
|
||||
def CONF(self) -> SystemConfModel:
|
||||
"""
|
||||
{
|
||||
"torrents": "缓存种子数量",
|
||||
"refresh": "订阅刷新处理数量",
|
||||
"tmdb": "TMDB请求缓存数量",
|
||||
"douban": "豆瓣请求缓存数量",
|
||||
"fanart": "Fanart请求缓存数量",
|
||||
"meta": "元数据缓存过期时间(秒)",
|
||||
"memory": "最大占用内存(MB)",
|
||||
"scheduler": "调度器缓存数量"
|
||||
"threadpool": "线程池数量"
|
||||
}
|
||||
根据内存模式返回系统配置
|
||||
"""
|
||||
if self.BIG_MEMORY_MODE:
|
||||
return {
|
||||
"torrents": 200,
|
||||
"refresh": 100,
|
||||
"tmdb": 1024,
|
||||
"douban": 512,
|
||||
"bangumi": 512,
|
||||
"fanart": 512,
|
||||
"meta": (self.META_CACHE_EXPIRE or 24) * 3600,
|
||||
"scheduler": 100,
|
||||
"threadpool": 100
|
||||
}
|
||||
return {
|
||||
"torrents": 100,
|
||||
"refresh": 50,
|
||||
"tmdb": 256,
|
||||
"douban": 256,
|
||||
"bangumi": 256,
|
||||
"fanart": 128,
|
||||
"meta": (self.META_CACHE_EXPIRE or 2) * 3600,
|
||||
"scheduler": 50,
|
||||
"threadpool": 50
|
||||
}
|
||||
return SystemConfModel(
|
||||
torrents=200,
|
||||
refresh=100,
|
||||
tmdb=1024,
|
||||
douban=512,
|
||||
bangumi=512,
|
||||
fanart=512,
|
||||
meta=(self.META_CACHE_EXPIRE or 24) * 3600,
|
||||
scheduler=100,
|
||||
threadpool=100,
|
||||
dbpool=100,
|
||||
dbpooloverflow=50
|
||||
)
|
||||
return SystemConfModel(
|
||||
torrents=100,
|
||||
refresh=50,
|
||||
tmdb=256,
|
||||
douban=256,
|
||||
bangumi=256,
|
||||
fanart=128,
|
||||
meta=(self.META_CACHE_EXPIRE or 2) * 3600,
|
||||
scheduler=50,
|
||||
threadpool=50,
|
||||
dbpool=50,
|
||||
dbpooloverflow=20
|
||||
)
|
||||
|
||||
@property
|
||||
def PROXY(self):
|
||||
@@ -586,7 +605,8 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
"""
|
||||
if self.GITHUB_TOKEN:
|
||||
return {
|
||||
"Authorization": f"Bearer {self.GITHUB_TOKEN}"
|
||||
"Authorization": f"Bearer {self.GITHUB_TOKEN}",
|
||||
"User-Agent": self.USER_AGENT,
|
||||
}
|
||||
return {}
|
||||
|
||||
@@ -614,7 +634,8 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
print(f"无效的令牌或仓库信息: {token_pair}")
|
||||
continue
|
||||
headers[repo_info] = {
|
||||
"Authorization": f"Bearer {token}"
|
||||
"Authorization": f"Bearer {token}",
|
||||
"User-Agent": self.USER_AGENT,
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"处理令牌对 '{token_pair}' 时出错: {e}")
|
||||
|
||||
@@ -6,7 +6,6 @@ import threading
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from functools import lru_cache
|
||||
from queue import Empty, PriorityQueue
|
||||
from typing import Callable, Dict, List, Optional, Union
|
||||
|
||||
@@ -263,7 +262,6 @@ class EventManager(metaclass=Singleton):
|
||||
return handler_info
|
||||
|
||||
@classmethod
|
||||
@lru_cache(maxsize=1000)
|
||||
def __get_handler_identifier(cls, target: Union[Callable, type]) -> Optional[str]:
|
||||
"""
|
||||
获取处理器或处理器类的唯一标识符,包括模块名和类名/方法名
|
||||
@@ -279,7 +277,6 @@ class EventManager(metaclass=Singleton):
|
||||
return f"{module_name}.{qualname}"
|
||||
|
||||
@classmethod
|
||||
@lru_cache(maxsize=1000)
|
||||
def __get_class_from_callable(cls, handler: Callable) -> Optional[str]:
|
||||
"""
|
||||
获取可调用对象所属类的唯一标识符
|
||||
|
||||
@@ -55,6 +55,8 @@ class MetaBase(object):
|
||||
resource_team: Optional[str] = None
|
||||
# 识别的自定义占位符
|
||||
customization: Optional[str] = None
|
||||
# 识别的流媒体平台
|
||||
web_source: Optional[str] = None
|
||||
# 视频编码
|
||||
video_encode: Optional[str] = None
|
||||
# 音频编码
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.core.meta.releasegroup import ReleaseGroupsMatcher
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.string import StringUtils
|
||||
from app.utils.tokens import Tokens
|
||||
from app.core.meta.streamingplatform import StreamingPlatforms
|
||||
|
||||
|
||||
class MetaVideo(MetaBase):
|
||||
@@ -31,7 +32,7 @@ class MetaVideo(MetaBase):
|
||||
_part_re = r"(^PART[0-9ABI]{0,2}$|^CD[0-9]{0,2}$|^DVD[0-9]{0,2}$|^DISK[0-9]{0,2}$|^DISC[0-9]{0,2}$)"
|
||||
_roman_numerals = r"^(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$"
|
||||
_source_re = r"^BLURAY$|^HDTV$|^UHDTV$|^HDDVD$|^WEBRIP$|^DVDRIP$|^BDRIP$|^BLU$|^WEB$|^BD$|^HDRip$|^REMUX$|^UHD$"
|
||||
_effect_re = r"^SDR$|^HDR\d*$|^DOLBY$|^DOVI$|^DV$|^3D$|^REPACK$|^HLG$|^HDR10(\+|Plus)$"
|
||||
_effect_re = r"^SDR$|^HDR\d*$|^DOLBY$|^DOVI$|^DV$|^3D$|^REPACK$|^HLG$|^HDR10(\+|Plus)$|^EDR$|^HQ$"
|
||||
_resources_type_re = r"%s|%s" % (_source_re, _effect_re)
|
||||
_name_no_begin_re = r"^[\[【].+?[\]】]"
|
||||
_name_no_chinese_re = r".*版|.*字幕"
|
||||
@@ -51,7 +52,7 @@ class MetaVideo(MetaBase):
|
||||
_resources_pix_re = r"^[SBUHD]*(\d{3,4}[PI]+)|\d{3,4}X(\d{3,4})"
|
||||
_resources_pix_re2 = r"(^[248]+K)"
|
||||
_video_encode_re = r"^(H26[45])$|^(x26[45])$|^AVC$|^HEVC$|^VC\d?$|^MPEG\d?$|^Xvid$|^DivX$|^AV1$|^HDR\d*$|^AVS(\+|[23])$"
|
||||
_audio_encode_re = r"^DTS\d?$|^DTSHD$|^DTSHDMA$|^Atmos$|^TrueHD\d?$|^AC3$|^\dAudios?$|^DDP\d?$|^DD\+\d?$|^DD\d?$|^LPCM\d?$|^AAC\d?$|^FLAC\d?$|^HD\d?$|^MA\d?$|^HR\d?$|^Opus\d?$|^Vorbis\d?$"
|
||||
_audio_encode_re = r"^DTS\d?$|^DTSHD$|^DTSHDMA$|^Atmos$|^TrueHD\d?$|^AC3$|^\dAudios?$|^DDP\d?$|^DD\+\d?$|^DD\d?$|^LPCM\d?$|^AAC\d?$|^FLAC\d?$|^HD\d?$|^MA\d?$|^HR\d?$|^Opus\d?$|^Vorbis\d?$|^AV[3S]A$"
|
||||
|
||||
def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
|
||||
"""
|
||||
@@ -66,6 +67,7 @@ class MetaVideo(MetaBase):
|
||||
original_title = title
|
||||
self._source = ""
|
||||
self._effect = []
|
||||
self._index = 0
|
||||
# 判断是否纯数字命名
|
||||
if isfile \
|
||||
and title.isdigit() \
|
||||
@@ -93,9 +95,12 @@ class MetaVideo(MetaBase):
|
||||
# 拆分tokens
|
||||
tokens = Tokens(title)
|
||||
self.tokens = tokens
|
||||
# 实例化StreamingPlatforms对象
|
||||
streaming_platforms = StreamingPlatforms()
|
||||
# 解析名称、年份、季、集、资源类型、分辨率等
|
||||
token = tokens.get_next()
|
||||
while token:
|
||||
self._index += 1 # 更新当前处理的token索引
|
||||
# Part
|
||||
self.__init_part(token)
|
||||
# 标题
|
||||
@@ -116,6 +121,9 @@ class MetaVideo(MetaBase):
|
||||
# 资源类型
|
||||
if self._continue_flag:
|
||||
self.__init_resource_type(token)
|
||||
# 流媒体平台
|
||||
if self._continue_flag:
|
||||
self.__init_web_source(token, streaming_platforms)
|
||||
# 视频编码
|
||||
if self._continue_flag:
|
||||
self.__init_video_encode(token)
|
||||
@@ -574,6 +582,57 @@ class MetaVideo(MetaBase):
|
||||
self._effect.append(effect)
|
||||
self._last_token = effect.upper()
|
||||
|
||||
def __init_web_source(self, token: str, streaming_platforms: StreamingPlatforms):
|
||||
"""
|
||||
识别流媒体平台
|
||||
"""
|
||||
if not self.name:
|
||||
return
|
||||
|
||||
platform_name = None
|
||||
query_range = 1
|
||||
|
||||
prev_token = None
|
||||
prev_idx = self._index - 2
|
||||
if 0 <= prev_idx < len(self.tokens.tokens):
|
||||
prev_token = self.tokens.tokens[prev_idx]
|
||||
|
||||
next_token = self.tokens.peek()
|
||||
|
||||
if streaming_platforms.is_streaming_platform(token):
|
||||
platform_name = streaming_platforms.get_streaming_platform_name(token)
|
||||
else:
|
||||
for adjacent_token, is_next in [(prev_token, False), (next_token, True)]:
|
||||
if not adjacent_token or platform_name:
|
||||
continue
|
||||
|
||||
for separator in [" ", "-"]:
|
||||
if is_next:
|
||||
combined_token = f"{token}{separator}{adjacent_token}"
|
||||
else:
|
||||
combined_token = f"{adjacent_token}{separator}{token}"
|
||||
|
||||
if streaming_platforms.is_streaming_platform(combined_token):
|
||||
platform_name = streaming_platforms.get_streaming_platform_name(combined_token)
|
||||
query_range = 2
|
||||
if is_next:
|
||||
self.tokens.get_next()
|
||||
break
|
||||
|
||||
if not platform_name:
|
||||
return
|
||||
|
||||
web_tokens = ["WEB", "DL", "WEBDL", "WEBRIP"]
|
||||
match_start_idx = self._index - query_range
|
||||
match_end_idx = self._index - 1
|
||||
start_index = max(0, match_start_idx - query_range)
|
||||
end_index = min(len(self.tokens.tokens), match_end_idx + 1 + query_range)
|
||||
tokens_to_check = self.tokens.tokens[start_index:end_index]
|
||||
|
||||
if any(tok and tok.upper() in web_tokens for tok in tokens_to_check):
|
||||
self.web_source = platform_name
|
||||
self._continue_flag = False
|
||||
|
||||
def __init_video_encode(self, token: str):
|
||||
"""
|
||||
识别视频编码
|
||||
|
||||
314
app/core/meta/streamingplatform.py
Normal file
314
app/core/meta/streamingplatform.py
Normal file
@@ -0,0 +1,314 @@
|
||||
from typing import Optional, List, Tuple
|
||||
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class StreamingPlatforms(metaclass=Singleton):
|
||||
"""
|
||||
流媒体平台简称与全称。
|
||||
"""
|
||||
STREAMING_PLATFORMS: List[Tuple[str, str]] = [
|
||||
("AMZN", "Amazon"),
|
||||
("NF", "Netflix"),
|
||||
("ATVP", "Apple TV+"),
|
||||
("iT", "iTunes"),
|
||||
("DSNP", "Disney+"),
|
||||
("HS", "Hotstar"),
|
||||
("APPS", "Disney+ MENA"),
|
||||
("PMTP", "Paramount+"),
|
||||
("HMAX", "Max"),
|
||||
("", "Max"),
|
||||
("HULU", "Hulu Networks"),
|
||||
("MA", "Movies Anywhere"),
|
||||
("BCORE", "Bravia Core"),
|
||||
("MS", "Microsoft Store"),
|
||||
("SHO", "Showtime"),
|
||||
("STAN", "Stan"),
|
||||
("PCOK", "Peacock"),
|
||||
("SKST", "SkyShowtime"),
|
||||
("NOW", "Now"),
|
||||
("FXTL", "Foxtel Now"),
|
||||
("BNGE", "Binge"),
|
||||
("CRKL", "Crackle"),
|
||||
("RKTN", "Rakuten TV"),
|
||||
("ALL4", "Channel 4"),
|
||||
("AS", "Adult Swim"),
|
||||
("BRTB", "Brtb TV"),
|
||||
("CNLP", "Canal+"),
|
||||
("CRIT", "Criterion Channel"),
|
||||
("DSCP", "Discovery+"),
|
||||
("FOOD", "Food Network"),
|
||||
("MUBI", "Mubi"),
|
||||
("PLAY", "Google Play"),
|
||||
("YT", "YouTube"),
|
||||
("", "friDay"),
|
||||
("", "KKTV"),
|
||||
("", "ofiii"),
|
||||
("", "LiTV"),
|
||||
("", "MyVideo"),
|
||||
("Hami", "Hami Video"),
|
||||
("HamiVideo", "Hami Video"),
|
||||
("MW", "meWATCH"),
|
||||
("CATCHPLAY", "CATCHPLAY+"),
|
||||
("CPP", "CATCHPLAY+"),
|
||||
("LINETV", "LINE TV"),
|
||||
("VIU", "Viu"),
|
||||
("IQ", ""),
|
||||
("", "WeTV"),
|
||||
("ABMA", "Abema"),
|
||||
("ADN", ""),
|
||||
("AT-X", ""),
|
||||
("Baha", ""),
|
||||
("BG", "B-Global"),
|
||||
("CR", "Crunchyroll"),
|
||||
("", "DMM"),
|
||||
("FOD", ""),
|
||||
("FUNi", "Funimation"),
|
||||
("HIDI", "HIDIVE"),
|
||||
("UNXT", "U-NEXT"),
|
||||
("FAA", "Filmarchiv Austria"),
|
||||
("CC", "Comedy Central"),
|
||||
("iP", "BBC iPlayer"),
|
||||
("9NOW", "9Now"),
|
||||
("ABC", ""),
|
||||
("", "AMC"),
|
||||
("", "ZEE5"),
|
||||
("", "WAVO"),
|
||||
("SHAHID", "Shahid"),
|
||||
("Flixole", "FlixOlé"),
|
||||
("TOU", "Ici TOU.TV"),
|
||||
("ROKU", "Roku"),
|
||||
("KNPY", "Kanopy"),
|
||||
("SNXT", "Sun NXT"),
|
||||
("CUR", "Curiosity Stream"),
|
||||
("MY5", "Channel 5"),
|
||||
("AHA", "aha"),
|
||||
("WOWP", "WOW Presents Plus"),
|
||||
("JC", "JioCinema"),
|
||||
("", "Dekkoo"),
|
||||
("FILMZIE", "Filmzie"),
|
||||
("HoiChoi", "Hoichoi"),
|
||||
("VIKI", "Rakuten Viki"),
|
||||
("SF", "SF Anytime"),
|
||||
("PLEX", "Plex"),
|
||||
("SHDR", "Shudder"),
|
||||
("CRAV", "Crave"),
|
||||
("CPE", "Cineplex Entertainment"),
|
||||
("JF HC", ""),
|
||||
("JF", ""),
|
||||
("JFFP", ""),
|
||||
("VIAP", "Viaplay"),
|
||||
("TUBI", "TubiTV"),
|
||||
("", "PBS"),
|
||||
("PBSK", "PBS KIDS"),
|
||||
("LGP", "Lionsgate Play"),
|
||||
("", "CTV"),
|
||||
("", "Cineverse"),
|
||||
("LN", "Love Nature"),
|
||||
("MP", "Movistar Plus+"),
|
||||
("RUNTIME", "Runtime"),
|
||||
("STZ", "STARZ"),
|
||||
("FUBO", "fuboTV"),
|
||||
("TENK", "Tënk"),
|
||||
("KNOW", "Knowledge Network"),
|
||||
("TVO", "tvo"),
|
||||
("", "OVID"),
|
||||
("CBC", "CBC Gem"),
|
||||
("FANDOR", "fandor"),
|
||||
("CW", "The CW"),
|
||||
("KNPY", "Kanopy"),
|
||||
("FREE", "Freeform"),
|
||||
("AE", "A&E"),
|
||||
("LIFE", "Lifetime"),
|
||||
("WWEN", "WWE Network"),
|
||||
("CMAX", "Cinemax"),
|
||||
("HLMK", "Hallmark"),
|
||||
("BYU", "BYUtv"),
|
||||
("", "ViX"),
|
||||
("VICE", "Viceland"),
|
||||
("", "TVING"),
|
||||
("USAN", "USA Network"),
|
||||
("FOX", ""),
|
||||
("", "TCM"),
|
||||
("BRAV", "BravoTV"),
|
||||
("", "TNT"),
|
||||
("", "ZDF"),
|
||||
("", "IndieFlix"),
|
||||
("", "TLC"),
|
||||
("", "HGTV"),
|
||||
("ANPL", "Animal Planet"),
|
||||
("TRVL", "Travel Channel"),
|
||||
("", "VH1"),
|
||||
("SAINA", "Saina Play"),
|
||||
("SP", "Saina Play"),
|
||||
("OXGN", "Oxygen"),
|
||||
("PSN", "PlayStation Network"),
|
||||
("PMNT", "Paramount Network"),
|
||||
("FAWESOME", "Fawesome"),
|
||||
("KLASSIKI", "Klassiki"),
|
||||
("STRP", "Star+"),
|
||||
("NATG", "National Geographic"),
|
||||
("REVEEL", "Reveel"),
|
||||
("FYI", "FYI Network"),
|
||||
("WatchiT", "WATCH IT"),
|
||||
("ITVX", "ITV"),
|
||||
("GAIA", "Gaia"),
|
||||
("", "FlixLatino"),
|
||||
("CNNP", "CNN+"),
|
||||
("TROMA", "Troma"),
|
||||
("IVI", "Ivi"),
|
||||
("9NOW", "9Now"),
|
||||
("A3P", "Atresplayer"),
|
||||
("7PLUS", "7plus"),
|
||||
("", "SBS"),
|
||||
("TEN", "10Play"),
|
||||
("AUBC", ""),
|
||||
("DSNY", "Disney Networks"),
|
||||
("OSN", "OSN+"),
|
||||
("SVT", "Sveriges Television"),
|
||||
("LACINETEK", "LaCinetek"),
|
||||
("", "Maxdome"),
|
||||
("RTL", "RTL+"),
|
||||
("ARTE", "Arte"),
|
||||
("JOYN", "Joyn"),
|
||||
("TV2", "TV 2"),
|
||||
("3SAT", "3sat"),
|
||||
("FILMINGO", "filmingo"),
|
||||
("", "WOW"),
|
||||
("OKKO", "Okko"),
|
||||
("", "Go3"),
|
||||
("ARGP", "Argo"),
|
||||
("VOYO", "Voyo"),
|
||||
("VMAX", "vivamax"),
|
||||
("FILMIN", "Filmin"),
|
||||
("", "Mitele"),
|
||||
("MY5", "Channel 5"),
|
||||
("", "ARD"),
|
||||
("BK", "Bentkey"),
|
||||
("BOOM", "Boomerang"),
|
||||
("", "CBS"),
|
||||
("CLBI", "Club illico"),
|
||||
("CMOR", "C More"),
|
||||
("CMT", ""),
|
||||
("", "CNBC"),
|
||||
("COOK", "Cooking Channel"),
|
||||
("CWS", "CW Seed"),
|
||||
("DCU", "DC Universe"),
|
||||
("DDY", "Digiturk Dilediğin Yerde"),
|
||||
("DEST", "Destination America"),
|
||||
("DISC", "Discovery Channel"),
|
||||
("DW", "DailyWire+"),
|
||||
("DLWP", "DailyWire+"),
|
||||
("DPLY", "dplay"),
|
||||
("DRPO", "Dropout"),
|
||||
("EPIX", "EPIX MGM+"),
|
||||
("ESQ", "Esquire"),
|
||||
("ETV", "E!"),
|
||||
("FBWatch", "Facebook Watch"),
|
||||
("FPT", "FPT Play"),
|
||||
("FTV", "France.tv"),
|
||||
("GLOB", "GloboSat Play"),
|
||||
("GLBO", "Globoplay"),
|
||||
("GO90", "go90"),
|
||||
("HIST", "History Channel"),
|
||||
("HPLAY", "Hungama Play"),
|
||||
("KS", "Kaleidescape"),
|
||||
("", "MBC"),
|
||||
("MMAX", "ManoramaMAX"),
|
||||
("MNBC", "MSNBC"),
|
||||
("MTOD", "Motor Trend OnDemand"),
|
||||
("NBC", ""),
|
||||
("NBLA", "Nebula"),
|
||||
("NICK", "Nickelodeon"),
|
||||
("ODK", "OnDemandKorea"),
|
||||
("POGO", "PokerGO"),
|
||||
("PUHU", "puhutv"),
|
||||
("QIBI", "Quibi"),
|
||||
("RTE", "RTÉ"),
|
||||
("SESO", "Seeso"),
|
||||
("SPIK", "Spike"),
|
||||
("SS", "Simply South"),
|
||||
("SYFY", "SyFy"),
|
||||
("TIMV", "TIMvision"),
|
||||
("TK", "Tentkotta"),
|
||||
("", "TV4"),
|
||||
("TVL", "TV Land"),
|
||||
("", "TVNZ"),
|
||||
("", "UKTV"),
|
||||
("VLCT", "Discovery Velocity"),
|
||||
("VMEO", "Vimeo"),
|
||||
("VRV", "VRV Defunct"),
|
||||
("WTCH", "Watcha"),
|
||||
("", "NowPlayer"),
|
||||
("HuluJP", "Hulu Networks"),
|
||||
("Gaga", "GagaOOLala"),
|
||||
("MyTVS", "MyTVSuper"),
|
||||
("", "BBC"),
|
||||
("CC", "Comedy Central"),
|
||||
("NowE", "Now E"),
|
||||
("WAVVE", "Wavve"),
|
||||
("SE", ""),
|
||||
("", "BritBox"),
|
||||
("AOD", "Anime on Demand"),
|
||||
("AF", ""),
|
||||
("BCH", "Bandai Channel"),
|
||||
("VMJ", "VideoMarket"),
|
||||
("LFTL", "Laftel"),
|
||||
("WAKA", "Wakanim"),
|
||||
("WAKANIM", "Wakanim"),
|
||||
("AO", "AnimeOnegai"),
|
||||
("", "Lemino"),
|
||||
("VIDIO", "Vidio"),
|
||||
("TVER", "TVer"),
|
||||
("", "MBS"),
|
||||
("LFTLNET", "Laftel"),
|
||||
("JONU", "Jonu Play"),
|
||||
("PlutoTV", "Pluto TV"),
|
||||
("AbemaTV", "Abema"),
|
||||
("", "dTV"),
|
||||
("NYMEY", "Nymey"),
|
||||
("SMNS", "SAMANSA"),
|
||||
("CTHP", "CATCHPLAY+"),
|
||||
("HBOGO", "HBO GO"),
|
||||
("HBO", "HBO"),
|
||||
("FPTP", "FPT Play"),
|
||||
("", "LOCIPO"),
|
||||
("DANT", "DANET"),
|
||||
("OV", "OceanVeil"),
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
"""初始化流媒体平台匹配器"""
|
||||
self._lookup_cache = {}
|
||||
self._build_cache()
|
||||
|
||||
def _build_cache(self) -> None:
|
||||
"""
|
||||
构建查询缓存。
|
||||
"""
|
||||
self._lookup_cache.clear()
|
||||
for short_name, full_name in self.STREAMING_PLATFORMS:
|
||||
canonical_name = full_name or short_name
|
||||
if not canonical_name:
|
||||
continue
|
||||
|
||||
aliases = {short_name, full_name}
|
||||
for alias in aliases:
|
||||
if alias:
|
||||
self._lookup_cache[alias.upper()] = canonical_name
|
||||
|
||||
def get_streaming_platform_name(self, platform_code: str) -> Optional[str]:
|
||||
"""
|
||||
根据流媒体平台简称或全称获取标准名称。
|
||||
"""
|
||||
if platform_code is None:
|
||||
return None
|
||||
return self._lookup_cache.get(platform_code.upper())
|
||||
|
||||
def is_streaming_platform(self, name: str) -> bool:
|
||||
"""
|
||||
判断给定的字符串是否为已知的流媒体平台代码或名称。
|
||||
"""
|
||||
if name is None:
|
||||
return False
|
||||
return name.upper() in self._lookup_cache
|
||||
@@ -154,35 +154,35 @@ def find_metainfo(title: str) -> Tuple[str, dict]:
|
||||
# 去除title中该部分
|
||||
if tmdbid or mtype or begin_season or end_season or begin_episode or end_episode:
|
||||
title = title.replace(f"{{[{result}]}}", '')
|
||||
|
||||
|
||||
# 支持Emby格式的ID标签
|
||||
# 1. [tmdbid=xxxx] 或 [tmdbid-xxxx] 格式
|
||||
tmdb_match = re.search(r'\[tmdbid[=\-](\d+)\]', title)
|
||||
if tmdb_match:
|
||||
metainfo['tmdbid'] = tmdb_match.group(1)
|
||||
title = re.sub(r'\[tmdbid[=\-](\d+)\]', '', title).strip()
|
||||
|
||||
|
||||
# 2. [tmdb=xxxx] 或 [tmdb-xxxx] 格式
|
||||
if not metainfo['tmdbid']:
|
||||
tmdb_match = re.search(r'\[tmdb[=\-](\d+)\]', title)
|
||||
if tmdb_match:
|
||||
metainfo['tmdbid'] = tmdb_match.group(1)
|
||||
title = re.sub(r'\[tmdb[=\-](\d+)\]', '', title).strip()
|
||||
|
||||
|
||||
# 3. {tmdbid=xxxx} 或 {tmdbid-xxxx} 格式
|
||||
if not metainfo['tmdbid']:
|
||||
tmdb_match = re.search(r'\{tmdbid[=\-](\d+)\}', title)
|
||||
if tmdb_match:
|
||||
metainfo['tmdbid'] = tmdb_match.group(1)
|
||||
title = re.sub(r'\{tmdbid[=\-](\d+)\}', '', title).strip()
|
||||
|
||||
|
||||
# 4. {tmdb=xxxx} 或 {tmdb-xxxx} 格式
|
||||
if not metainfo['tmdbid']:
|
||||
tmdb_match = re.search(r'\{tmdb[=\-](\d+)\}', title)
|
||||
if tmdb_match:
|
||||
metainfo['tmdbid'] = tmdb_match.group(1)
|
||||
title = re.sub(r'\{tmdb[=\-](\d+)\}', '', title).strip()
|
||||
|
||||
|
||||
# 计算季集总数
|
||||
if metainfo.get('begin_season') and metainfo.get('end_season'):
|
||||
if metainfo['begin_season'] > metainfo['end_season']:
|
||||
|
||||
@@ -3,6 +3,7 @@ import concurrent.futures
|
||||
import importlib.util
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
@@ -19,7 +20,6 @@ from app.core.config import settings
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.db.plugindata_oper import PluginDataOper
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.module import ModuleHelper
|
||||
from app.helper.plugin import PluginHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.log import logger
|
||||
@@ -122,21 +122,10 @@ class PluginManager(metaclass=Singleton):
|
||||
return False
|
||||
return True
|
||||
|
||||
# 扫描插件目录
|
||||
if pid:
|
||||
# 加载指定插件
|
||||
plugins = ModuleHelper.load_with_pre_filter(
|
||||
"app.plugins",
|
||||
filter_func=lambda name, obj: check_module(obj) and name == pid
|
||||
)
|
||||
else:
|
||||
# 加载所有插件
|
||||
plugins = ModuleHelper.load(
|
||||
"app.plugins",
|
||||
filter_func=lambda _, obj: check_module(obj)
|
||||
)
|
||||
# 已安装插件
|
||||
installed_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
# 扫描插件目录,只加载符合条件的插件
|
||||
plugins = self._load_selective_plugins(pid, installed_plugins, check_module)
|
||||
# 排序
|
||||
plugins.sort(key=lambda x: x.plugin_order if hasattr(x, "plugin_order") else 0)
|
||||
for plugin in plugins:
|
||||
@@ -152,11 +141,6 @@ class PluginManager(metaclass=Singleton):
|
||||
continue
|
||||
# 存储Class
|
||||
self._plugins[plugin_id] = plugin
|
||||
# 未安装的不加载
|
||||
if plugin_id not in installed_plugins:
|
||||
# 设置事件状态为不可用
|
||||
eventmanager.disable_event_handler(plugin)
|
||||
continue
|
||||
# 生成实例
|
||||
plugin_obj = plugin()
|
||||
# 生效插件配置
|
||||
@@ -201,7 +185,7 @@ class PluginManager(metaclass=Singleton):
|
||||
logger.info(f"正在停止插件 {pid}...")
|
||||
plugin_obj = self._running_plugins.get(pid)
|
||||
if not plugin_obj:
|
||||
logger.warning(f"插件 {pid} 不存在或未加载")
|
||||
logger.debug(f"插件 {pid} 不存在或未加载")
|
||||
return
|
||||
plugins = {pid: plugin_obj}
|
||||
else:
|
||||
@@ -213,13 +197,92 @@ class PluginManager(metaclass=Singleton):
|
||||
# 清空对像
|
||||
if pid:
|
||||
# 清空指定插件
|
||||
self._plugins.pop(pid, None)
|
||||
self._running_plugins.pop(pid, None)
|
||||
# 清除插件模块缓存,包括所有子模块
|
||||
self._clear_plugin_modules(pid)
|
||||
else:
|
||||
# 清空
|
||||
self._plugins = {}
|
||||
self._running_plugins = {}
|
||||
# 清除所有插件模块缓存
|
||||
self._clear_plugin_modules()
|
||||
logger.info("插件停止完成")
|
||||
|
||||
@staticmethod
|
||||
def _load_selective_plugins(pid: Optional[str], installed_plugins: List[str],
|
||||
check_module_func: Callable) -> List[Any]:
|
||||
"""
|
||||
选择性加载插件,只import符合条件的插件
|
||||
:param pid: 指定插件ID,为空则加载所有已安装插件
|
||||
:param installed_plugins: 已安装插件列表
|
||||
:param check_module_func: 模块检查函数
|
||||
:return: 插件类列表
|
||||
"""
|
||||
import importlib
|
||||
|
||||
plugins = []
|
||||
plugins_dir = settings.ROOT_PATH / "app" / "plugins"
|
||||
|
||||
if not plugins_dir.exists():
|
||||
logger.warning(f"插件目录不存在:{plugins_dir}")
|
||||
return plugins
|
||||
|
||||
# 确定需要加载的插件目录名称列表
|
||||
if pid:
|
||||
# 加载指定插件
|
||||
target_plugins = [pid.lower()]
|
||||
else:
|
||||
# 加载已安装插件
|
||||
target_plugins = [plugin_id.lower() for plugin_id in installed_plugins]
|
||||
|
||||
if not target_plugins:
|
||||
logger.debug("没有需要加载的插件")
|
||||
return plugins
|
||||
|
||||
# 扫描plugins目录
|
||||
_loaded_modules = set()
|
||||
for plugin_dir in plugins_dir.iterdir():
|
||||
if not plugin_dir.is_dir() or plugin_dir.name.startswith('_'):
|
||||
continue
|
||||
|
||||
# 检查是否是需要加载的插件
|
||||
if plugin_dir.name not in target_plugins:
|
||||
logger.debug(f"跳过插件目录:{plugin_dir.name}(不在加载列表中)")
|
||||
continue
|
||||
|
||||
# 检查__init__.py是否存在
|
||||
init_file = plugin_dir / "__init__.py"
|
||||
if not init_file.exists():
|
||||
logger.debug(f"跳过插件目录:{plugin_dir.name}(缺少__init__.py)")
|
||||
continue
|
||||
|
||||
try:
|
||||
# 构建模块名
|
||||
module_name = f"app.plugins.{plugin_dir.name}"
|
||||
logger.debug(f"正在导入插件模块:{module_name}")
|
||||
|
||||
# 导入模块
|
||||
module = importlib.import_module(module_name)
|
||||
importlib.reload(module)
|
||||
|
||||
# 检查模块中的类
|
||||
for name, obj in module.__dict__.items():
|
||||
if name.startswith('_') or not isinstance(obj, type):
|
||||
continue
|
||||
if name in _loaded_modules:
|
||||
continue
|
||||
if check_module_func(obj):
|
||||
_loaded_modules.add(name)
|
||||
plugins.append(obj)
|
||||
logger.debug(f"找到符合条件的插件类:{name}")
|
||||
break
|
||||
|
||||
except Exception as err:
|
||||
logger.error(f"加载插件 {plugin_dir.name} 失败:{str(err)} - {traceback.format_exc()}")
|
||||
|
||||
return plugins
|
||||
|
||||
@property
|
||||
def running_plugins(self) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -247,6 +310,7 @@ class PluginManager(metaclass=Singleton):
|
||||
event_data: schemas.ConfigChangeEventData = event.event_data
|
||||
if event_data.key not in ['DEV', 'PLUGIN_AUTO_RELOAD']:
|
||||
return
|
||||
logger.info("配置变更,重新加载插件文件修改监测...")
|
||||
self.reload_monitor()
|
||||
|
||||
def reload_monitor(self):
|
||||
@@ -307,28 +371,54 @@ class PluginManager(metaclass=Singleton):
|
||||
"""
|
||||
self.stop(plugin_id)
|
||||
|
||||
# 从模块列表中移除插件
|
||||
from sys import modules
|
||||
try:
|
||||
del modules[f"app.plugins.{plugin_id.lower()}"]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def reload_plugin(self, plugin_id: str):
|
||||
"""
|
||||
将一个插件重新加载到内存
|
||||
:param plugin_id: 插件ID
|
||||
"""
|
||||
# 先移除
|
||||
# 先移除插件实例
|
||||
self.stop(plugin_id)
|
||||
# 重新加载
|
||||
self.start(plugin_id)
|
||||
# 广播事件
|
||||
eventmanager.send_event(EventType.PluginReload, data={"plugin_id": plugin_id})
|
||||
|
||||
@staticmethod
|
||||
def _clear_plugin_modules(plugin_id: Optional[str] = None):
|
||||
"""
|
||||
清除插件及其所有子模块的缓存
|
||||
:param plugin_id: 插件ID
|
||||
"""
|
||||
|
||||
# 构建插件模块前缀
|
||||
if plugin_id:
|
||||
plugin_module_prefix = f"app.plugins.{plugin_id.lower()}"
|
||||
else:
|
||||
plugin_module_prefix = "app.plugins"
|
||||
|
||||
# 收集需要删除的模块名(创建模块名列表的副本以避免迭代时修改字典)
|
||||
modules_to_remove = []
|
||||
for module_name in list(sys.modules.keys()):
|
||||
if module_name == plugin_module_prefix or module_name.startswith(plugin_module_prefix + "."):
|
||||
modules_to_remove.append(module_name)
|
||||
|
||||
# 删除模块
|
||||
for module_name in modules_to_remove:
|
||||
try:
|
||||
del sys.modules[module_name]
|
||||
logger.debug(f"已清除插件模块缓存:{module_name}")
|
||||
except KeyError:
|
||||
# 模块可能已经被删除
|
||||
pass
|
||||
if plugin_id:
|
||||
if modules_to_remove:
|
||||
logger.info(f"插件 {plugin_id} 共清除 {len(modules_to_remove)} 个模块缓存:{modules_to_remove}")
|
||||
else:
|
||||
logger.debug(f"插件 {plugin_id} 没有找到需要清除的模块缓存")
|
||||
|
||||
def sync(self) -> List[str]:
|
||||
"""
|
||||
安装本地不存在的在线插件
|
||||
安装本地不存在或需要更新的插件
|
||||
"""
|
||||
|
||||
def install_plugin(plugin):
|
||||
@@ -354,7 +444,7 @@ class PluginManager(metaclass=Singleton):
|
||||
# 确定需要安装的插件
|
||||
plugins_to_install = [
|
||||
plugin for plugin in online_plugins
|
||||
if plugin.id in install_plugins and not self.is_plugin_exists(plugin.id)
|
||||
if plugin.id in install_plugins and not self.is_plugin_exists(plugin.id, plugin.plugin_version)
|
||||
]
|
||||
|
||||
if not plugins_to_install:
|
||||
@@ -889,10 +979,11 @@ class PluginManager(metaclass=Singleton):
|
||||
return plugins
|
||||
|
||||
@staticmethod
|
||||
def is_plugin_exists(pid: str) -> bool:
|
||||
def is_plugin_exists(pid: str, version: str = None) -> bool:
|
||||
"""
|
||||
判断插件是否在本地包中存在
|
||||
判断插件是否存在,并满足版本要求(有传入version时)
|
||||
:param pid: 插件ID
|
||||
:param version: 插件版本
|
||||
"""
|
||||
if not pid:
|
||||
return False
|
||||
@@ -903,7 +994,18 @@ class PluginManager(metaclass=Singleton):
|
||||
spec = importlib.util.find_spec(package_name)
|
||||
package_exists = spec is not None and spec.origin is not None
|
||||
logger.debug(f"{pid} exists: {package_exists}")
|
||||
return package_exists
|
||||
if not package_exists:
|
||||
return False
|
||||
|
||||
local_version = PluginManager().get_plugin_attr(pid=pid, attr="plugin_version")
|
||||
if not local_version:
|
||||
return False
|
||||
|
||||
if version and not StringUtils.compare_version(local_version, ">=", version):
|
||||
logger.warn(f"Plugin {pid} version: {local_version} (older than version: {version})")
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"获取插件是否在本地包中存在失败,{e}")
|
||||
return False
|
||||
@@ -1345,8 +1447,9 @@ class PluginManager(metaclass=Singleton):
|
||||
content = f.read()
|
||||
|
||||
# 替换CSS中可能的类名引用
|
||||
content = content.replace(original_class_name.lower(), clone_class_name.lower())
|
||||
content = content.replace(original_class_name, clone_class_name)
|
||||
content = content.replace(original_class_name.lower(),
|
||||
clone_class_name.lower()).replace(original_class_name,
|
||||
clone_class_name)
|
||||
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
@@ -24,9 +24,9 @@ db_kwargs = {
|
||||
# 当使用 QueuePool 时,添加 QueuePool 特有的参数
|
||||
if pool_class == QueuePool:
|
||||
db_kwargs.update({
|
||||
"pool_size": settings.DB_POOL_SIZE,
|
||||
"pool_size": settings.CONF.dbpool,
|
||||
"pool_timeout": settings.DB_POOL_TIMEOUT,
|
||||
"max_overflow": settings.DB_MAX_OVERFLOW
|
||||
"max_overflow": settings.CONF.dbpooloverflow
|
||||
})
|
||||
# 创建数据库引擎
|
||||
Engine = create_engine(**db_kwargs)
|
||||
@@ -221,8 +221,7 @@ class Base:
|
||||
@classmethod
|
||||
@db_query
|
||||
def list(cls, db: Session) -> List[Self]:
|
||||
result = db.query(cls).all()
|
||||
return list(result)
|
||||
return db.query(cls).all()
|
||||
|
||||
def to_dict(self):
|
||||
return {c.name: getattr(self, c.name, None) for c in self.__table__.columns} # noqa
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Sequence, JSON, or_
|
||||
from sqlalchemy import Column, Integer, String, Sequence, JSON
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_query, db_update, Base
|
||||
@@ -74,8 +74,7 @@ class DownloadHistory(Base):
|
||||
@staticmethod
|
||||
@db_query
|
||||
def list_by_page(db: Session, page: Optional[int] = 1, count: Optional[int] = 30):
|
||||
result = db.query(DownloadHistory).offset((page - 1) * count).limit(count).all()
|
||||
return list(result)
|
||||
return db.query(DownloadHistory).offset((page - 1) * count).limit(count).all()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
@@ -91,50 +90,47 @@ class DownloadHistory(Base):
|
||||
据tmdbid、season、season_episode查询下载记录
|
||||
tmdbid + mtype 或 title + year
|
||||
"""
|
||||
result = None
|
||||
# TMDBID + 类型
|
||||
if tmdbid and mtype:
|
||||
# 电视剧某季某集
|
||||
if season and episode:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.type == mtype,
|
||||
DownloadHistory.seasons == season,
|
||||
DownloadHistory.episodes == episode).order_by(
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.type == mtype,
|
||||
DownloadHistory.seasons == season,
|
||||
DownloadHistory.episodes == episode).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
# 电视剧某季
|
||||
elif season:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.type == mtype,
|
||||
DownloadHistory.seasons == season).order_by(
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.type == mtype,
|
||||
DownloadHistory.seasons == season).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
else:
|
||||
# 电视剧所有季集/电影
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.type == mtype).order_by(
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.type == mtype).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
# 标题 + 年份
|
||||
elif title and year:
|
||||
# 电视剧某季某集
|
||||
if season and episode:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.title == title,
|
||||
DownloadHistory.year == year,
|
||||
DownloadHistory.seasons == season,
|
||||
DownloadHistory.episodes == episode).order_by(
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.title == title,
|
||||
DownloadHistory.year == year,
|
||||
DownloadHistory.seasons == season,
|
||||
DownloadHistory.episodes == episode).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
# 电视剧某季
|
||||
elif season:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.title == title,
|
||||
DownloadHistory.year == year,
|
||||
DownloadHistory.seasons == season).order_by(
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.title == title,
|
||||
DownloadHistory.year == year,
|
||||
DownloadHistory.seasons == season).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
else:
|
||||
# 电视剧所有季集/电影
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.title == title,
|
||||
DownloadHistory.year == year).order_by(
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.title == title,
|
||||
DownloadHistory.year == year).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
|
||||
if result:
|
||||
return list(result)
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
@@ -144,13 +140,12 @@ class DownloadHistory(Base):
|
||||
查询某用户某时间之后的下载历史
|
||||
"""
|
||||
if username:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.date < date,
|
||||
DownloadHistory.username == username).order_by(
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.date < date,
|
||||
DownloadHistory.username == username).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
else:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.date < date).order_by(
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.date < date).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
return list(result)
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
@@ -173,12 +168,11 @@ class DownloadHistory(Base):
|
||||
@staticmethod
|
||||
@db_query
|
||||
def list_by_type(db: Session, mtype: str, days: int):
|
||||
result = db.query(DownloadHistory) \
|
||||
return db.query(DownloadHistory) \
|
||||
.filter(DownloadHistory.type == mtype,
|
||||
DownloadHistory.date >= time.strftime("%Y-%m-%d %H:%M:%S",
|
||||
time.localtime(time.time() - 86400 * int(days)))
|
||||
).all()
|
||||
return list(result)
|
||||
|
||||
|
||||
class DownloadFiles(Base):
|
||||
@@ -205,12 +199,10 @@ class DownloadFiles(Base):
|
||||
@db_query
|
||||
def get_by_hash(db: Session, download_hash: str, state: Optional[int] = None):
|
||||
if state:
|
||||
result = db.query(DownloadFiles).filter(DownloadFiles.download_hash == download_hash,
|
||||
DownloadFiles.state == state).all()
|
||||
return db.query(DownloadFiles).filter(DownloadFiles.download_hash == download_hash,
|
||||
DownloadFiles.state == state).all()
|
||||
else:
|
||||
result = db.query(DownloadFiles).filter(DownloadFiles.download_hash == download_hash).all()
|
||||
|
||||
return list(result)
|
||||
return db.query(DownloadFiles).filter(DownloadFiles.download_hash == download_hash).all()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
@@ -225,8 +217,7 @@ class DownloadFiles(Base):
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_by_savepath(db: Session, savepath: str):
|
||||
result = db.query(DownloadFiles).filter(DownloadFiles.savepath == savepath).all()
|
||||
return list(result)
|
||||
return db.query(DownloadFiles).filter(DownloadFiles.savepath == savepath).all()
|
||||
|
||||
@staticmethod
|
||||
@db_update
|
||||
|
||||
@@ -37,7 +37,4 @@ class Message(Base):
|
||||
@staticmethod
|
||||
@db_query
|
||||
def list_by_page(db: Session, page: Optional[int] = 1, count: Optional[int] = 30):
|
||||
result = db.query(Message).order_by(Message.reg_time.desc()).offset((page - 1) * count).limit(
|
||||
count).all()
|
||||
result.sort(key=lambda x: x.reg_time, reverse=False)
|
||||
return list(result)
|
||||
return db.query(Message).order_by(Message.reg_time.desc()).offset((page - 1) * count).limit(count).all()
|
||||
|
||||
@@ -16,8 +16,7 @@ class PluginData(Base):
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_plugin_data(db: Session, plugin_id: str):
|
||||
result = db.query(PluginData).filter(PluginData.plugin_id == plugin_id).all()
|
||||
return list(result)
|
||||
return db.query(PluginData).filter(PluginData.plugin_id == plugin_id).all()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
@@ -37,5 +36,4 @@ class PluginData(Base):
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_plugin_data_by_plugin_id(db: Session, plugin_id: str):
|
||||
result = db.query(PluginData).filter(PluginData.plugin_id == plugin_id).all()
|
||||
return list(result)
|
||||
return db.query(PluginData).filter(PluginData.plugin_id == plugin_id).all()
|
||||
|
||||
@@ -62,20 +62,17 @@ class Site(Base):
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_actives(db: Session):
|
||||
result = db.query(Site).filter(Site.is_active == 1).all()
|
||||
return list(result)
|
||||
return db.query(Site).filter(Site.is_active == 1).all()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def list_order_by_pri(db: Session):
|
||||
result = db.query(Site).order_by(Site.pri).all()
|
||||
return list(result)
|
||||
return db.query(Site).order_by(Site.pri).all()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_domains_by_ids(db: Session, ids: list):
|
||||
result = db.query(Site.domain).filter(Site.id.in_(ids)).all()
|
||||
return [r[0] for r in result]
|
||||
return [r[0] for r in db.query(Site.domain).filter(Site.id.in_(ids)).all()]
|
||||
|
||||
@staticmethod
|
||||
@db_update
|
||||
|
||||
@@ -104,12 +104,10 @@ class Subscribe(Base):
|
||||
def get_by_state(db: Session, state: str):
|
||||
# 如果 state 为空或 None,返回所有订阅
|
||||
if not state:
|
||||
result = db.query(Subscribe).all()
|
||||
return db.query(Subscribe).all()
|
||||
else:
|
||||
# 如果传入的状态不为空,拆分成多个状态
|
||||
states = state.split(',')
|
||||
result = db.query(Subscribe).filter(Subscribe.state.in_(states)).all()
|
||||
return list(result)
|
||||
return db.query(Subscribe).filter(Subscribe.state.in_(state.split(','))).all()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
@@ -123,11 +121,10 @@ class Subscribe(Base):
|
||||
@db_query
|
||||
def get_by_tmdbid(db: Session, tmdbid: int, season: Optional[int] = None):
|
||||
if season:
|
||||
result = db.query(Subscribe).filter(Subscribe.tmdbid == tmdbid,
|
||||
Subscribe.season == season).all()
|
||||
return db.query(Subscribe).filter(Subscribe.tmdbid == tmdbid,
|
||||
Subscribe.season == season).all()
|
||||
else:
|
||||
result = db.query(Subscribe).filter(Subscribe.tmdbid == tmdbid).all()
|
||||
return list(result)
|
||||
return db.query(Subscribe).filter(Subscribe.tmdbid == tmdbid).all()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
@@ -170,26 +167,24 @@ class Subscribe(Base):
|
||||
def list_by_username(db: Session, username: str, state: Optional[str] = None, mtype: Optional[str] = None):
|
||||
if mtype:
|
||||
if state:
|
||||
result = db.query(Subscribe).filter(Subscribe.state == state,
|
||||
Subscribe.username == username,
|
||||
Subscribe.type == mtype).all()
|
||||
return db.query(Subscribe).filter(Subscribe.state == state,
|
||||
Subscribe.username == username,
|
||||
Subscribe.type == mtype).all()
|
||||
else:
|
||||
result = db.query(Subscribe).filter(Subscribe.username == username,
|
||||
Subscribe.type == mtype).all()
|
||||
return db.query(Subscribe).filter(Subscribe.username == username,
|
||||
Subscribe.type == mtype).all()
|
||||
else:
|
||||
if state:
|
||||
result = db.query(Subscribe).filter(Subscribe.state == state,
|
||||
Subscribe.username == username).all()
|
||||
return db.query(Subscribe).filter(Subscribe.state == state,
|
||||
Subscribe.username == username).all()
|
||||
else:
|
||||
result = db.query(Subscribe).filter(Subscribe.username == username).all()
|
||||
return list(result)
|
||||
return db.query(Subscribe).filter(Subscribe.username == username).all()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def list_by_type(db: Session, mtype: str, days: int):
|
||||
result = db.query(Subscribe) \
|
||||
return db.query(Subscribe) \
|
||||
.filter(Subscribe.type == mtype,
|
||||
Subscribe.date >= time.strftime("%Y-%m-%d %H:%M:%S",
|
||||
time.localtime(time.time() - 86400 * int(days)))
|
||||
).all()
|
||||
return list(result)
|
||||
|
||||
@@ -75,12 +75,11 @@ class SubscribeHistory(Base):
|
||||
@staticmethod
|
||||
@db_query
|
||||
def list_by_type(db: Session, mtype: str, page: Optional[int] = 1, count: Optional[int] = 30):
|
||||
result = db.query(SubscribeHistory).filter(
|
||||
return db.query(SubscribeHistory).filter(
|
||||
SubscribeHistory.type == mtype
|
||||
).order_by(
|
||||
SubscribeHistory.date.desc()
|
||||
).offset((page - 1) * count).limit(count).all()
|
||||
return list(result)
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
|
||||
@@ -63,35 +63,33 @@ class TransferHistory(Base):
|
||||
@db_query
|
||||
def list_by_title(db: Session, title: str, page: Optional[int] = 1, count: Optional[int] = 30, status: bool = None):
|
||||
if status is not None:
|
||||
result = db.query(TransferHistory).filter(
|
||||
return db.query(TransferHistory).filter(
|
||||
TransferHistory.status == status
|
||||
).order_by(
|
||||
TransferHistory.date.desc()
|
||||
).offset((page - 1) * count).limit(count).all()
|
||||
else:
|
||||
result = db.query(TransferHistory).filter(or_(
|
||||
return db.query(TransferHistory).filter(or_(
|
||||
TransferHistory.title.like(f'%{title}%'),
|
||||
TransferHistory.src.like(f'%{title}%'),
|
||||
TransferHistory.dest.like(f'%{title}%'),
|
||||
)).order_by(
|
||||
TransferHistory.date.desc()
|
||||
).offset((page - 1) * count).limit(count).all()
|
||||
return list(result)
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def list_by_page(db: Session, page: Optional[int] = 1, count: Optional[int] = 30, status: bool = None):
|
||||
if status is not None:
|
||||
result = db.query(TransferHistory).filter(
|
||||
return db.query(TransferHistory).filter(
|
||||
TransferHistory.status == status
|
||||
).order_by(
|
||||
TransferHistory.date.desc()
|
||||
).offset((page - 1) * count).limit(count).all()
|
||||
else:
|
||||
result = db.query(TransferHistory).order_by(
|
||||
return db.query(TransferHistory).order_by(
|
||||
TransferHistory.date.desc()
|
||||
).offset((page - 1) * count).limit(count).all()
|
||||
return list(result)
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
@@ -115,8 +113,7 @@ class TransferHistory(Base):
|
||||
@staticmethod
|
||||
@db_query
|
||||
def list_by_hash(db: Session, download_hash: str):
|
||||
result = db.query(TransferHistory).filter(TransferHistory.download_hash == download_hash).all()
|
||||
return list(result)
|
||||
return db.query(TransferHistory).filter(TransferHistory.download_hash == download_hash).all()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
@@ -128,8 +125,7 @@ class TransferHistory(Base):
|
||||
TransferHistory.id.label('id')).filter(
|
||||
TransferHistory.date >= time.strftime("%Y-%m-%d %H:%M:%S",
|
||||
time.localtime(time.time() - 86400 * days))).subquery()
|
||||
result = db.query(sub_query.c.date, func.count(sub_query.c.id)).group_by(sub_query.c.date).all()
|
||||
return list(result)
|
||||
return db.query(sub_query.c.date, func.count(sub_query.c.id)).group_by(sub_query.c.date).all()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
@@ -153,70 +149,67 @@ class TransferHistory(Base):
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def list_by(db: Session, mtype: Optional[str] = None, title: Optional[str] = None, year: Optional[str] = None, season: Optional[str] = None,
|
||||
def list_by(db: Session, mtype: Optional[str] = None, title: Optional[str] = None, year: Optional[str] = None,
|
||||
season: Optional[str] = None,
|
||||
episode: Optional[str] = None, tmdbid: Optional[int] = None, dest: Optional[str] = None):
|
||||
"""
|
||||
据tmdbid、season、season_episode查询转移记录
|
||||
tmdbid + mtype 或 title + year 必输
|
||||
"""
|
||||
result = None
|
||||
# TMDBID + 类型
|
||||
if tmdbid and mtype:
|
||||
# 电视剧某季某集
|
||||
if season and episode:
|
||||
result = db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
|
||||
TransferHistory.type == mtype,
|
||||
TransferHistory.seasons == season,
|
||||
TransferHistory.episodes == episode,
|
||||
TransferHistory.dest == dest).all()
|
||||
return db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
|
||||
TransferHistory.type == mtype,
|
||||
TransferHistory.seasons == season,
|
||||
TransferHistory.episodes == episode,
|
||||
TransferHistory.dest == dest).all()
|
||||
# 电视剧某季
|
||||
elif season:
|
||||
result = db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
|
||||
TransferHistory.type == mtype,
|
||||
TransferHistory.seasons == season).all()
|
||||
return db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
|
||||
TransferHistory.type == mtype,
|
||||
TransferHistory.seasons == season).all()
|
||||
else:
|
||||
if dest:
|
||||
# 电影
|
||||
result = db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
|
||||
TransferHistory.type == mtype,
|
||||
TransferHistory.dest == dest).all()
|
||||
return db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
|
||||
TransferHistory.type == mtype,
|
||||
TransferHistory.dest == dest).all()
|
||||
else:
|
||||
# 电视剧所有季集
|
||||
result = db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
|
||||
TransferHistory.type == mtype).all()
|
||||
return db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
|
||||
TransferHistory.type == mtype).all()
|
||||
# 标题 + 年份
|
||||
elif title and year:
|
||||
# 电视剧某季某集
|
||||
if season and episode:
|
||||
result = db.query(TransferHistory).filter(TransferHistory.title == title,
|
||||
TransferHistory.year == year,
|
||||
TransferHistory.seasons == season,
|
||||
TransferHistory.episodes == episode,
|
||||
TransferHistory.dest == dest).all()
|
||||
return db.query(TransferHistory).filter(TransferHistory.title == title,
|
||||
TransferHistory.year == year,
|
||||
TransferHistory.seasons == season,
|
||||
TransferHistory.episodes == episode,
|
||||
TransferHistory.dest == dest).all()
|
||||
# 电视剧某季
|
||||
elif season:
|
||||
result = db.query(TransferHistory).filter(TransferHistory.title == title,
|
||||
TransferHistory.year == year,
|
||||
TransferHistory.seasons == season).all()
|
||||
return db.query(TransferHistory).filter(TransferHistory.title == title,
|
||||
TransferHistory.year == year,
|
||||
TransferHistory.seasons == season).all()
|
||||
else:
|
||||
if dest:
|
||||
# 电影
|
||||
result = db.query(TransferHistory).filter(TransferHistory.title == title,
|
||||
TransferHistory.year == year,
|
||||
TransferHistory.dest == dest).all()
|
||||
return db.query(TransferHistory).filter(TransferHistory.title == title,
|
||||
TransferHistory.year == year,
|
||||
TransferHistory.dest == dest).all()
|
||||
else:
|
||||
# 电视剧所有季集
|
||||
result = db.query(TransferHistory).filter(TransferHistory.title == title,
|
||||
TransferHistory.year == year).all()
|
||||
return db.query(TransferHistory).filter(TransferHistory.title == title,
|
||||
TransferHistory.year == year).all()
|
||||
# 类型 + 转移路径(emby webhook season无tmdbid场景)
|
||||
elif mtype and season and dest:
|
||||
# 电视剧某季
|
||||
result = db.query(TransferHistory).filter(TransferHistory.type == mtype,
|
||||
TransferHistory.seasons == season,
|
||||
TransferHistory.dest.like(f"{dest}%")).all()
|
||||
|
||||
if result:
|
||||
return list(result)
|
||||
return db.query(TransferHistory).filter(TransferHistory.type == mtype,
|
||||
TransferHistory.seasons == season,
|
||||
TransferHistory.dest.like(f"{dest}%")).all()
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -31,8 +31,8 @@ class SystemConfigOper(DbOper, metaclass=Singleton):
|
||||
key = key.value
|
||||
# 旧值
|
||||
old_value = self.__SYSTEMCONF.get(key)
|
||||
# 更新内存
|
||||
self.__SYSTEMCONF[key] = value
|
||||
# 更新内存(deepcopy避免内存共享)
|
||||
self.__SYSTEMCONF[key] = copy.deepcopy(value)
|
||||
conf = SystemConfig.get_by_key(self._db, key)
|
||||
if conf:
|
||||
if old_value != value:
|
||||
@@ -78,7 +78,3 @@ class SystemConfigOper(DbOper, metaclass=Singleton):
|
||||
if conf:
|
||||
conf.delete(self._db, conf.id)
|
||||
return True
|
||||
|
||||
def __del__(self):
|
||||
if self._db:
|
||||
self._db.close()
|
||||
|
||||
@@ -50,10 +50,6 @@ class UserConfigOper(DbOper, metaclass=Singleton):
|
||||
return self.__get_config_caches(username=username)
|
||||
return self.__get_config_cache(username=username, key=key)
|
||||
|
||||
def __del__(self):
|
||||
if self._db:
|
||||
self._db.close()
|
||||
|
||||
def __set_config_cache(self, username: str, key: str, value: Any):
|
||||
"""
|
||||
设置配置缓存
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
from .doh import doh_query_json
|
||||
from .cloudflare import under_challenge
|
||||
|
||||
@@ -46,17 +46,17 @@ class PlaywrightHelper:
|
||||
browser = playwright[self.browser_type].launch(headless=headless)
|
||||
context = browser.new_context(user_agent=ua, proxy=proxies)
|
||||
page = context.new_page()
|
||||
|
||||
|
||||
if cookies:
|
||||
page.set_extra_http_headers({"cookie": cookies})
|
||||
|
||||
|
||||
if not self.__pass_cloudflare(url, page):
|
||||
logger.warn("cloudflare challenge fail!")
|
||||
page.wait_for_load_state("networkidle", timeout=timeout * 1000)
|
||||
|
||||
|
||||
# 回调函数
|
||||
result = callback(page)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"网页操作失败: {str(e)}")
|
||||
finally:
|
||||
@@ -69,7 +69,7 @@ class PlaywrightHelper:
|
||||
browser.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Playwright初始化失败: {str(e)}")
|
||||
|
||||
|
||||
return result
|
||||
|
||||
def get_page_source(self, url: str,
|
||||
@@ -97,16 +97,16 @@ class PlaywrightHelper:
|
||||
browser = playwright[self.browser_type].launch(headless=headless)
|
||||
context = browser.new_context(user_agent=ua, proxy=proxies)
|
||||
page = context.new_page()
|
||||
|
||||
|
||||
if cookies:
|
||||
page.set_extra_http_headers({"cookie": cookies})
|
||||
|
||||
|
||||
if not self.__pass_cloudflare(url, page):
|
||||
logger.warn("cloudflare challenge fail!")
|
||||
page.wait_for_load_state("networkidle", timeout=timeout * 1000)
|
||||
|
||||
|
||||
source = page.content()
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取网页源码失败: {str(e)}")
|
||||
source = None
|
||||
@@ -120,7 +120,7 @@ class PlaywrightHelper:
|
||||
browser.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Playwright初始化失败: {str(e)}")
|
||||
|
||||
|
||||
return source
|
||||
|
||||
|
||||
|
||||
@@ -96,53 +96,58 @@ class CookieHelper:
|
||||
return None, None, "获取源码失败"
|
||||
# 查找用户名输入框
|
||||
html = etree.HTML(html_text)
|
||||
username_xpath = None
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("username"):
|
||||
if html.xpath(xpath):
|
||||
username_xpath = xpath
|
||||
break
|
||||
if not username_xpath:
|
||||
return None, None, "未找到用户名输入框"
|
||||
# 查找密码输入框
|
||||
password_xpath = None
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("password"):
|
||||
if html.xpath(xpath):
|
||||
password_xpath = xpath
|
||||
break
|
||||
if not password_xpath:
|
||||
return None, None, "未找到密码输入框"
|
||||
# 处理二步验证码
|
||||
otp_code = TwoFactorAuth(two_step_code).get_code()
|
||||
# 查找二步验证码输入框
|
||||
twostep_xpath = None
|
||||
if otp_code:
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("twostep"):
|
||||
try:
|
||||
username_xpath = None
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("username"):
|
||||
if html.xpath(xpath):
|
||||
twostep_xpath = xpath
|
||||
username_xpath = xpath
|
||||
break
|
||||
# 查找验证码输入框
|
||||
captcha_xpath = None
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("captcha"):
|
||||
if html.xpath(xpath):
|
||||
captcha_xpath = xpath
|
||||
break
|
||||
# 查找验证码图片
|
||||
captcha_img_url = None
|
||||
if captcha_xpath:
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("captcha_img"):
|
||||
if not username_xpath:
|
||||
return None, None, "未找到用户名输入框"
|
||||
# 查找密码输入框
|
||||
password_xpath = None
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("password"):
|
||||
if html.xpath(xpath):
|
||||
captcha_img_url = html.xpath(xpath)[0]
|
||||
password_xpath = xpath
|
||||
break
|
||||
if not captcha_img_url:
|
||||
return None, None, "未找到验证码图片"
|
||||
# 查找登录按钮
|
||||
submit_xpath = None
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("submit"):
|
||||
if html.xpath(xpath):
|
||||
submit_xpath = xpath
|
||||
break
|
||||
if not submit_xpath:
|
||||
return None, None, "未找到登录按钮"
|
||||
if not password_xpath:
|
||||
return None, None, "未找到密码输入框"
|
||||
# 处理二步验证码
|
||||
otp_code = TwoFactorAuth(two_step_code).get_code()
|
||||
# 查找二步验证码输入框
|
||||
twostep_xpath = None
|
||||
if otp_code:
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("twostep"):
|
||||
if html.xpath(xpath):
|
||||
twostep_xpath = xpath
|
||||
break
|
||||
# 查找验证码输入框
|
||||
captcha_xpath = None
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("captcha"):
|
||||
if html.xpath(xpath):
|
||||
captcha_xpath = xpath
|
||||
break
|
||||
# 查找验证码图片
|
||||
captcha_img_url = None
|
||||
if captcha_xpath:
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("captcha_img"):
|
||||
if html.xpath(xpath):
|
||||
captcha_img_url = html.xpath(xpath)[0]
|
||||
break
|
||||
if not captcha_img_url:
|
||||
return None, None, "未找到验证码图片"
|
||||
# 查找登录按钮
|
||||
submit_xpath = None
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("submit"):
|
||||
if html.xpath(xpath):
|
||||
submit_xpath = xpath
|
||||
break
|
||||
if not submit_xpath:
|
||||
return None, None, "未找到登录按钮"
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
# 点击登录按钮
|
||||
try:
|
||||
# 等待登录按钮准备好
|
||||
@@ -185,19 +190,23 @@ class CookieHelper:
|
||||
if not otp_code:
|
||||
return None, None, "需要二次验证码"
|
||||
html = etree.HTML(page.content())
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("twostep"):
|
||||
if html.xpath(xpath):
|
||||
try:
|
||||
# 刷新一下 2fa code
|
||||
otp_code = TwoFactorAuth(two_step_code).get_code()
|
||||
page.fill(xpath, otp_code)
|
||||
# 登录按钮 xpath 理论上相同,不再重复查找
|
||||
page.click(submit_xpath)
|
||||
page.wait_for_load_state("networkidle", timeout=30 * 1000)
|
||||
except Exception as e:
|
||||
logger.error(f"二次验证码输入失败:{str(e)}")
|
||||
return None, None, f"二次验证码输入失败:{str(e)}"
|
||||
break
|
||||
try:
|
||||
for xpath in self._SITE_LOGIN_XPATH.get("twostep"):
|
||||
if html.xpath(xpath):
|
||||
try:
|
||||
# 刷新一下 2fa code
|
||||
otp_code = TwoFactorAuth(two_step_code).get_code()
|
||||
page.fill(xpath, otp_code)
|
||||
# 登录按钮 xpath 理论上相同,不再重复查找
|
||||
page.click(submit_xpath)
|
||||
page.wait_for_load_state("networkidle", timeout=30 * 1000)
|
||||
except Exception as e:
|
||||
logger.error(f"二次验证码输入失败:{str(e)}")
|
||||
return None, None, f"二次验证码输入失败:{str(e)}"
|
||||
break
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
# 登录后的源码
|
||||
html_text = page.content()
|
||||
if not html_text:
|
||||
|
||||
@@ -10,10 +10,15 @@ import socket
|
||||
import struct
|
||||
import urllib
|
||||
import urllib.request
|
||||
from threading import Lock
|
||||
from typing import Dict, Optional
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.event import Event, eventmanager
|
||||
from app.log import logger
|
||||
from app.schemas import ConfigChangeEventData
|
||||
from app.schemas.types import EventType
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
# 定义一个全局线程池执行器
|
||||
_executor = concurrent.futures.ThreadPoolExecutor()
|
||||
@@ -21,11 +26,15 @@ _executor = concurrent.futures.ThreadPoolExecutor()
|
||||
# 定义默认的DoH配置
|
||||
_doh_timeout = 5
|
||||
_doh_cache: Dict[str, str] = {}
|
||||
_doh_lock = Lock()
|
||||
# 保存原始的 socket.getaddrinfo 方法
|
||||
_orig_getaddrinfo = socket.getaddrinfo
|
||||
|
||||
# 对 socket.getaddrinfo 进行补丁
|
||||
if settings.DOH_ENABLE:
|
||||
# 保存原始的 socket.getaddrinfo 方法
|
||||
_orig_getaddrinfo = socket.getaddrinfo
|
||||
|
||||
def enable_doh(enable: bool):
|
||||
"""
|
||||
对 socket.getaddrinfo 进行补丁
|
||||
"""
|
||||
|
||||
def _patched_getaddrinfo(host, *args, **kwargs):
|
||||
"""
|
||||
@@ -34,8 +43,9 @@ if settings.DOH_ENABLE:
|
||||
if host not in settings.DOH_DOMAINS.split(","):
|
||||
return _orig_getaddrinfo(host, *args, **kwargs)
|
||||
# 检查主机是否已解析
|
||||
if host in _doh_cache:
|
||||
ip = _doh_cache[host]
|
||||
with _doh_lock:
|
||||
ip = _doh_cache.get("host", None)
|
||||
if ip is not None:
|
||||
logger.info("已解析 [%s] 为 [%s] (缓存)", host, ip)
|
||||
return _orig_getaddrinfo(ip, *args, **kwargs)
|
||||
# 使用DoH解析主机
|
||||
@@ -46,13 +56,34 @@ if settings.DOH_ENABLE:
|
||||
ip = future.result()
|
||||
if ip is not None:
|
||||
logger.info("已解析 [%s] 为 [%s]", host, ip)
|
||||
_doh_cache[host] = ip
|
||||
with _doh_lock:
|
||||
_doh_cache[host] = ip
|
||||
host = ip
|
||||
break
|
||||
return _orig_getaddrinfo(host, *args, **kwargs)
|
||||
|
||||
# 替换 socket.getaddrinfo 方法
|
||||
socket.getaddrinfo = _patched_getaddrinfo
|
||||
if enable:
|
||||
# 替换 socket.getaddrinfo 方法
|
||||
socket.getaddrinfo = _patched_getaddrinfo
|
||||
else:
|
||||
socket.getaddrinfo = _orig_getaddrinfo
|
||||
|
||||
|
||||
class DohHelper(metaclass=Singleton):
|
||||
def __init__(self):
|
||||
enable_doh(settings.DOH_ENABLE)
|
||||
|
||||
@eventmanager.register(EventType.ConfigChanged)
|
||||
def handle_config_changed(self, event: Event):
|
||||
if not event:
|
||||
return
|
||||
event_data: ConfigChangeEventData = event.event_data
|
||||
if event_data.key not in ["DOH_ENABLE", "DOH_DOMAINS", "DOH_RESOLVERS"]:
|
||||
return
|
||||
with _doh_lock:
|
||||
# DOH配置有变动的情况下,清空缓存
|
||||
_doh_cache.clear()
|
||||
enable_doh(settings.DOH_ENABLE)
|
||||
|
||||
|
||||
def _doh_query(resolver: str, host: str) -> Optional[str]:
|
||||
|
||||
@@ -361,7 +361,7 @@ class MemoryHelper(metaclass=Singleton):
|
||||
|
||||
# 对于较大的对象,使用 asizeof 进行深度计算
|
||||
size_bytes = asizeof.asizeof(obj)
|
||||
|
||||
|
||||
# 只处理大于10KB的对象,提高分析效率
|
||||
if size_bytes < 10240:
|
||||
continue
|
||||
|
||||
@@ -183,6 +183,8 @@ class TemplateContextBuilder:
|
||||
"videoCodec": meta.video_encode,
|
||||
# 音频编码
|
||||
"audioCodec": meta.audio_encode,
|
||||
# 流媒体平台
|
||||
"webSource": meta.web_source,
|
||||
}
|
||||
self._context.update({**meta_info, **tech_metadata, **episode_data})
|
||||
|
||||
@@ -241,7 +243,7 @@ class TemplateContextBuilder:
|
||||
"total_size": StringUtils.str_filesize(transferinfo.total_size),
|
||||
"err_msg": transferinfo.message,
|
||||
}
|
||||
self._context.update(ctx)
|
||||
return self._context.update(ctx)
|
||||
|
||||
def _add_file_info(self, file_extension: Optional[str]):
|
||||
"""
|
||||
@@ -363,7 +365,7 @@ class TemplateHelper(metaclass=SingletonClass):
|
||||
self.set_cache_context(rendered, context)
|
||||
# 返回渲染结果
|
||||
return rendered
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"模板处理失败: {str(e)}")
|
||||
raise ValueError(f"模板处理失败: {str(e)}") from e
|
||||
@@ -645,7 +647,8 @@ class MessageQueueManager(metaclass=SingletonClass):
|
||||
"""
|
||||
发送消息(立即发送或加入队列)
|
||||
"""
|
||||
if self._is_in_scheduled_time(datetime.now()):
|
||||
immediately = kwargs.pop("immediately", False)
|
||||
if immediately or self._is_in_scheduled_time(datetime.now()):
|
||||
self._send(*args, **kwargs)
|
||||
else:
|
||||
self.queue.put({
|
||||
|
||||
@@ -9,7 +9,7 @@ class OcrHelper:
|
||||
|
||||
_ocr_b64_url = f"{settings.OCR_HOST}/captcha/base64"
|
||||
|
||||
def get_captcha_text(self, image_url: Optional[str] = None, image_b64: Optional[str] = None,
|
||||
def get_captcha_text(self, image_url: Optional[str] = None, image_b64: Optional[str] = None,
|
||||
cookie: Optional[str] = None, ua: Optional[str] = None):
|
||||
"""
|
||||
根据图片地址,获取验证码图片,并识别内容
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import sys
|
||||
import importlib
|
||||
import json
|
||||
import shutil
|
||||
import traceback
|
||||
import site
|
||||
import importlib
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple, Set
|
||||
from typing import Dict, List, Optional, Tuple, Set
|
||||
|
||||
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||
from packaging.version import Version, InvalidVersion
|
||||
from pkg_resources import Requirement, working_set
|
||||
from requests import Response
|
||||
|
||||
from app.core.cache import cached
|
||||
from app.core.config import settings
|
||||
@@ -52,10 +53,10 @@ class PluginHelper(metaclass=Singleton):
|
||||
# 如果强制刷新,直接调用不带缓存的版本
|
||||
if force:
|
||||
return self._get_plugins_uncached(repo_url, package_version)
|
||||
|
||||
|
||||
# 正常情况下调用带缓存的版本
|
||||
return self._get_plugins_cached(repo_url, package_version)
|
||||
|
||||
|
||||
@cached(maxsize=64, ttl=1800)
|
||||
def _get_plugins_cached(self, repo_url: str, package_version: Optional[str] = None) -> Optional[Dict[str, dict]]:
|
||||
"""
|
||||
@@ -64,7 +65,7 @@ class PluginHelper(metaclass=Singleton):
|
||||
:param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本
|
||||
"""
|
||||
return self._get_plugins_uncached(repo_url, package_version)
|
||||
|
||||
|
||||
def _get_plugins_uncached(self, repo_url: str, package_version: Optional[str] = None) -> Optional[Dict[str, dict]]:
|
||||
"""
|
||||
获取Github所有最新插件列表(不使用缓存)
|
||||
@@ -85,11 +86,13 @@ class PluginHelper(metaclass=Singleton):
|
||||
if res is None:
|
||||
return None
|
||||
if res:
|
||||
content = res.text
|
||||
try:
|
||||
return json.loads(res.text)
|
||||
return json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"插件包数据解析失败:{res.text}")
|
||||
return None
|
||||
if "404: Not Found" not in content:
|
||||
logger.warn(f"插件包数据解析失败:{content}")
|
||||
return None
|
||||
return {}
|
||||
|
||||
def get_plugin_package_version(self, pid: str, repo_url: str, package_version: Optional[str] = None) -> Optional[str]:
|
||||
@@ -109,14 +112,11 @@ class PluginHelper(metaclass=Singleton):
|
||||
package_version = settings.VERSION_FLAG
|
||||
|
||||
# 优先检查指定版本的插件,即 package.v(x).json 文件中是否存在该插件,如果存在,返回该版本号
|
||||
plugins = self.get_plugins(repo_url, package_version)
|
||||
if pid in plugins:
|
||||
if pid in (self.get_plugins(repo_url, package_version) or []):
|
||||
return package_version
|
||||
|
||||
# 如果指定版本的插件不存在,检查全局 package.json 文件,查看插件是否兼容指定的版本
|
||||
global_plugins = self.get_plugins(repo_url)
|
||||
plugin = global_plugins.get(pid, None)
|
||||
|
||||
plugin = (self.get_plugins(repo_url) or {}).get(pid, None)
|
||||
# 检查插件是否明确支持当前指定的版本(如 v2 或 v3),如果支持,返回空字符串表示使用 package.json(v1)
|
||||
if plugin and plugin.get(package_version) is True:
|
||||
return ""
|
||||
@@ -308,7 +308,7 @@ class PluginHelper(metaclass=Singleton):
|
||||
return None, "连接仓库失败"
|
||||
elif res.status_code != 200:
|
||||
return None, f"连接仓库失败:{res.status_code} - " \
|
||||
f"{'超出速率限制,请配置GITHUB_TOKEN环境变量或稍后重试' if res.status_code == 403 else res.reason}"
|
||||
f"{'超出速率限制,请设置Github Token或稍后重试' if res.status_code == 403 else res.reason}"
|
||||
|
||||
try:
|
||||
ret = res.json()
|
||||
@@ -317,7 +317,7 @@ class PluginHelper(metaclass=Singleton):
|
||||
else:
|
||||
return None, "插件在仓库中不存在或返回数据格式不正确"
|
||||
except Exception as e:
|
||||
logger.error(f"插件数据解析失败:{res.text},{e}")
|
||||
logger.error(f"插件数据解析失败:{e}")
|
||||
return None, "插件数据解析失败"
|
||||
|
||||
def __download_files(self, pid: str, file_list: List[dict], user_repo: str,
|
||||
@@ -399,8 +399,7 @@ class PluginHelper(metaclass=Singleton):
|
||||
with open(requirements_file_path, "w", encoding="utf-8") as f:
|
||||
f.write(requirements_txt)
|
||||
|
||||
success, message = self.__pip_install_with_fallback(requirements_file_path)
|
||||
return success, message
|
||||
return self.pip_install_with_fallback(requirements_file_path)
|
||||
|
||||
return True, "" # 如果 requirements.txt 为空,视作成功
|
||||
|
||||
@@ -417,7 +416,7 @@ class PluginHelper(metaclass=Singleton):
|
||||
# 检查是否存在 requirements.txt 文件
|
||||
if requirements_file.exists():
|
||||
logger.info(f"{pid} 存在依赖,开始尝试安装依赖")
|
||||
success, error_message = self.__pip_install_with_fallback(requirements_file)
|
||||
success, error_message = self.pip_install_with_fallback(requirements_file)
|
||||
if success:
|
||||
return True, True, ""
|
||||
else:
|
||||
@@ -475,7 +474,7 @@ class PluginHelper(metaclass=Singleton):
|
||||
shutil.rmtree(plugin_dir, ignore_errors=True)
|
||||
|
||||
@staticmethod
|
||||
def __pip_install_with_fallback(requirements_file: Path) -> Tuple[bool, str]:
|
||||
def pip_install_with_fallback(requirements_file: Path) -> Tuple[bool, str]:
|
||||
"""
|
||||
使用自动降级策略安装依赖,并确保新安装的包可被动态导入
|
||||
:param requirements_file: 依赖的 requirements.txt 文件路径
|
||||
@@ -520,7 +519,7 @@ class PluginHelper(metaclass=Singleton):
|
||||
def __request_with_fallback(url: str,
|
||||
headers: Optional[dict] = None,
|
||||
timeout: Optional[int] = 60,
|
||||
is_api: bool = False) -> Optional[Any]:
|
||||
is_api: bool = False) -> Optional[Response]:
|
||||
"""
|
||||
使用自动降级策略,请求资源,优先级依次为镜像站、代理、直连
|
||||
:param url: 目标URL
|
||||
@@ -596,7 +595,6 @@ class PluginHelper(metaclass=Singleton):
|
||||
def install_dependencies(self, dependencies: List[str]) -> Tuple[bool, str]:
|
||||
"""
|
||||
安装指定的依赖项列表
|
||||
|
||||
:param dependencies: 需要安装或更新的依赖项列表
|
||||
:return: (success, message)
|
||||
"""
|
||||
@@ -611,12 +609,12 @@ class PluginHelper(metaclass=Singleton):
|
||||
with open(requirements_temp_file, "w", encoding="utf-8") as f:
|
||||
for dep in dependencies:
|
||||
f.write(dep + "\n")
|
||||
|
||||
# 使用自动降级策略安装依赖
|
||||
success, message = self.__pip_install_with_fallback(requirements_temp_file)
|
||||
# 删除临时文件
|
||||
requirements_temp_file.unlink()
|
||||
return success, message
|
||||
try:
|
||||
# 使用自动降级策略安装依赖
|
||||
return self.pip_install_with_fallback(requirements_temp_file)
|
||||
finally:
|
||||
# 删除临时文件
|
||||
requirements_temp_file.unlink()
|
||||
except Exception as e:
|
||||
logger.error(f"安装依赖项时发生错误:{e}")
|
||||
return False, f"安装依赖项时发生错误:{e}"
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.log import logger
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.string import StringUtils
|
||||
from app.utils.system import SystemUtils
|
||||
from version import APP_VERSION
|
||||
|
||||
|
||||
class ResourceHelper:
|
||||
@@ -15,8 +16,8 @@ class ResourceHelper:
|
||||
检测和更新资源包
|
||||
"""
|
||||
# 资源包的git仓库地址
|
||||
_repo = f"{settings.GITHUB_PROXY}https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/package.json"
|
||||
_files_api = f"https://api.github.com/repos/jxxghp/MoviePilot-Resources/contents/resources"
|
||||
_repo = f"{settings.GITHUB_PROXY}https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/package.v2.json"
|
||||
_files_api = f"https://api.github.com/repos/jxxghp/MoviePilot-Resources/contents/resources.v2"
|
||||
_base_dir: Path = settings.ROOT_PATH
|
||||
|
||||
def __init__(self):
|
||||
@@ -58,6 +59,12 @@ class ResourceHelper:
|
||||
if rtype == "auth":
|
||||
# 站点认证资源
|
||||
local_version = SitesHelper().auth_version
|
||||
# 阻断站点认证资源v2.3.0以下的版本直接更新,避免无限重启
|
||||
if StringUtils.compare_version(local_version, "<", "2.3.0"):
|
||||
continue
|
||||
# 阻断主程序版本v2.6.3以下的版本直接更新,避免搜索异常
|
||||
if StringUtils.compare_version(APP_VERSION, "<", "2.6.3"):
|
||||
continue
|
||||
elif rtype == "sites":
|
||||
# 站点索引资源
|
||||
local_version = SitesHelper().indexer_version
|
||||
@@ -78,6 +85,8 @@ class ResourceHelper:
|
||||
elif not r:
|
||||
return None, "连接仓库失败"
|
||||
files_info = r.json()
|
||||
# 下载资源文件
|
||||
success = True
|
||||
for item in files_info:
|
||||
save_path = need_updates.get(item.get("name"))
|
||||
if not save_path:
|
||||
@@ -90,16 +99,23 @@ class ResourceHelper:
|
||||
timeout=180).get_res(download_url)
|
||||
if not res:
|
||||
logger.error(f"文件 {item.get('name')} 下载失败!")
|
||||
success = False
|
||||
break
|
||||
elif res.status_code != 200:
|
||||
logger.error(f"下载文件 {item.get('name')} 失败:{res.status_code} - {res.reason}")
|
||||
success = False
|
||||
break
|
||||
# 创建插件文件夹
|
||||
file_path = self._base_dir / save_path / item.get("name")
|
||||
if not file_path.parent.exists():
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# 写入文件
|
||||
file_path.write_bytes(res.content)
|
||||
logger.info("资源包更新完成,开始重启服务...")
|
||||
SystemHelper.restart()
|
||||
if success:
|
||||
logger.info("资源包更新完成,开始重启服务...")
|
||||
SystemHelper.restart()
|
||||
else:
|
||||
logger.warn("资源包更新失败,跳过升级!")
|
||||
else:
|
||||
logger.info("所有资源已最新,无需更新")
|
||||
except json.JSONDecodeError:
|
||||
|
||||
@@ -246,12 +246,17 @@ class RssHelper:
|
||||
ret = RequestUtils(proxies=settings.PROXY if proxy else None,
|
||||
timeout=timeout, headers=headers).get_res(url)
|
||||
if not ret:
|
||||
logger.error(f"获取RSS失败:请求返回空值,URL: {url}")
|
||||
return False
|
||||
except Exception as err:
|
||||
logger.error(f"获取RSS失败:{str(err)} - {traceback.format_exc()}")
|
||||
return False
|
||||
|
||||
if ret:
|
||||
# 检查HTTP状态码
|
||||
if ret.status_code != 200:
|
||||
logger.error(f"RSS请求失败,状态码: {ret.status_code}, URL: {url}")
|
||||
return False
|
||||
ret_xml = None
|
||||
root = None
|
||||
try:
|
||||
@@ -280,6 +285,17 @@ class RssHelper:
|
||||
if not ret_xml:
|
||||
ret_xml = ret.text
|
||||
|
||||
# 验证RSS内容是否有效
|
||||
if not ret_xml or not ret_xml.strip():
|
||||
logger.error("RSS内容为空")
|
||||
return False
|
||||
|
||||
# 检查是否包含基本的RSS/XML结构
|
||||
ret_xml_stripped = ret_xml.strip()
|
||||
if not ret_xml_stripped.startswith('<'):
|
||||
logger.error("RSS内容不是有效的XML格式")
|
||||
return False
|
||||
|
||||
# 使用lxml.etree解析XML
|
||||
parser = None
|
||||
try:
|
||||
@@ -292,7 +308,8 @@ class RssHelper:
|
||||
huge_tree=False # 禁用大文档解析,避免内存问题
|
||||
)
|
||||
root = etree.fromstring(ret_xml.encode('utf-8'), parser=parser)
|
||||
except etree.XMLSyntaxError:
|
||||
except etree.XMLSyntaxError as xml_error:
|
||||
logger.debug(f"XML解析失败:{str(xml_error)},尝试HTML解析")
|
||||
# 如果XML解析失败,尝试作为HTML解析
|
||||
try:
|
||||
root = etree.HTML(ret_xml)
|
||||
@@ -304,8 +321,15 @@ class RssHelper:
|
||||
except Exception as e:
|
||||
logger.error(f"HTML解析也失败:{str(e)}")
|
||||
return False
|
||||
except Exception as general_error:
|
||||
logger.error(f"解析RSS时发生未预期错误:{str(general_error)}")
|
||||
return False
|
||||
finally:
|
||||
if parser is not None:
|
||||
try:
|
||||
parser.close()
|
||||
except Exception as close_error:
|
||||
logger.debug(f"关闭解析器时出错:{str(close_error)}")
|
||||
del parser
|
||||
|
||||
if root is None:
|
||||
@@ -319,70 +343,72 @@ class RssHelper:
|
||||
items_count = min(len(items), self.MAX_RSS_ITEMS)
|
||||
if len(items) > self.MAX_RSS_ITEMS:
|
||||
logger.warning(f"RSS条目过多: {len(items)},仅处理前{self.MAX_RSS_ITEMS}个")
|
||||
try:
|
||||
for item in items[:items_count]:
|
||||
try:
|
||||
# 使用xpath提取信息,更高效
|
||||
title_nodes = item.xpath('.//title')
|
||||
title = title_nodes[0].text if title_nodes and title_nodes[0].text else ""
|
||||
if not title:
|
||||
continue
|
||||
|
||||
for item in items[:items_count]:
|
||||
try:
|
||||
# 使用xpath提取信息,更高效
|
||||
title_nodes = item.xpath('.//title')
|
||||
title = title_nodes[0].text if title_nodes and title_nodes[0].text else ""
|
||||
if not title:
|
||||
# 描述
|
||||
desc_nodes = item.xpath('.//description | .//summary')
|
||||
description = desc_nodes[0].text if desc_nodes and desc_nodes[0].text else ""
|
||||
|
||||
# 种子页面
|
||||
link_nodes = item.xpath('.//link')
|
||||
if link_nodes:
|
||||
link = link_nodes[0].text if hasattr(link_nodes[0], 'text') and link_nodes[0].text else link_nodes[0].get('href', '')
|
||||
else:
|
||||
link = ""
|
||||
|
||||
# 种子链接
|
||||
enclosure_nodes = item.xpath('.//enclosure')
|
||||
enclosure = enclosure_nodes[0].get('url', '') if enclosure_nodes else ""
|
||||
if not enclosure and not link:
|
||||
continue
|
||||
# 部分RSS只有link没有enclosure
|
||||
if not enclosure and link:
|
||||
enclosure = link
|
||||
|
||||
# 大小
|
||||
size = 0
|
||||
if enclosure_nodes:
|
||||
size_attr = enclosure_nodes[0].get('length', '0')
|
||||
if size_attr and str(size_attr).isdigit():
|
||||
size = int(size_attr)
|
||||
|
||||
# 发布日期
|
||||
pubdate_nodes = item.xpath('.//pubDate | .//published | .//updated')
|
||||
pubdate = ""
|
||||
if pubdate_nodes and pubdate_nodes[0].text:
|
||||
pubdate = StringUtils.get_time(pubdate_nodes[0].text)
|
||||
|
||||
# 获取豆瓣昵称
|
||||
nickname_nodes = item.xpath('.//*[local-name()="creator"]')
|
||||
nickname = nickname_nodes[0].text if nickname_nodes and nickname_nodes[0].text else ""
|
||||
|
||||
# 返回对象
|
||||
tmp_dict = {
|
||||
'title': title,
|
||||
'enclosure': enclosure,
|
||||
'size': size,
|
||||
'description': description,
|
||||
'link': link,
|
||||
'pubdate': pubdate
|
||||
}
|
||||
# 如果豆瓣昵称不为空,返回数据增加豆瓣昵称,供doubansync插件获取
|
||||
if nickname:
|
||||
tmp_dict['nickname'] = nickname
|
||||
ret_array.append(tmp_dict)
|
||||
|
||||
except Exception as e1:
|
||||
logger.debug(f"解析RSS条目失败:{str(e1)} - {traceback.format_exc()}")
|
||||
continue
|
||||
|
||||
# 描述
|
||||
desc_nodes = item.xpath('.//description | .//summary')
|
||||
description = desc_nodes[0].text if desc_nodes and desc_nodes[0].text else ""
|
||||
|
||||
# 种子页面
|
||||
link_nodes = item.xpath('.//link')
|
||||
if link_nodes:
|
||||
link = link_nodes[0].text if hasattr(link_nodes[0], 'text') and link_nodes[0].text else \
|
||||
link_nodes[0].get('href', '')
|
||||
else:
|
||||
link = ""
|
||||
|
||||
# 种子链接
|
||||
enclosure_nodes = item.xpath('.//enclosure')
|
||||
enclosure = enclosure_nodes[0].get('url', '') if enclosure_nodes else ""
|
||||
if not enclosure and not link:
|
||||
continue
|
||||
# 部分RSS只有link没有enclosure
|
||||
if not enclosure and link:
|
||||
enclosure = link
|
||||
|
||||
# 大小
|
||||
size = 0
|
||||
if enclosure_nodes:
|
||||
size_attr = enclosure_nodes[0].get('length', '0')
|
||||
if size_attr and str(size_attr).isdigit():
|
||||
size = int(size_attr)
|
||||
|
||||
# 发布日期
|
||||
pubdate_nodes = item.xpath('.//pubDate | .//published | .//updated')
|
||||
pubdate = ""
|
||||
if pubdate_nodes and pubdate_nodes[0].text:
|
||||
pubdate = StringUtils.get_time(pubdate_nodes[0].text)
|
||||
|
||||
# 获取豆瓣昵称
|
||||
nickname_nodes = item.xpath('.//*[local-name()="creator"]')
|
||||
nickname = nickname_nodes[0].text if nickname_nodes and nickname_nodes[0].text else ""
|
||||
|
||||
# 返回对象
|
||||
tmp_dict = {
|
||||
'title': title,
|
||||
'enclosure': enclosure,
|
||||
'size': size,
|
||||
'description': description,
|
||||
'link': link,
|
||||
'pubdate': pubdate
|
||||
}
|
||||
# 如果豆瓣昵称不为空,返回数据增加豆瓣昵称,供doubansync插件获取
|
||||
if nickname:
|
||||
tmp_dict['nickname'] = nickname
|
||||
ret_array.append(tmp_dict)
|
||||
|
||||
except Exception as e1:
|
||||
logger.debug(f"解析RSS条目失败:{str(e1)} - {traceback.format_exc()}")
|
||||
continue
|
||||
finally:
|
||||
items.clear()
|
||||
del items
|
||||
|
||||
except Exception as e2:
|
||||
logger.error(f"解析RSS失败:{str(e2)} - {traceback.format_exc()}")
|
||||
|
||||
@@ -107,8 +107,7 @@ class ServiceBaseHelper(Generic[TConf]):
|
||||
迭代所有模块的实例及其对应的配置,返回 ServiceInfo 实例
|
||||
"""
|
||||
configs = self.get_configs()
|
||||
modules = self.modulemanager.get_running_type_modules(self.module_type)
|
||||
for module in modules:
|
||||
for module in self.modulemanager.get_running_type_modules(self.module_type):
|
||||
if not module:
|
||||
continue
|
||||
module_instances = module.get_instances()
|
||||
|
||||
@@ -58,7 +58,7 @@ class SubscribeHelper(metaclass=Singleton):
|
||||
self.get_user_uuid()
|
||||
self.get_github_user()
|
||||
|
||||
@cached(maxsize=20, ttl=1800)
|
||||
@cached(maxsize=5, ttl=1800)
|
||||
def get_statistic(self, stype: str, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""
|
||||
获取订阅统计数据
|
||||
|
||||
@@ -32,6 +32,7 @@ class SystemHelper:
|
||||
if event_data.key not in ['DEBUG', 'LOG_LEVEL', 'LOG_MAX_FILE_SIZE', 'LOG_BACKUP_COUNT',
|
||||
'LOG_FILE_FORMAT', 'LOG_CONSOLE_FORMAT']:
|
||||
return
|
||||
logger.info("配置变更,更新日志设置...")
|
||||
logger.update_loggers()
|
||||
|
||||
@staticmethod
|
||||
@@ -90,10 +91,10 @@ class SystemHelper:
|
||||
# 检查是否有有效的重启策略
|
||||
auto_restart_policies = ['always', 'unless-stopped', 'on-failure']
|
||||
has_restart_policy = policy_name in auto_restart_policies
|
||||
|
||||
|
||||
logger.info(f"容器重启策略: {policy_name}, 支持自动重启: {has_restart_policy}")
|
||||
return has_restart_policy
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"检查重启策略失败: {str(e)}")
|
||||
return False
|
||||
@@ -105,11 +106,10 @@ class SystemHelper:
|
||||
"""
|
||||
if not SystemUtils.is_docker():
|
||||
return False, "非Docker环境,无法重启!"
|
||||
|
||||
|
||||
try:
|
||||
# 检查容器是否配置了自动重启策略
|
||||
has_restart_policy = SystemHelper._check_restart_policy()
|
||||
|
||||
if has_restart_policy:
|
||||
# 有重启策略,使用优雅退出方式
|
||||
logger.info("检测到容器配置了自动重启策略,使用优雅重启方式...")
|
||||
@@ -120,7 +120,6 @@ class SystemHelper:
|
||||
# 没有重启策略,使用Docker API强制重启
|
||||
logger.info("容器未配置自动重启策略,使用Docker API重启...")
|
||||
return SystemHelper._docker_api_restart()
|
||||
|
||||
except Exception as err:
|
||||
logger.error(f"重启失败: {str(err)}")
|
||||
# 降级为Docker API重启
|
||||
@@ -141,7 +140,6 @@ class SystemHelper:
|
||||
# 重启容器
|
||||
client.containers.get(container_id).restart()
|
||||
return True, ""
|
||||
|
||||
except Exception as docker_err:
|
||||
return False, f"重启时发生错误:{str(docker_err)}"
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Optional
|
||||
|
||||
from app.utils.singleton import Singleton
|
||||
from app.core.config import settings
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class ThreadHelper(metaclass=Singleton):
|
||||
@@ -10,7 +9,7 @@ class ThreadHelper(metaclass=Singleton):
|
||||
线程池管理
|
||||
"""
|
||||
def __init__(self):
|
||||
self.pool = ThreadPoolExecutor(max_workers=settings.CONF['threadpool'])
|
||||
self.pool = ThreadPoolExecutor(max_workers=settings.CONF.threadpool)
|
||||
|
||||
def submit(self, func, *args, **kwargs):
|
||||
"""
|
||||
@@ -28,6 +27,3 @@ class ThreadHelper(metaclass=Singleton):
|
||||
:return:
|
||||
"""
|
||||
self.pool.shutdown()
|
||||
|
||||
def __del__(self):
|
||||
self.shutdown()
|
||||
|
||||
@@ -18,14 +18,14 @@ class WallpaperHelper(metaclass=Singleton):
|
||||
获取登录页面壁纸
|
||||
"""
|
||||
if settings.WALLPAPER == "bing":
|
||||
url = self.get_bing_wallpaper()
|
||||
return self.get_bing_wallpaper()
|
||||
elif settings.WALLPAPER == "mediaserver":
|
||||
url = self.get_mediaserver_wallpaper()
|
||||
return self.get_mediaserver_wallpaper()
|
||||
elif settings.WALLPAPER == "customize":
|
||||
url = self.get_customize_wallpaper()
|
||||
else:
|
||||
url = self.get_tmdb_wallpaper()
|
||||
return url
|
||||
return self.get_customize_wallpaper()
|
||||
elif settings.WALLPAPER == "tmdb":
|
||||
return self.get_tmdb_wallpaper()
|
||||
return ''
|
||||
|
||||
def get_wallpapers(self, num: int = 10) -> List[str]:
|
||||
"""
|
||||
@@ -37,8 +37,9 @@ class WallpaperHelper(metaclass=Singleton):
|
||||
return self.get_mediaserver_wallpapers(num)
|
||||
elif settings.WALLPAPER == "customize":
|
||||
return self.get_customize_wallpapers()
|
||||
else:
|
||||
elif settings.WALLPAPER == "tmdb":
|
||||
return self.get_tmdb_wallpapers(num)
|
||||
return []
|
||||
|
||||
@cached(maxsize=1, ttl=3600)
|
||||
def get_tmdb_wallpaper(self) -> Optional[str]:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import multiprocessing
|
||||
import os
|
||||
import setproctitle
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
@@ -19,6 +20,9 @@ if SystemUtils.is_frozen():
|
||||
from app.core.config import settings
|
||||
from app.db.init import init_db, update_db
|
||||
|
||||
# 设置进程名
|
||||
setproctitle.setproctitle(settings.PROJECT_NAME)
|
||||
|
||||
# uvicorn服务
|
||||
Server = uvicorn.Server(Config(app, host=settings.HOST, port=settings.PORT,
|
||||
reload=settings.DEV, workers=multiprocessing.cpu_count(),
|
||||
@@ -83,7 +87,7 @@ if __name__ == '__main__':
|
||||
# 注册信号处理器
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
|
||||
|
||||
# 启动托盘
|
||||
start_tray()
|
||||
# 初始化数据库
|
||||
|
||||
@@ -112,7 +112,7 @@ class ServiceBase(Generic[TService, TConf], metaclass=ABCMeta):
|
||||
# 通过服务类型或工厂函数来创建实例
|
||||
if isinstance(service_type, type):
|
||||
# 如果传入的是类类型,调用构造函数实例化
|
||||
self._instances[conf.name] = service_type(**conf.config)
|
||||
self._instances[conf.name] = service_type(name=conf.name, **conf.config)
|
||||
else:
|
||||
# 如果传入的是工厂函数,直接调用工厂函数
|
||||
self._instances[conf.name] = service_type(conf)
|
||||
@@ -210,8 +210,8 @@ class _MessageBase(ServiceBase[TService, NotificationConf]):
|
||||
# 检查消息来源
|
||||
if message.source and message.source != source:
|
||||
return False
|
||||
# 检查消息类型开关
|
||||
if message.mtype:
|
||||
# 不是定向发送时,检查消息类型开关
|
||||
if not message.userid and message.mtype:
|
||||
conf = self.get_config(source)
|
||||
if conf:
|
||||
switchs = conf.switchs or []
|
||||
|
||||
@@ -51,7 +51,7 @@ class BangumiModule(_ModuleBase):
|
||||
获取模块子类型
|
||||
"""
|
||||
return MediaRecognizeType.Bangumi
|
||||
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""
|
||||
|
||||
@@ -30,7 +30,7 @@ class BangumiApi(object):
|
||||
self._session = requests.Session()
|
||||
self._req = RequestUtils(session=self._session)
|
||||
|
||||
@cached(maxsize=settings.CONF["bangumi"], ttl=settings.CONF["meta"])
|
||||
@cached(maxsize=settings.CONF.bangumi, ttl=settings.CONF.meta)
|
||||
def __invoke(self, url, key: Optional[str] = None, **kwargs):
|
||||
req_url = self._base_url + url
|
||||
params = {}
|
||||
|
||||
@@ -171,14 +171,14 @@ class DoubanApi(metaclass=Singleton):
|
||||
).digest()
|
||||
).decode()
|
||||
|
||||
@cached(maxsize=settings.CONF["douban"], ttl=settings.CONF["meta"])
|
||||
@cached(maxsize=settings.CONF.douban, ttl=settings.CONF.meta)
|
||||
def __invoke_recommend(self, url: str, **kwargs) -> dict:
|
||||
"""
|
||||
推荐/发现类API
|
||||
"""
|
||||
return self.__invoke(url, **kwargs)
|
||||
|
||||
@cached(maxsize=settings.CONF["douban"], ttl=settings.CONF["meta"])
|
||||
@cached(maxsize=settings.CONF.douban, ttl=settings.CONF.meta)
|
||||
def __invoke_search(self, url: str, **kwargs) -> dict:
|
||||
"""
|
||||
搜索类API
|
||||
@@ -213,7 +213,7 @@ class DoubanApi(metaclass=Singleton):
|
||||
return resp.json()
|
||||
return resp.json() if resp else {}
|
||||
|
||||
@cached(maxsize=settings.CONF["douban"], ttl=settings.CONF["meta"])
|
||||
@cached(maxsize=settings.CONF.douban, ttl=settings.CONF.meta)
|
||||
def __post(self, url: str, **kwargs) -> dict:
|
||||
"""
|
||||
POST请求
|
||||
|
||||
@@ -16,7 +16,7 @@ from app.schemas.types import MediaType
|
||||
lock = RLock()
|
||||
|
||||
CACHE_EXPIRE_TIMESTAMP_STR = "cache_expire_timestamp"
|
||||
EXPIRE_TIMESTAMP = settings.CONF["meta"]
|
||||
EXPIRE_TIMESTAMP = settings.CONF.meta
|
||||
|
||||
|
||||
class DoubanCache(metaclass=Singleton):
|
||||
|
||||
@@ -29,6 +29,7 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
|
||||
event_data: schemas.ConfigChangeEventData = event.event_data
|
||||
if event_data.key not in [SystemConfigKey.MediaServers.value]:
|
||||
return
|
||||
logger.info("配置变更,重新初始化Emby模块...")
|
||||
self.init_module()
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -399,28 +399,30 @@ class FanartModule(_ModuleBase):
|
||||
if not mediainfo.get_image(season_image):
|
||||
mediainfo.set_image(season_image, image_obj.get('url'))
|
||||
else:
|
||||
|
||||
# 其他图片,优先环境变量指定语言,再like最多
|
||||
def pick_best_image(images):
|
||||
def __pick_best_image(_images):
|
||||
lang_env = settings.FANART_LANG
|
||||
if lang_env:
|
||||
langs = [lang.strip() for lang in lang_env.split(",") if lang.strip()]
|
||||
for lang in langs:
|
||||
lang_images = [img for img in images if img.get('lang') == lang]
|
||||
lang_images = [img for img in _images if img.get('lang') == lang]
|
||||
if lang_images:
|
||||
lang_images.sort(key=lambda x: int(x.get('likes', 0)), reverse=True)
|
||||
return lang_images[0]
|
||||
# 没设置或没找到,按原逻辑 zh、en、like最多
|
||||
zh_images = [img for img in images if img.get('lang') == 'zh']
|
||||
zh_images = [img for img in _images if img.get('lang') == 'zh']
|
||||
if zh_images:
|
||||
zh_images.sort(key=lambda x: int(x.get('likes', 0)), reverse=True)
|
||||
return zh_images[0]
|
||||
en_images = [img for img in images if img.get('lang') == 'en']
|
||||
en_images = [img for img in _images if img.get('lang') == 'en']
|
||||
if en_images:
|
||||
en_images.sort(key=lambda x: int(x.get('likes', 0)), reverse=True)
|
||||
return en_images[0]
|
||||
images.sort(key=lambda x: int(x.get('likes', 0)), reverse=True)
|
||||
return images[0]
|
||||
image_obj = pick_best_image(images)
|
||||
_images.sort(key=lambda x: int(x.get('likes', 0)), reverse=True)
|
||||
return _images[0]
|
||||
|
||||
image_obj = __pick_best_image(images)
|
||||
# 设置图片,没有图片才设置
|
||||
if not mediainfo.get_image(image_name):
|
||||
mediainfo.set_image(image_name, image_obj.get('url'))
|
||||
@@ -438,7 +440,7 @@ class FanartModule(_ModuleBase):
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
@cached(maxsize=settings.CONF["fanart"], ttl=settings.CONF["meta"])
|
||||
@cached(maxsize=settings.CONF.fanart, ttl=settings.CONF.meta)
|
||||
def __request_fanart(cls, media_type: MediaType, queryid: Union[str, int]) -> Optional[dict]:
|
||||
if media_type == MediaType.MOVIE:
|
||||
image_url = cls._movie_url % queryid
|
||||
|
||||
@@ -344,9 +344,14 @@ class FileManagerModule(_ModuleBase):
|
||||
return None
|
||||
return storage_oper.get_parent(fileitem)
|
||||
|
||||
def snapshot_storage(self, storage: str, path: Path) -> Optional[Dict[str, float]]:
|
||||
def snapshot_storage(self, storage: str, path: Path,
|
||||
last_snapshot_time: float = None, max_depth: int = 5) -> Optional[Dict[str, Dict]]:
|
||||
"""
|
||||
快照存储
|
||||
:param storage: 存储类型
|
||||
:param path: 路径
|
||||
:param last_snapshot_time: 上次快照时间,用于增量快照
|
||||
:param max_depth: 最大递归深度,避免过深遍历
|
||||
"""
|
||||
if storage not in self._support_storages:
|
||||
return None
|
||||
@@ -354,7 +359,7 @@ class FileManagerModule(_ModuleBase):
|
||||
if not storage_oper:
|
||||
logger.error(f"不支持 {storage} 的快照处理")
|
||||
return None
|
||||
return storage_oper.snapshot(path)
|
||||
return storage_oper.snapshot(path, last_snapshot_time=last_snapshot_time, max_depth=max_depth)
|
||||
|
||||
def storage_usage(self, storage: str) -> Optional[StorageUsage]:
|
||||
"""
|
||||
@@ -507,17 +512,38 @@ class FileManagerModule(_ModuleBase):
|
||||
# 重命名格式
|
||||
rename_format = settings.TV_RENAME_FORMAT \
|
||||
if mediainfo.type == MediaType.TV else settings.MOVIE_RENAME_FORMAT
|
||||
# 元数据补上常用属性,尽可能确保重命名后的路径不出现空白
|
||||
meta = MetaInfo(mediainfo.title)
|
||||
if meta.type == MediaType.UNKNOWN and mediainfo.type is not None:
|
||||
meta.type = mediainfo.type
|
||||
if meta.year is None:
|
||||
meta.year = mediainfo.year
|
||||
if meta.begin_season is None:
|
||||
meta.begin_season = 1
|
||||
if meta.begin_episode is None:
|
||||
meta.begin_episode = 1
|
||||
# 获取路径(重命名路径)
|
||||
target_path = handler.get_rename_path(
|
||||
path=dir_path,
|
||||
template_string=rename_format,
|
||||
rename_dict=handler.get_naming_dict(meta=MetaInfo(mediainfo.title),
|
||||
rename_dict=handler.get_naming_dict(meta=meta,
|
||||
mediainfo=mediainfo)
|
||||
)
|
||||
# 计算重命名中的文件夹层数
|
||||
rename_format_level = len(rename_format.split("/")) - 1
|
||||
rename_list = rename_format.split("/")
|
||||
rename_format_level = len(rename_list) - 1
|
||||
for level, name in enumerate(rename_list):
|
||||
# 处理特例,有的人重命名第一层是年份、分辨率
|
||||
if "{{title}}" in name:
|
||||
# 找出含标题的这一层作为扫描路径
|
||||
rename_format_level -= level
|
||||
break
|
||||
# 取相对路径的第1层目录
|
||||
media_path = target_path.parents[rename_format_level - 1]
|
||||
if dir_path.is_relative_to(media_path):
|
||||
# 兜底检查,避免不必要的扫盘
|
||||
logger.warn(f"{media_path} 是媒体库目录 {dir_path} 的父目录,忽略获取媒体文件列表,请检查重命名格式!")
|
||||
continue
|
||||
# 检索媒体文件
|
||||
fileitem = storage_oper.get_item(media_path)
|
||||
if not fileitem:
|
||||
@@ -543,9 +569,12 @@ class FileManagerModule(_ModuleBase):
|
||||
if not settings.LOCAL_EXISTS_SEARCH:
|
||||
return None
|
||||
|
||||
logger.debug(f"正在本地媒体库中查找 {mediainfo.title_year}...")
|
||||
|
||||
# 检查媒体库
|
||||
fileitems = self.media_files(mediainfo)
|
||||
if not fileitems:
|
||||
logger.debug(f"{mediainfo.title_year} 不在本地媒体库中")
|
||||
return None
|
||||
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import Optional, List, Dict, Tuple
|
||||
|
||||
from app import schemas
|
||||
from app.helper.storage import StorageHelper
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class StorageBase(metaclass=ABCMeta):
|
||||
@@ -135,7 +136,8 @@ class StorageBase(metaclass=ABCMeta):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def upload(self, fileitem: schemas.FileItem, path: Path, new_name: Optional[str] = None) -> Optional[schemas.FileItem]:
|
||||
def upload(self, fileitem: schemas.FileItem, path: Path,
|
||||
new_name: Optional[str] = None) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
上传文件
|
||||
:param fileitem: 上传目录项
|
||||
@@ -192,21 +194,44 @@ class StorageBase(metaclass=ABCMeta):
|
||||
"""
|
||||
pass
|
||||
|
||||
def snapshot(self, path: Path) -> Dict[str, float]:
|
||||
def snapshot(self, path: Path, last_snapshot_time: float = None, max_depth: int = 5) -> Dict[str, Dict]:
|
||||
"""
|
||||
快照文件系统,输出所有层级文件信息(不含目录)
|
||||
:param path: 路径
|
||||
:param last_snapshot_time: 上次快照时间,用于增量快照
|
||||
:param max_depth: 最大递归深度,避免过深遍历
|
||||
"""
|
||||
files_info = {}
|
||||
|
||||
def __snapshot_file(_fileitm: schemas.FileItem):
|
||||
def __snapshot_file(_fileitm: schemas.FileItem, current_depth: int = 0):
|
||||
"""
|
||||
递归获取文件信息
|
||||
"""
|
||||
if _fileitm.type == "dir":
|
||||
for sub_file in self.list(_fileitm):
|
||||
__snapshot_file(sub_file)
|
||||
else:
|
||||
files_info[_fileitm.path] = _fileitm.size
|
||||
try:
|
||||
if _fileitm.type == "dir":
|
||||
# 检查递归深度限制
|
||||
if current_depth >= max_depth:
|
||||
return
|
||||
|
||||
# 增量检查:如果目录修改时间早于上次快照,跳过
|
||||
if (last_snapshot_time and
|
||||
_fileitm.modify_time and
|
||||
_fileitm.modify_time <= last_snapshot_time):
|
||||
return
|
||||
|
||||
# 遍历子文件
|
||||
sub_files = self.list(_fileitm)
|
||||
for sub_file in sub_files:
|
||||
__snapshot_file(sub_file, current_depth + 1)
|
||||
else:
|
||||
# 记录文件的完整信息用于比对
|
||||
files_info[_fileitm.path] = {
|
||||
'size': _fileitm.size or 0,
|
||||
'modify_time': getattr(_fileitm, 'modify_time', 0),
|
||||
'type': _fileitm.type
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"Snapshot error for {_fileitm.path}: {e}")
|
||||
|
||||
fileitem = self.get_item(path)
|
||||
if not fileitem:
|
||||
|
||||
@@ -5,7 +5,7 @@ import secrets
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Tuple, Union
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
import requests
|
||||
from tqdm import tqdm
|
||||
@@ -52,9 +52,6 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
# 基础url
|
||||
base_url = "https://openapi.alipan.com"
|
||||
|
||||
# CID和路径缓存
|
||||
_id_cache: Dict[str, Tuple[str, str]] = {}
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.session = requests.Session()
|
||||
@@ -279,61 +276,6 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
return ret_data.get(result_key)
|
||||
return ret_data
|
||||
|
||||
def _path_to_id(self, drive_id: str, path: str) -> Tuple[str, str]:
|
||||
"""
|
||||
路径转drive_id, file_id(带缓存机制)
|
||||
"""
|
||||
# 根目录
|
||||
if path == "/":
|
||||
return drive_id, "root"
|
||||
if len(path) > 1 and path.endswith("/"):
|
||||
path = path[:-1]
|
||||
# 检查缓存
|
||||
if path in self._id_cache:
|
||||
return self._id_cache[path]
|
||||
# 逐级查找缓存
|
||||
file_id = "root"
|
||||
file_path = "/"
|
||||
for p in Path(path).parents:
|
||||
if str(p) in self._id_cache:
|
||||
file_path = str(p)
|
||||
file_id = self._id_cache[file_path]
|
||||
break
|
||||
# 计算相对路径
|
||||
rel_path = Path(path).relative_to(file_path)
|
||||
for part in Path(rel_path).parts:
|
||||
find_part = False
|
||||
next_marker = None
|
||||
while True:
|
||||
resp = self._request_api(
|
||||
"POST",
|
||||
"/adrive/v1.0/openFile/list",
|
||||
json={
|
||||
"drive_id": drive_id,
|
||||
"limit": 100,
|
||||
"marker": next_marker,
|
||||
"parent_file_id": file_id,
|
||||
}
|
||||
)
|
||||
if not resp:
|
||||
break
|
||||
for item in resp.get("items", []):
|
||||
if item["name"] == part:
|
||||
file_id = item["file_id"]
|
||||
find_part = True
|
||||
break
|
||||
if find_part:
|
||||
break
|
||||
if len(resp.get("items")) < 100:
|
||||
break
|
||||
if not find_part:
|
||||
raise FileNotFoundError(f"【阿里云盘】{path} 不存在")
|
||||
if file_id == "root":
|
||||
raise FileNotFoundError(f"【阿里云盘】{path} 不存在")
|
||||
# 缓存路径
|
||||
self._id_cache[path] = (drive_id, file_id)
|
||||
return drive_id, file_id
|
||||
|
||||
def __get_fileitem(self, fileinfo: dict, parent: str = "/") -> schemas.FileItem:
|
||||
"""
|
||||
获取文件信息
|
||||
@@ -427,9 +369,6 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
break
|
||||
next_marker = resp.get("next_marker")
|
||||
for item in resp.get("items", []):
|
||||
# 更新缓存
|
||||
path = f"{fileitem.path}{item.get('name')}"
|
||||
self._id_cache[path] = (drive_id, item.get("file_id"))
|
||||
items.append(self.__get_fileitem(item, parent=fileitem.path))
|
||||
if len(resp.get("items")) < 100:
|
||||
break
|
||||
@@ -467,7 +406,6 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
return None
|
||||
# 缓存新目录
|
||||
new_path = Path(parent_item.path) / name
|
||||
self._id_cache[str(new_path)] = (resp.get("drive_id"), resp.get("file_id"))
|
||||
return self._delay_get_item(new_path)
|
||||
|
||||
@staticmethod
|
||||
@@ -837,15 +775,9 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
if resp.get("code"):
|
||||
logger.warn(f"【阿里云盘】重命名失败: {resp.get('message')}")
|
||||
return False
|
||||
if fileitem.path in self._id_cache:
|
||||
del self._id_cache[fileitem.path]
|
||||
for key in list(self._id_cache.keys()):
|
||||
if key.startswith(fileitem.path):
|
||||
del self._id_cache[key]
|
||||
self._id_cache[str(Path(fileitem.path).parent / name)] = (resp.get("drive_id"), resp.get("file_id"))
|
||||
return True
|
||||
|
||||
def get_item(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
def get_item(self, path: Path, drive_id: str = None) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
获取指定路径的文件/目录项
|
||||
"""
|
||||
@@ -854,7 +786,7 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
"POST",
|
||||
"/adrive/v1.0/openFile/get_by_path",
|
||||
json={
|
||||
"drive_id": self._default_drive_id,
|
||||
"drive_id": drive_id or self._default_drive_id,
|
||||
"file_path": str(path)
|
||||
}
|
||||
)
|
||||
@@ -910,9 +842,15 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
|
||||
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
企业级复制实现(支持目录递归复制)
|
||||
复制文件到指定路径
|
||||
:param fileitem: 要复制的文件项
|
||||
:param path: 目标目录路径
|
||||
:param new_name: 新文件名
|
||||
"""
|
||||
dest_cid = self._path_to_id(fileitem.drive_id, str(path))
|
||||
dest_fileitem = self.get_item(path, drive_id=fileitem.drive_id)
|
||||
if not dest_fileitem or dest_fileitem.type != "dir":
|
||||
logger.warn(f"【阿里云盘】目标路径 {path} 不存在或不是目录!")
|
||||
return False
|
||||
resp = self._request_api(
|
||||
"POST",
|
||||
"/adrive/v1.0/openFile/copy",
|
||||
@@ -920,7 +858,7 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
"drive_id": fileitem.drive_id,
|
||||
"file_id": fileitem.fileid,
|
||||
"to_drive_id": fileitem.drive_id,
|
||||
"to_parent_file_id": dest_cid
|
||||
"to_parent_file_id": dest_fileitem.fileid,
|
||||
}
|
||||
)
|
||||
if not resp:
|
||||
@@ -932,18 +870,20 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
new_path = Path(path) / fileitem.name
|
||||
new_file = self._delay_get_item(new_path)
|
||||
self.rename(new_file, new_name)
|
||||
# 更新缓存
|
||||
del self._id_cache[fileitem.path]
|
||||
rename_new_path = Path(path) / new_name
|
||||
self._id_cache[str(rename_new_path)] = (resp.get("drive_id"), resp.get("file_id"))
|
||||
return True
|
||||
|
||||
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
原子性移动操作实现
|
||||
移动文件到指定路径
|
||||
:param fileitem: 要移动的文件项
|
||||
:param path: 目标目录路径
|
||||
:param new_name: 新文件名
|
||||
"""
|
||||
src_fid = fileitem.fileid
|
||||
target_id = self._path_to_id(fileitem.drive_id, str(path))
|
||||
target_fileitem = self.get_item(path, drive_id=fileitem.drive_id)
|
||||
if not target_fileitem or target_fileitem.type != "dir":
|
||||
logger.warn(f"【阿里云盘】目标路径 {path} 不存在或不是目录!")
|
||||
return False
|
||||
|
||||
resp = self._request_api(
|
||||
"POST",
|
||||
@@ -951,7 +891,7 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
json={
|
||||
"drive_id": fileitem.drive_id,
|
||||
"file_id": src_fid,
|
||||
"to_parent_file_id": target_id,
|
||||
"to_parent_file_id": target_fileitem.fileid,
|
||||
"new_name": new_name
|
||||
}
|
||||
)
|
||||
@@ -960,10 +900,6 @@ class AliPan(StorageBase, metaclass=Singleton):
|
||||
if resp.get("code"):
|
||||
logger.warn(f"【阿里云盘】移动文件失败: {resp.get('message')}")
|
||||
return False
|
||||
# 更新缓存
|
||||
del self._id_cache[fileitem.path]
|
||||
rename_new_path = Path(path) / new_name
|
||||
self._id_cache[str(rename_new_path)] = (resp.get("drive_id"), resp.get("file_id"))
|
||||
return True
|
||||
|
||||
def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict
|
||||
from typing import Optional, List
|
||||
|
||||
import requests
|
||||
from requests import Response
|
||||
|
||||
from app import schemas
|
||||
from app.core.cache import cached
|
||||
@@ -20,7 +19,7 @@ from app.utils.url import UrlUtils
|
||||
class Alist(StorageBase, metaclass=Singleton):
|
||||
"""
|
||||
Alist相关操作
|
||||
api文档:https://alist.nn.ci/zh/guide/api
|
||||
api文档:https://oplist.org/zh/
|
||||
"""
|
||||
|
||||
# 存储类型
|
||||
@@ -39,7 +38,7 @@ class Alist(StorageBase, metaclass=Singleton):
|
||||
"""
|
||||
初始化
|
||||
"""
|
||||
pass
|
||||
self.__generate_token.clear_cache()
|
||||
|
||||
@property
|
||||
def __get_base_url(self) -> str:
|
||||
@@ -77,7 +76,7 @@ class Alist(StorageBase, metaclass=Singleton):
|
||||
token = conf.get("token")
|
||||
if token:
|
||||
return str(token)
|
||||
resp: Response = RequestUtils(headers={
|
||||
resp = RequestUtils(headers={
|
||||
'Content-Type': 'application/json'
|
||||
}).post_res(
|
||||
self.__get_api_url("/api/auth/login"),
|
||||
@@ -102,20 +101,20 @@ class Alist(StorageBase, metaclass=Singleton):
|
||||
"""
|
||||
|
||||
if resp is None:
|
||||
logger.warning("【alist】请求登录失败,无法连接alist服务")
|
||||
logger.warning("【OpenList】请求登录失败,无法连接alist服务")
|
||||
return ""
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"【alist】更新令牌请求发送失败,状态码:{resp.status_code}")
|
||||
logger.warning(f"【OpenList】更新令牌请求发送失败,状态码:{resp.status_code}")
|
||||
return ""
|
||||
|
||||
result = resp.json()
|
||||
|
||||
if result["code"] != 200:
|
||||
logger.critical(f'【alist】更新令牌,错误信息:{result["message"]}')
|
||||
logger.critical(f'【OpenList】更新令牌,错误信息:{result["message"]}')
|
||||
return ""
|
||||
|
||||
logger.debug("【alist】AList获取令牌成功")
|
||||
logger.debug("【OpenList】AList获取令牌成功")
|
||||
return result["data"]["token"]
|
||||
|
||||
def __get_header_with_token(self) -> dict:
|
||||
@@ -128,7 +127,7 @@ class Alist(StorageBase, metaclass=Singleton):
|
||||
"""
|
||||
检查存储是否可用
|
||||
"""
|
||||
pass
|
||||
return True if self.__generate_token else False
|
||||
|
||||
def list(
|
||||
self,
|
||||
@@ -151,7 +150,7 @@ class Alist(StorageBase, metaclass=Singleton):
|
||||
if item:
|
||||
return [item]
|
||||
return []
|
||||
resp: Response = RequestUtils(
|
||||
resp = RequestUtils(
|
||||
headers=self.__get_header_with_token()
|
||||
).post_res(
|
||||
self.__get_api_url("/api/fs/list"),
|
||||
@@ -200,11 +199,11 @@ class Alist(StorageBase, metaclass=Singleton):
|
||||
"""
|
||||
|
||||
if resp is None:
|
||||
logger.warn(f"【alist】请求获取目录 {fileitem.path} 的文件列表失败,无法连接alist服务")
|
||||
logger.warn(f"【OpenList】请求获取目录 {fileitem.path} 的文件列表失败,无法连接alist服务")
|
||||
return []
|
||||
if resp.status_code != 200:
|
||||
logger.warn(
|
||||
f"【alist】请求获取目录 {fileitem.path} 的文件列表失败,状态码:{resp.status_code}"
|
||||
f"【OpenList】请求获取目录 {fileitem.path} 的文件列表失败,状态码:{resp.status_code}"
|
||||
)
|
||||
return []
|
||||
|
||||
@@ -212,7 +211,7 @@ class Alist(StorageBase, metaclass=Singleton):
|
||||
|
||||
if result["code"] != 200:
|
||||
logger.warn(
|
||||
f'【alist】获取目录 {fileitem.path} 的文件列表失败,错误信息:{result["message"]}'
|
||||
f'【OpenList】获取目录 {fileitem.path} 的文件列表失败,错误信息:{result["message"]}'
|
||||
)
|
||||
return []
|
||||
|
||||
@@ -240,7 +239,7 @@ class Alist(StorageBase, metaclass=Singleton):
|
||||
:param name: 目录名
|
||||
"""
|
||||
path = Path(fileitem.path) / name
|
||||
resp: Response = RequestUtils(
|
||||
resp = RequestUtils(
|
||||
headers=self.__get_header_with_token()
|
||||
).post_res(
|
||||
self.__get_api_url("/api/fs/mkdir"),
|
||||
@@ -258,15 +257,15 @@ class Alist(StorageBase, metaclass=Singleton):
|
||||
}
|
||||
"""
|
||||
if resp is None:
|
||||
logger.warn(f"【alist】请求创建目录 {path} 失败,无法连接alist服务")
|
||||
logger.warn(f"【OpenList】请求创建目录 {path} 失败,无法连接alist服务")
|
||||
return None
|
||||
if resp.status_code != 200:
|
||||
logger.warn(f"【alist】请求创建目录 {path} 失败,状态码:{resp.status_code}")
|
||||
logger.warn(f"【OpenList】请求创建目录 {path} 失败,状态码:{resp.status_code}")
|
||||
return None
|
||||
|
||||
result = resp.json()
|
||||
if result["code"] != 200:
|
||||
logger.warn(f'【alist】创建目录 {path} 失败,错误信息:{result["message"]}')
|
||||
logger.warn(f'【OpenList】创建目录 {path} 失败,错误信息:{result["message"]}')
|
||||
return None
|
||||
|
||||
return self.get_item(path)
|
||||
@@ -304,7 +303,7 @@ class Alist(StorageBase, metaclass=Singleton):
|
||||
:param per_page: 每页数量
|
||||
:param refresh: 是否刷新
|
||||
"""
|
||||
resp: Response = RequestUtils(
|
||||
resp = RequestUtils(
|
||||
headers=self.__get_header_with_token()
|
||||
).post_res(
|
||||
self.__get_api_url("/api/fs/get"),
|
||||
@@ -348,15 +347,15 @@ class Alist(StorageBase, metaclass=Singleton):
|
||||
}
|
||||
"""
|
||||
if resp is None:
|
||||
logger.warn(f"【alist】请求获取文件 {path} 失败,无法连接alist服务")
|
||||
logger.warn(f"【OpenList】请求获取文件 {path} 失败,无法连接alist服务")
|
||||
return None
|
||||
if resp.status_code != 200:
|
||||
logger.warn(f"【alist】请求获取文件 {path} 失败,状态码:{resp.status_code}")
|
||||
logger.warn(f"【OpenList】请求获取文件 {path} 失败,状态码:{resp.status_code}")
|
||||
return None
|
||||
|
||||
result = resp.json()
|
||||
if result["code"] != 200:
|
||||
logger.debug(f'【alist】获取文件 {path} 失败,错误信息:{result["message"]}')
|
||||
logger.debug(f'【OpenList】获取文件 {path} 失败,错误信息:{result["message"]}')
|
||||
return None
|
||||
|
||||
return schemas.FileItem(
|
||||
@@ -381,7 +380,7 @@ class Alist(StorageBase, metaclass=Singleton):
|
||||
"""
|
||||
删除文件
|
||||
"""
|
||||
resp: Response = RequestUtils(
|
||||
resp = RequestUtils(
|
||||
headers=self.__get_header_with_token()
|
||||
).post_res(
|
||||
self.__get_api_url("/api/fs/remove"),
|
||||
@@ -405,18 +404,18 @@ class Alist(StorageBase, metaclass=Singleton):
|
||||
}
|
||||
"""
|
||||
if resp is None:
|
||||
logger.warn(f"【alist】请求删除文件 {fileitem.path} 失败,无法连接alist服务")
|
||||
logger.warn(f"【OpenList】请求删除文件 {fileitem.path} 失败,无法连接alist服务")
|
||||
return False
|
||||
if resp.status_code != 200:
|
||||
logger.warn(
|
||||
f"【alist】请求删除文件 {fileitem.path} 失败,状态码:{resp.status_code}"
|
||||
f"【OpenList】请求删除文件 {fileitem.path} 失败,状态码:{resp.status_code}"
|
||||
)
|
||||
return False
|
||||
|
||||
result = resp.json()
|
||||
if result["code"] != 200:
|
||||
logger.warn(
|
||||
f'【alist】删除文件 {fileitem.path} 失败,错误信息:{result["message"]}'
|
||||
f'【OpenList】删除文件 {fileitem.path} 失败,错误信息:{result["message"]}'
|
||||
)
|
||||
return False
|
||||
return True
|
||||
@@ -425,7 +424,7 @@ class Alist(StorageBase, metaclass=Singleton):
|
||||
"""
|
||||
重命名文件
|
||||
"""
|
||||
resp: Response = RequestUtils(
|
||||
resp = RequestUtils(
|
||||
headers=self.__get_header_with_token()
|
||||
).post_res(
|
||||
self.__get_api_url("/api/fs/rename"),
|
||||
@@ -447,18 +446,18 @@ class Alist(StorageBase, metaclass=Singleton):
|
||||
}
|
||||
"""
|
||||
if not resp:
|
||||
logger.warn(f"【alist】请求重命名文件 {fileitem.path} 失败,无法连接alist服务")
|
||||
logger.warn(f"【OpenList】请求重命名文件 {fileitem.path} 失败,无法连接alist服务")
|
||||
return False
|
||||
if resp.status_code != 200:
|
||||
logger.warn(
|
||||
f"【alist】请求重命名文件 {fileitem.path} 失败,状态码:{resp.status_code}"
|
||||
f"【OpenList】请求重命名文件 {fileitem.path} 失败,状态码:{resp.status_code}"
|
||||
)
|
||||
return False
|
||||
|
||||
result = resp.json()
|
||||
if result["code"] != 200:
|
||||
logger.warn(
|
||||
f'【alist】重命名文件 {fileitem.path} 失败,错误信息:{result["message"]}'
|
||||
f'【OpenList】重命名文件 {fileitem.path} 失败,错误信息:{result["message"]}'
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -476,7 +475,7 @@ class Alist(StorageBase, metaclass=Singleton):
|
||||
:param path: 文件保存路径
|
||||
:param password: 文件密码
|
||||
"""
|
||||
resp: Response = RequestUtils(
|
||||
resp = RequestUtils(
|
||||
headers=self.__get_header_with_token()
|
||||
).post_res(
|
||||
self.__get_api_url("/api/fs/get"),
|
||||
@@ -512,15 +511,15 @@ class Alist(StorageBase, metaclass=Singleton):
|
||||
}
|
||||
"""
|
||||
if not resp:
|
||||
logger.warn(f"【alist】请求获取文件 {path} 失败,无法连接alist服务")
|
||||
logger.warn(f"【OpenList】请求获取文件 {path} 失败,无法连接alist服务")
|
||||
return None
|
||||
if resp.status_code != 200:
|
||||
logger.warn(f"【alist】请求获取文件 {path} 失败,状态码:{resp.status_code}")
|
||||
logger.warn(f"【OpenList】请求获取文件 {path} 失败,状态码:{resp.status_code}")
|
||||
return None
|
||||
|
||||
result = resp.json()
|
||||
if result["code"] != 200:
|
||||
logger.warn(f'【alist】获取文件 {path} 失败,错误信息:{result["message"]}')
|
||||
logger.warn(f'【OpenList】获取文件 {path} 失败,错误信息:{result["message"]}')
|
||||
return None
|
||||
|
||||
if result["data"]["raw_url"]:
|
||||
@@ -561,13 +560,13 @@ class Alist(StorageBase, metaclass=Singleton):
|
||||
headers.setdefault("As-Task", str(task).lower())
|
||||
headers.setdefault("File-Path", encoded_path)
|
||||
with open(path, "rb") as f:
|
||||
resp: Response = RequestUtils(headers=headers).put_res(
|
||||
resp = RequestUtils(headers=headers).put_res(
|
||||
self.__get_api_url("/api/fs/put"),
|
||||
data=f,
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.warn(f"【alist】请求上传文件 {path} 失败,状态码:{resp.status_code}")
|
||||
logger.warn(f"【OpenList】请求上传文件 {path} 失败,状态码:{resp.status_code}")
|
||||
return None
|
||||
|
||||
new_item = self.get_item(Path(fileitem.path) / path.name)
|
||||
@@ -590,7 +589,7 @@ class Alist(StorageBase, metaclass=Singleton):
|
||||
:param path: 目标目录
|
||||
:param new_name: 新文件名
|
||||
"""
|
||||
resp: Response = RequestUtils(
|
||||
resp = RequestUtils(
|
||||
headers=self.__get_header_with_token()
|
||||
).post_res(
|
||||
self.__get_api_url("/api/fs/copy"),
|
||||
@@ -617,19 +616,19 @@ class Alist(StorageBase, metaclass=Singleton):
|
||||
"""
|
||||
if resp is None:
|
||||
logger.warn(
|
||||
f"【alist】请求复制文件 {fileitem.path} 失败,无法连接alist服务"
|
||||
f"【OpenList】请求复制文件 {fileitem.path} 失败,无法连接alist服务"
|
||||
)
|
||||
return False
|
||||
if resp.status_code != 200:
|
||||
logger.warn(
|
||||
f"【alist】请求复制文件 {fileitem.path} 失败,状态码:{resp.status_code}"
|
||||
f"【OpenList】请求复制文件 {fileitem.path} 失败,状态码:{resp.status_code}"
|
||||
)
|
||||
return False
|
||||
|
||||
result = resp.json()
|
||||
if result["code"] != 200:
|
||||
logger.warn(
|
||||
f'【alist】复制文件 {fileitem.path} 失败,错误信息:{result["message"]}'
|
||||
f'【OpenList】复制文件 {fileitem.path} 失败,错误信息:{result["message"]}'
|
||||
)
|
||||
return False
|
||||
# 重命名
|
||||
@@ -649,7 +648,7 @@ class Alist(StorageBase, metaclass=Singleton):
|
||||
# 先重命名
|
||||
if fileitem.name != new_name:
|
||||
self.rename(fileitem, new_name)
|
||||
resp: Response = RequestUtils(
|
||||
resp = RequestUtils(
|
||||
headers=self.__get_header_with_token()
|
||||
).post_res(
|
||||
self.__get_api_url("/api/fs/move"),
|
||||
@@ -676,19 +675,19 @@ class Alist(StorageBase, metaclass=Singleton):
|
||||
"""
|
||||
if resp is None:
|
||||
logger.warn(
|
||||
f"【alist】请求移动文件 {fileitem.path} 失败,无法连接alist服务"
|
||||
f"【OpenList】请求移动文件 {fileitem.path} 失败,无法连接alist服务"
|
||||
)
|
||||
return False
|
||||
if resp.status_code != 200:
|
||||
logger.warn(
|
||||
f"【alist】请求移动文件 {fileitem.path} 失败,状态码:{resp.status_code}"
|
||||
f"【OpenList】请求移动文件 {fileitem.path} 失败,状态码:{resp.status_code}"
|
||||
)
|
||||
return False
|
||||
|
||||
result = resp.json()
|
||||
if result["code"] != 200:
|
||||
logger.warn(
|
||||
f'【alist】移动文件 {fileitem.path} 失败,错误信息:{result["message"]}'
|
||||
f'【OpenList】移动文件 {fileitem.path} 失败,错误信息:{result["message"]}'
|
||||
)
|
||||
return False
|
||||
return True
|
||||
@@ -711,30 +710,6 @@ class Alist(StorageBase, metaclass=Singleton):
|
||||
"""
|
||||
pass
|
||||
|
||||
def snapshot(self, path: Path) -> Dict[str, float]:
|
||||
"""
|
||||
快照文件系统,输出所有层级文件信息(不含目录)
|
||||
"""
|
||||
files_info = {}
|
||||
|
||||
def __snapshot_file(_fileitm: schemas.FileItem):
|
||||
"""
|
||||
递归获取文件信息
|
||||
"""
|
||||
if _fileitm.type == "dir":
|
||||
for sub_file in self.list(_fileitm):
|
||||
__snapshot_file(sub_file)
|
||||
else:
|
||||
files_info[_fileitm.path] = _fileitm.size
|
||||
|
||||
fileitem = self.get_item(path)
|
||||
if not fileitem:
|
||||
return {}
|
||||
|
||||
__snapshot_file(fileitem)
|
||||
|
||||
return files_info
|
||||
|
||||
@staticmethod
|
||||
def __parse_timestamp(time_str: str) -> float:
|
||||
"""
|
||||
|
||||
@@ -191,7 +191,8 @@ class LocalStorage(StorageBase):
|
||||
"""
|
||||
return Path(fileitem.path)
|
||||
|
||||
def upload(self, fileitem: schemas.FileItem, path: Path, new_name: Optional[str] = None) -> Optional[schemas.FileItem]:
|
||||
def upload(self, fileitem: schemas.FileItem, path: Path,
|
||||
new_name: Optional[str] = None) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
上传文件
|
||||
:param fileitem: 上传目录项
|
||||
@@ -260,8 +261,11 @@ class LocalStorage(StorageBase):
|
||||
"""
|
||||
存储使用情况
|
||||
"""
|
||||
library_dirs = DirectoryHelper().get_local_library_dirs()
|
||||
total_storage, free_storage = SystemUtils.space_usage([Path(d.library_path) for d in library_dirs])
|
||||
directory_helper = DirectoryHelper()
|
||||
total_storage, free_storage = SystemUtils.space_usage(
|
||||
[Path(d.download_path) for d in directory_helper.get_local_download_dirs() if d.download_path] +
|
||||
[Path(d.library_path) for d in directory_helper.get_local_library_dirs() if d.library_path]
|
||||
)
|
||||
return schemas.StorageUsage(
|
||||
total=total_storage,
|
||||
available=free_storage
|
||||
|
||||
549
app/modules/filemanager/storages/smb.py
Normal file
549
app/modules/filemanager/storages/smb.py
Normal file
@@ -0,0 +1,549 @@
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Union
|
||||
|
||||
import smbclient
|
||||
from smbclient import ClientConfig, register_session, reset_connection_cache
|
||||
from smbprotocol.exceptions import SMBException, SMBResponseException, SMBAuthenticationError
|
||||
|
||||
from app import schemas
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
from app.modules.filemanager import StorageBase
|
||||
from app.schemas.types import StorageSchema
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
lock = threading.Lock()
|
||||
|
||||
|
||||
class SMBConnectionError(Exception):
|
||||
"""SMB 连接错误"""
|
||||
pass
|
||||
|
||||
|
||||
class SMB(StorageBase, metaclass=Singleton):
|
||||
"""
|
||||
SMB网络挂载存储相关操作 - 使用 smbclient 高级接口
|
||||
"""
|
||||
|
||||
# 存储类型
|
||||
schema = StorageSchema.SMB
|
||||
|
||||
# 支持的整理方式
|
||||
transtype = {
|
||||
"move": "移动",
|
||||
"copy": "复制",
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._connected = False
|
||||
self._server_path = None
|
||||
self._host = None
|
||||
self._username = None
|
||||
self._password = None
|
||||
self._init_connection()
|
||||
|
||||
def _init_connection(self):
|
||||
"""
|
||||
初始化SMB连接配置
|
||||
"""
|
||||
try:
|
||||
conf = self.get_conf()
|
||||
if not conf:
|
||||
return
|
||||
|
||||
self._host = conf.get("host")
|
||||
self._username = conf.get("username")
|
||||
self._password = conf.get("password")
|
||||
domain = conf.get("domain", "")
|
||||
share = conf.get("share", "")
|
||||
port = conf.get("port", 445)
|
||||
|
||||
if not all([self._host, share]):
|
||||
logger.error("【SMB】缺少必要的连接参数:host 和 share")
|
||||
return
|
||||
|
||||
# 构建服务器路径
|
||||
self._server_path = f"\\\\{self._host}\\{share}"
|
||||
|
||||
# 配置全局客户端设置
|
||||
ClientConfig(
|
||||
username=self._username,
|
||||
password=self._password,
|
||||
domain=domain if domain else None,
|
||||
connection_timeout=60,
|
||||
port=port,
|
||||
auth_protocol="negotiate", # 使用协商认证
|
||||
require_secure_negotiate=False # 匿名访问时可能需要关闭安全协商
|
||||
)
|
||||
|
||||
# 注册会话以启用连接池
|
||||
register_session(
|
||||
self._host,
|
||||
username=self._username,
|
||||
password=self._password,
|
||||
port=port,
|
||||
encrypt=False, # 根据需要启用加密
|
||||
connection_timeout=60
|
||||
)
|
||||
|
||||
# 测试连接
|
||||
self._test_connection()
|
||||
|
||||
self._connected = True
|
||||
# 判断是否为匿名访问
|
||||
if self._is_anonymous_access():
|
||||
logger.info(f"【SMB】匿名连接成功:{self._server_path}")
|
||||
else:
|
||||
logger.info(f"【SMB】认证连接成功:{self._server_path} (用户:{self._username})")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"【SMB】连接初始化失败:{e}")
|
||||
self._connected = False
|
||||
|
||||
def _test_connection(self):
|
||||
"""
|
||||
测试SMB连接
|
||||
"""
|
||||
try:
|
||||
# 尝试列出根目录来测试连接
|
||||
smbclient.listdir(self._server_path)
|
||||
except SMBAuthenticationError as e:
|
||||
raise SMBConnectionError(f"SMB认证失败:{e}")
|
||||
except SMBResponseException as e:
|
||||
raise SMBConnectionError(f"SMB响应错误:{e}")
|
||||
except SMBException as e:
|
||||
raise SMBConnectionError(f"SMB连接错误:{e}")
|
||||
except Exception as e:
|
||||
raise SMBConnectionError(f"连接测试失败:{e}")
|
||||
|
||||
def _is_anonymous_access(self) -> bool:
|
||||
"""
|
||||
检查是否为匿名访问
|
||||
"""
|
||||
return not self._username and not self._password
|
||||
|
||||
def _check_connection(self):
|
||||
"""
|
||||
检查SMB连接状态
|
||||
"""
|
||||
if not self._connected or not self._server_path:
|
||||
raise SMBConnectionError("【SMB】连接未建立或已断开,请检查配置!")
|
||||
|
||||
def _normalize_path(self, path: Union[str, Path]) -> str:
|
||||
"""
|
||||
标准化路径格式为SMB路径
|
||||
"""
|
||||
path_str = str(path)
|
||||
|
||||
# 处理根路径
|
||||
if path_str in ["/", "\\"]:
|
||||
return self._server_path
|
||||
|
||||
# 去除前导斜杠
|
||||
if path_str.startswith("/"):
|
||||
path_str = path_str[1:]
|
||||
|
||||
# 构建完整的SMB路径
|
||||
if path_str:
|
||||
return f"{self._server_path}\\{path_str.replace('/', '\\')}"
|
||||
else:
|
||||
return self._server_path
|
||||
|
||||
def _create_fileitem(self, stat_result, file_path: str, name: str) -> schemas.FileItem:
|
||||
"""
|
||||
创建文件项
|
||||
"""
|
||||
try:
|
||||
# 检查是否为目录
|
||||
is_directory = smbclient.path.isdir(file_path)
|
||||
|
||||
# 处理路径
|
||||
relative_path = file_path.replace(self._server_path, "").replace("\\", "/")
|
||||
if not relative_path.startswith("/"):
|
||||
relative_path = "/" + relative_path
|
||||
|
||||
if is_directory and not relative_path.endswith("/"):
|
||||
relative_path += "/"
|
||||
|
||||
# 获取时间戳
|
||||
try:
|
||||
modify_time = int(stat_result.st_mtime)
|
||||
except (AttributeError, TypeError):
|
||||
modify_time = int(time.time())
|
||||
|
||||
if is_directory:
|
||||
return schemas.FileItem(
|
||||
storage=self.schema.value,
|
||||
type="dir",
|
||||
path=relative_path,
|
||||
name=name,
|
||||
basename=name,
|
||||
modify_time=modify_time
|
||||
)
|
||||
else:
|
||||
return schemas.FileItem(
|
||||
storage=self.schema.value,
|
||||
type="file",
|
||||
path=relative_path,
|
||||
name=name,
|
||||
basename=Path(name).stem,
|
||||
extension=Path(name).suffix[1:] if Path(name).suffix else None,
|
||||
size=getattr(stat_result, 'st_size', 0),
|
||||
modify_time=modify_time
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"【SMB】创建文件项失败:{e}")
|
||||
# 返回基本的文件项信息
|
||||
return schemas.FileItem(
|
||||
storage=self.schema.value,
|
||||
type="file",
|
||||
path=file_path.replace(self._server_path, "").replace("\\", "/"),
|
||||
name=name,
|
||||
basename=Path(name).stem,
|
||||
modify_time=int(time.time())
|
||||
)
|
||||
|
||||
def init_storage(self):
|
||||
"""
|
||||
初始化存储
|
||||
"""
|
||||
# 重置连接缓存
|
||||
reset_connection_cache()
|
||||
self._init_connection()
|
||||
|
||||
def check(self) -> bool:
|
||||
"""
|
||||
检查存储是否可用
|
||||
"""
|
||||
if not self._connected:
|
||||
return False
|
||||
|
||||
try:
|
||||
self._test_connection()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"【SMB】连接检查失败:{e}")
|
||||
self._connected = False
|
||||
return False
|
||||
|
||||
def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]:
|
||||
"""
|
||||
浏览文件
|
||||
"""
|
||||
try:
|
||||
self._check_connection()
|
||||
|
||||
if fileitem.type == "file":
|
||||
item = self.detail(fileitem)
|
||||
if item:
|
||||
return [item]
|
||||
return []
|
||||
|
||||
# 构建SMB路径
|
||||
smb_path = self._normalize_path(fileitem.path.rstrip("/"))
|
||||
|
||||
# 列出目录内容
|
||||
try:
|
||||
entries = smbclient.listdir(smb_path)
|
||||
except SMBResponseException as e:
|
||||
logger.error(f"【SMB】列出目录失败: {smb_path} - {e}")
|
||||
return []
|
||||
except SMBException as e:
|
||||
logger.error(f"【SMB】列出目录失败: {smb_path} - {e}")
|
||||
return []
|
||||
|
||||
items = []
|
||||
for entry in entries:
|
||||
if entry in [".", ".."]:
|
||||
continue
|
||||
|
||||
entry_path = f"{smb_path}\\{entry}"
|
||||
try:
|
||||
stat_result = smbclient.stat(entry_path)
|
||||
item = self._create_fileitem(stat_result, entry_path, entry)
|
||||
items.append(item)
|
||||
except Exception as e:
|
||||
logger.debug(f"【SMB】获取文件信息失败: {entry_path} - {e}")
|
||||
continue
|
||||
|
||||
return items
|
||||
except Exception as e:
|
||||
logger.error(f"【SMB】列出文件失败: {e}")
|
||||
return []
|
||||
|
||||
def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
创建目录
|
||||
"""
|
||||
try:
|
||||
self._check_connection()
|
||||
|
||||
parent_path = self._normalize_path(fileitem.path.rstrip("/"))
|
||||
new_path = f"{parent_path}\\{name}"
|
||||
|
||||
# 创建目录
|
||||
smbclient.mkdir(new_path)
|
||||
|
||||
# 返回创建的目录信息
|
||||
return schemas.FileItem(
|
||||
storage=self.schema.value,
|
||||
type="dir",
|
||||
path=f"{fileitem.path.rstrip('/')}/{name}/",
|
||||
name=name,
|
||||
basename=name,
|
||||
modify_time=int(time.time())
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"【SMB】创建目录失败: {e}")
|
||||
return None
|
||||
|
||||
def get_folder(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
获取目录,如目录不存在则创建
|
||||
"""
|
||||
# 检查目录是否存在
|
||||
folder = self.get_item(path)
|
||||
if folder:
|
||||
return folder
|
||||
|
||||
# 逐级创建目录
|
||||
parts = path.parts
|
||||
current_path = Path("/")
|
||||
|
||||
for part in parts[1:]: # 跳过根目录
|
||||
current_path = current_path / part
|
||||
folder = self.get_item(current_path)
|
||||
if not folder:
|
||||
parent_folder = self.get_item(current_path.parent)
|
||||
if not parent_folder:
|
||||
logger.error(f"【SMB】父目录不存在: {current_path.parent}")
|
||||
return None
|
||||
folder = self.create_folder(parent_folder, part)
|
||||
if not folder:
|
||||
return None
|
||||
|
||||
return folder
|
||||
|
||||
def get_item(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
获取文件或目录,不存在返回None
|
||||
"""
|
||||
try:
|
||||
self._check_connection()
|
||||
|
||||
# 处理根目录
|
||||
if str(path) == "/":
|
||||
return schemas.FileItem(
|
||||
storage=self.schema.value,
|
||||
type="dir",
|
||||
path="/",
|
||||
name="",
|
||||
basename="",
|
||||
modify_time=int(time.time())
|
||||
)
|
||||
|
||||
smb_path = self._normalize_path(str(path).rstrip("/"))
|
||||
|
||||
# 检查路径是否存在
|
||||
if not smbclient.path.exists(smb_path):
|
||||
return None
|
||||
|
||||
stat_result = smbclient.stat(smb_path)
|
||||
file_name = Path(path).name
|
||||
|
||||
return self._create_fileitem(stat_result, smb_path, file_name)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"【SMB】获取文件项失败: {e}")
|
||||
return None
|
||||
|
||||
def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
获取文件详情
|
||||
"""
|
||||
return self.get_item(Path(fileitem.path))
|
||||
|
||||
def delete(self, fileitem: schemas.FileItem) -> bool:
|
||||
"""
|
||||
删除文件或目录
|
||||
"""
|
||||
try:
|
||||
self._check_connection()
|
||||
|
||||
smb_path = self._normalize_path(fileitem.path.rstrip("/"))
|
||||
|
||||
if fileitem.type == "dir":
|
||||
# 删除目录
|
||||
smbclient.rmdir(smb_path)
|
||||
else:
|
||||
# 删除文件
|
||||
smbclient.remove(smb_path)
|
||||
|
||||
logger.info(f"【SMB】删除成功: {fileitem.path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"【SMB】删除失败: {e}")
|
||||
return False
|
||||
|
||||
def rename(self, fileitem: schemas.FileItem, name: str) -> bool:
|
||||
"""
|
||||
重命名文件
|
||||
"""
|
||||
try:
|
||||
self._check_connection()
|
||||
|
||||
old_path = self._normalize_path(fileitem.path.rstrip("/"))
|
||||
parent_path = Path(fileitem.path).parent
|
||||
new_path = self._normalize_path(str(parent_path / name))
|
||||
|
||||
# 重命名
|
||||
smbclient.rename(old_path, new_path)
|
||||
|
||||
logger.info(f"【SMB】重命名成功: {fileitem.path} -> {name}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"【SMB】重命名失败: {e}")
|
||||
return False
|
||||
|
||||
def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]:
|
||||
"""
|
||||
下载文件
|
||||
"""
|
||||
try:
|
||||
self._check_connection()
|
||||
|
||||
smb_path = self._normalize_path(fileitem.path)
|
||||
local_path = path or settings.TEMP_PATH / fileitem.name
|
||||
|
||||
# 确保本地目录存在
|
||||
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 使用更高效的文件传输方式
|
||||
with smbclient.open_file(smb_path, mode="rb") as src_file:
|
||||
with open(local_path, "wb") as dst_file:
|
||||
# 使用更大的缓冲区提高性能
|
||||
buffer_size = 1024 * 1024 # 1MB
|
||||
while True:
|
||||
chunk = src_file.read(buffer_size)
|
||||
if not chunk:
|
||||
break
|
||||
dst_file.write(chunk)
|
||||
|
||||
logger.info(f"【SMB】下载成功: {fileitem.path} -> {local_path}")
|
||||
return local_path
|
||||
except Exception as e:
|
||||
logger.error(f"【SMB】下载失败: {e}")
|
||||
return None
|
||||
|
||||
def upload(self, fileitem: schemas.FileItem, path: Path,
|
||||
new_name: Optional[str] = None) -> Optional[schemas.FileItem]:
|
||||
"""
|
||||
上传文件
|
||||
"""
|
||||
try:
|
||||
self._check_connection()
|
||||
|
||||
target_name = new_name or path.name
|
||||
target_path = Path(fileitem.path) / target_name
|
||||
smb_path = self._normalize_path(str(target_path))
|
||||
|
||||
# 使用更高效的文件传输方式
|
||||
with open(path, "rb") as src_file:
|
||||
with smbclient.open_file(smb_path, mode="wb") as dst_file:
|
||||
# 使用更大的缓冲区提高性能
|
||||
buffer_size = 1024 * 1024 # 1MB
|
||||
while True:
|
||||
chunk = src_file.read(buffer_size)
|
||||
if not chunk:
|
||||
break
|
||||
dst_file.write(chunk)
|
||||
|
||||
logger.info(f"【SMB】上传成功: {path} -> {target_path}")
|
||||
|
||||
# 返回上传后的文件信息
|
||||
return self.get_item(target_path)
|
||||
except Exception as e:
|
||||
logger.error(f"【SMB】上传失败: {e}")
|
||||
return None
|
||||
|
||||
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
复制文件
|
||||
"""
|
||||
try:
|
||||
# 下载到临时文件
|
||||
temp_file = self.download(fileitem)
|
||||
if not temp_file:
|
||||
return False
|
||||
|
||||
# 获取目标目录
|
||||
target_folder = self.get_item(path)
|
||||
if not target_folder:
|
||||
return False
|
||||
|
||||
# 上传到目标位置
|
||||
result = self.upload(target_folder, temp_file, new_name)
|
||||
|
||||
# 删除临时文件
|
||||
if temp_file.exists():
|
||||
temp_file.unlink()
|
||||
|
||||
return result is not None
|
||||
except Exception as e:
|
||||
logger.error(f"【SMB】复制失败: {e}")
|
||||
return False
|
||||
|
||||
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
移动文件
|
||||
"""
|
||||
try:
|
||||
# 先复制
|
||||
if not self.copy(fileitem, path, new_name):
|
||||
return False
|
||||
|
||||
# 再删除原文件
|
||||
if not self.delete(fileitem):
|
||||
logger.warn(f"【SMB】删除原文件失败: {fileitem.path}")
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"【SMB】移动失败: {e}")
|
||||
return False
|
||||
|
||||
def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||
pass
|
||||
|
||||
def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||
pass
|
||||
|
||||
def usage(self) -> Optional[schemas.StorageUsage]:
|
||||
"""
|
||||
存储使用情况
|
||||
"""
|
||||
try:
|
||||
self._check_connection()
|
||||
volume_stat = smbclient.stat_volume(self._server_path)
|
||||
return schemas.StorageUsage(
|
||||
total=volume_stat.total_size,
|
||||
available=volume_stat.caller_available_size
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"【SMB】获取存储使用情况失败: {e}")
|
||||
return None
|
||||
|
||||
def __del__(self):
|
||||
"""
|
||||
析构函数,清理连接
|
||||
"""
|
||||
try:
|
||||
# smbclient 自动管理连接池,但我们可以重置缓存
|
||||
if hasattr(self, '_connected') and self._connected:
|
||||
reset_connection_cache()
|
||||
except Exception as e:
|
||||
logger.debug(f"【SMB】清理连接失败: {e}")
|
||||
@@ -5,7 +5,7 @@ import secrets
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Tuple, Union
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
import oss2
|
||||
import requests
|
||||
@@ -51,9 +51,6 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
# 基础url
|
||||
base_url = "https://proapi.115.com"
|
||||
|
||||
# CID和路径缓存
|
||||
_id_cache: Dict[str, str] = {}
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.session = requests.Session()
|
||||
@@ -222,8 +219,11 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
|
||||
# 处理速率限制
|
||||
if resp.status_code == 429:
|
||||
reset_time = int(resp.headers.get("X-RateLimit-Reset", 60))
|
||||
time.sleep(reset_time + 5)
|
||||
reset_time = 5 + int(resp.headers.get("X-RateLimit-Reset", 60))
|
||||
logger.debug(
|
||||
f"【115】{method} 请求 {endpoint} 限流,等待{reset_time}秒后重试"
|
||||
)
|
||||
time.sleep(reset_time)
|
||||
return self._request_api(method, endpoint, result_key, **kwargs)
|
||||
|
||||
# 处理请求错误
|
||||
@@ -238,58 +238,6 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
return ret_data.get(result_key)
|
||||
return ret_data
|
||||
|
||||
def _path_to_id(self, path: str) -> str:
|
||||
"""
|
||||
路径转FID(带缓存机制)
|
||||
"""
|
||||
# 根目录
|
||||
if path == "/":
|
||||
return '0'
|
||||
if len(path) > 1 and path.endswith("/"):
|
||||
path = path[:-1]
|
||||
# 检查缓存
|
||||
if path in self._id_cache:
|
||||
return self._id_cache[path]
|
||||
# 逐级查找缓存
|
||||
current_id = 0
|
||||
parent_path = "/"
|
||||
for p in Path(path).parents:
|
||||
if str(p) in self._id_cache:
|
||||
parent_path = str(p)
|
||||
current_id = self._id_cache[parent_path]
|
||||
break
|
||||
# 计算相对路径
|
||||
rel_path = Path(path).relative_to(parent_path)
|
||||
for part in Path(rel_path).parts:
|
||||
offset = 0
|
||||
find_part = False
|
||||
while True:
|
||||
resp = self._request_api(
|
||||
"GET",
|
||||
"/open/ufile/files",
|
||||
"data",
|
||||
params={"cid": current_id, "limit": 1000, "offset": offset, "cur": True, "show_dir": 1}
|
||||
)
|
||||
if not resp:
|
||||
break
|
||||
for item in resp:
|
||||
if item["fn"] == part:
|
||||
current_id = item["fid"]
|
||||
find_part = True
|
||||
break
|
||||
if find_part:
|
||||
break
|
||||
if len(resp) < 1000:
|
||||
break
|
||||
offset += len(resp)
|
||||
if not find_part:
|
||||
raise FileNotFoundError(f"【115】{path} 不存在")
|
||||
if not current_id:
|
||||
raise FileNotFoundError(f"【115】{path} 不存在")
|
||||
# 缓存路径
|
||||
self._id_cache[path] = str(current_id)
|
||||
return str(current_id)
|
||||
|
||||
@staticmethod
|
||||
def _calc_sha1(filepath: Path, size: Optional[int] = None) -> str:
|
||||
"""
|
||||
@@ -335,7 +283,11 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
else:
|
||||
cid = fileitem.fileid
|
||||
if not cid:
|
||||
cid = self._path_to_id(fileitem.path)
|
||||
_fileitem = self.get_item(Path(fileitem.path))
|
||||
if not _fileitem:
|
||||
logger.warn(f"【115】获取目录 {fileitem.path} 失败!")
|
||||
return []
|
||||
cid = _fileitem.fileid
|
||||
|
||||
items = []
|
||||
offset = 0
|
||||
@@ -354,8 +306,6 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
for item in resp:
|
||||
# 更新缓存
|
||||
path = f"{fileitem.path}{item['fn']}"
|
||||
self._id_cache[path] = str(item["fid"])
|
||||
|
||||
file_path = path + ("/" if item["fc"] == "0" else "")
|
||||
items.append(schemas.FileItem(
|
||||
storage=self.schema.value,
|
||||
@@ -398,8 +348,6 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
return self.get_item(new_path)
|
||||
logger.warn(f"【115】创建目录失败: {resp.get('error')}")
|
||||
return None
|
||||
# 缓存新目录
|
||||
self._id_cache[str(new_path)] = str(resp["data"]["file_id"])
|
||||
return schemas.FileItem(
|
||||
storage=self.schema.value,
|
||||
fileid=str(resp["data"]["file_id"]),
|
||||
@@ -716,13 +664,6 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
if not resp:
|
||||
return False
|
||||
if resp["state"]:
|
||||
if fileitem.path in self._id_cache:
|
||||
del self._id_cache[fileitem.path]
|
||||
for key in list(self._id_cache.keys()):
|
||||
if key.startswith(fileitem.path):
|
||||
del self._id_cache[key]
|
||||
new_path = Path(fileitem.path).parent / name
|
||||
self._id_cache[str(new_path)] = fileitem.fileid
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -731,15 +672,12 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
获取指定路径的文件/目录项
|
||||
"""
|
||||
try:
|
||||
file_id = self._path_to_id(str(path))
|
||||
if not file_id:
|
||||
return None
|
||||
resp = self._request_api(
|
||||
"GET",
|
||||
"POST",
|
||||
"/open/folder/get_info",
|
||||
"data",
|
||||
params={
|
||||
"file_id": int(file_id)
|
||||
data={
|
||||
"path": str(path)
|
||||
}
|
||||
)
|
||||
if not resp:
|
||||
@@ -753,7 +691,7 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
basename=Path(resp["file_name"]).stem,
|
||||
extension=Path(resp["file_name"]).suffix[1:] if resp["file_category"] == "1" else None,
|
||||
pickcode=resp["pick_code"],
|
||||
size=StringUtils.num_filesize(resp['size']) if resp["file_category"] == "1" else None,
|
||||
size=resp['size_byte'] if resp["file_category"] == "1" else None,
|
||||
modify_time=resp["utime"]
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -805,14 +743,17 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
企业级复制实现(支持目录递归复制)
|
||||
"""
|
||||
src_fid = fileitem.fileid
|
||||
dest_cid = self._path_to_id(str(path))
|
||||
dest_fileitem = self.get_item(path)
|
||||
if not dest_fileitem or dest_fileitem.type != "dir":
|
||||
logger.warn(f"【115】目标路径 {path} 不是一个有效的目录!")
|
||||
return False
|
||||
|
||||
resp = self._request_api(
|
||||
"POST",
|
||||
"/open/ufile/copy",
|
||||
data={
|
||||
"file_id": int(src_fid),
|
||||
"pid": int(dest_cid)
|
||||
"pid": int(dest_fileitem.fileid),
|
||||
}
|
||||
)
|
||||
if not resp:
|
||||
@@ -821,10 +762,6 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
new_path = Path(path) / fileitem.name
|
||||
new_item = self._delay_get_item(new_path)
|
||||
self.rename(new_item, new_name)
|
||||
# 更新缓存
|
||||
del self._id_cache[fileitem.path]
|
||||
rename_new_path = Path(path) / new_name
|
||||
self._id_cache[str(rename_new_path)] = new_item.fileid
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -833,14 +770,16 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
原子性移动操作实现
|
||||
"""
|
||||
src_fid = fileitem.fileid
|
||||
dest_cid = self._path_to_id(str(path))
|
||||
|
||||
dest_fileitem = self.get_item(path)
|
||||
if not dest_fileitem or dest_fileitem.type != "dir":
|
||||
logger.warn(f"【115】目标路径 {path} 不是一个有效的目录!")
|
||||
return False
|
||||
resp = self._request_api(
|
||||
"POST",
|
||||
"/open/ufile/move",
|
||||
data={
|
||||
"file_ids": int(src_fid),
|
||||
"to_cid": int(dest_cid)
|
||||
"to_cid": int(dest_fileitem.fileid),
|
||||
}
|
||||
)
|
||||
if not resp:
|
||||
@@ -849,10 +788,6 @@ class U115Pan(StorageBase, metaclass=Singleton):
|
||||
new_path = Path(path) / fileitem.name
|
||||
new_file = self._delay_get_item(new_path)
|
||||
self.rename(new_file, new_name)
|
||||
# 更新缓存
|
||||
del self._id_cache[fileitem.path]
|
||||
rename_new_path = Path(path) / new_name
|
||||
self._id_cache[str(rename_new_path)] = src_fid
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ class RuleParser:
|
||||
if __name__ == '__main__':
|
||||
# 测试代码
|
||||
expression_str = """
|
||||
SPECSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & 60FPS & !DOLBY & !SDR & !3D > CNSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & 60FPS & !DOLBY & !SDR & !3D > SPECSUB & 4K & !BLU & !REMUX & !WEBDL & 60FPS & !DOLBY & !SDR & !3D > CNSUB & 4K & !BLU & !REMUX & !WEBDL & 60FPS & !DOLBY & !SDR & !3D > SPECSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > CNSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > CNSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > SPECSUB & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > CNSUB & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > CNSUB & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > SPECSUB & CNVOI & 4K & WEBDL & 60FPS & !DOLBY & !SDR & !3D > CNSUB & CNVOI & 4K & WEBDL & 60FPS & !DOLBY & !SDR & !3D > SPECSUB & 4K & WEBDL & 60FPS & !DOLBY & !SDR & !3D > CNSUB & 4K & WEBDL & 60FPS & !DOLBY & !SDR & !3D > SPECSUB & CNVOI & 4K & WEBDL & !DOLBY & HDR & !3D > CNSUB & CNVOI & 4K & WEBDL & !DOLBY & HDR & !3D > SPECSUB & CNVOI & 4K & WEBDL & !DOLBY & !3D > CNSUB & CNVOI & 4K & WEBDL & !DOLBY & !3D > SPECSUB & 4K & WEBDL & !DOLBY & HDR & !3D > CNSUB & 4K & WEBDL & !DOLBY & HDR & !3D > SPECSUB & 4K & WEBDL & !DOLBY & !3D > CNSUB & 4K & WEBDL & !DOLBY & !3D > SPECSUB & CNVOI & 4K & !BLU & !WEBDL & !DOLBY & HDR & !3D > CNSUB & CNVOI & 4K & !BLU & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & CNVOI & 4K & !BLU & !WEBDL & !DOLBY & !3D > CNSUB & CNVOI & 4K & !BLU & !WEBDL & !DOLBY & !3D > SPECSUB & 4K & !BLU & !WEBDL & !DOLBY & HDR & !3D > CNSUB & 4K & !BLU & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & 4K & !BLU & !WEBDL & !DOLBY & !SDR & !3D > CNSUB & 4K & !BLU & !WEBDL & !DOLBY & !SDR & !3D > 4K & !BLU & !REMUX & !DOLBY & HDR & !3D > 4K & !BLURAY & !REMUX & !DOLBY & !3D > SPECSUB & 1080P & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > CNSUB & 1080P & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & 1080P & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > CNSUB & 1080P & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > SPECSUB & 1080P & !BLU & !WEBDL & !DOLBY & HDR & !3D > CNSUB & 1080P & !BLU & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & 1080P & !BLU & !WEBDL & !DOLBY & !3D > CNSUB & 1080P & !BLU & !WEBDL & !DOLBY & !3D > SPECSUB & 1080P & WEBDL & !DOLBY & HDR & !3D > CNSUB & 1080P & WEBDL & !DOLBY & HDR & !3D > SPECSUB & 1080P & WEBDL & !DOLBY & !3D > CNSUB & 1080P & WEBDL & !DOLBY & !3D > 1080P & !BLU & !REMUX & !DOLBY & HDR & !3D > 1080P & !BLU & !REMUX & !DOLBY & !3D
|
||||
SPECSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & 60FPS & !DOLBY & !SDR & !3D > CNSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & 60FPS & !DOLBY & !SDR & !3D > SPECSUB & 4K & !BLU & !REMUX & !WEBDL & 60FPS & !DOLBY & !SDR & !3D > CNSUB & 4K & !BLU & !REMUX & !WEBDL & 60FPS & !DOLBY & !SDR & !3D > SPECSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > CNSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > CNSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > SPECSUB & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > CNSUB & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > CNSUB & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > SPECSUB & CNVOI & 4K & WEBDL & 60FPS & !DOLBY & !SDR & !3D > CNSUB & CNVOI & 4K & WEBDL & 60FPS & !DOLBY & !SDR & !3D > SPECSUB & 4K & WEBDL & 60FPS & !DOLBY & !SDR & !3D > CNSUB & 4K & WEBDL & 60FPS & !DOLBY & !SDR & !3D > SPECSUB & CNVOI & 4K & WEBDL & !DOLBY & HDR & !3D > CNSUB & CNVOI & 4K & WEBDL & !DOLBY & HDR & !3D > SPECSUB & CNVOI & 4K & WEBDL & !DOLBY & !3D > CNSUB & CNVOI & 4K & WEBDL & !DOLBY & !3D > SPECSUB & 4K & WEBDL & !DOLBY & HDR & !3D > CNSUB & 4K & WEBDL & !DOLBY & HDR & !3D > SPECSUB & 4K & WEBDL & !DOLBY & !3D > CNSUB & 4K & WEBDL & !DOLBY & !3D > SPECSUB & CNVOI & 4K & !BLU & !WEBDL & !DOLBY & HDR & !3D > CNSUB & CNVOI & 4K & !BLU & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & CNVOI & 4K & !BLU & !WEBDL & !DOLBY & !3D > CNSUB & CNVOI & 4K & !BLU & !WEBDL & !DOLBY & !3D > SPECSUB & 4K & !BLU & !WEBDL & !DOLBY & HDR & !3D > CNSUB & 4K & !BLU & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & 4K & !BLU & !WEBDL & !DOLBY & !SDR & !3D > CNSUB & 4K & !BLU & !WEBDL & !DOLBY & !SDR & !3D > 4K & !BLU & !REMUX & !DOLBY & HDR & !3D > 4K & !BLURAY & !REMUX & !DOLBY & !3D > SPECSUB & 1080P & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > CNSUB & 1080P & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & 1080P & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > CNSUB & 1080P & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > SPECSUB & 1080P & !BLU & !WEBDL & !DOLBY & HDR & !3D > CNSUB & 1080P & !BLU & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & 1080P & !BLU & !WEBDL & !DOLBY & !3D > CNSUB & 1080P & !BLU & !WEBDL & !DOLBY & !3D > SPECSUB & 1080P & WEBDL & !DOLBY & HDR & !3D > CNSUB & 1080P & WEBDL & !DOLBY & HDR & !3D > SPECSUB & 1080P & WEBDL & !DOLBY & !3D > CNSUB & 1080P & WEBDL & !DOLBY & !3D > 1080P & !BLU & !REMUX & !DOLBY & HDR & !3D > 1080P & !BLU & !REMUX & !DOLBY & !3D
|
||||
"""
|
||||
for exp in expression_str.split('>'):
|
||||
parsed_expr = RuleParser().parse(exp.strip())
|
||||
|
||||
@@ -5,10 +5,11 @@ from app.core.config import settings
|
||||
from app.core.context import TorrentInfo
|
||||
from app.db.site_oper import SiteOper
|
||||
from app.helper.module import ModuleHelper
|
||||
from app.helper.sites import SitesHelper, SiteSpider
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.modules.indexer.parser import SiteParserBase
|
||||
from app.modules.indexer.spider import SiteSpider
|
||||
from app.modules.indexer.spider.haidan import HaiDanSpider
|
||||
from app.modules.indexer.spider.hddolby import HddolbySpider
|
||||
from app.modules.indexer.spider.mtorrent import MTorrentSpider
|
||||
@@ -286,26 +287,29 @@ class IndexerModule(_ModuleBase):
|
||||
return None
|
||||
|
||||
# 获取用户数据
|
||||
logger.info(f"站点 {site.get('name')} 开始以 {site.get('schema')} 模型解析数据...")
|
||||
site_obj.parse()
|
||||
logger.debug(f"站点 {site.get('name')} 数据解析完成")
|
||||
return SiteUserData(
|
||||
domain=StringUtils.get_url_domain(site.get("url")),
|
||||
userid=site_obj.userid,
|
||||
username=site_obj.username,
|
||||
user_level=site_obj.user_level,
|
||||
join_at=site_obj.join_at,
|
||||
upload=site_obj.upload,
|
||||
download=site_obj.download,
|
||||
ratio=site_obj.ratio,
|
||||
bonus=site_obj.bonus,
|
||||
seeding=site_obj.seeding,
|
||||
seeding_size=site_obj.seeding_size,
|
||||
seeding_info=site_obj.seeding_info or [],
|
||||
leeching=site_obj.leeching,
|
||||
leeching_size=site_obj.leeching_size,
|
||||
message_unread=site_obj.message_unread,
|
||||
message_unread_contents=site_obj.message_unread_contents or [],
|
||||
updated_day=datetime.now().strftime('%Y-%m-%d'),
|
||||
err_msg=site_obj.err_msg
|
||||
)
|
||||
try:
|
||||
logger.info(f"站点 {site.get('name')} 开始以 {site.get('schema')} 模型解析数据...")
|
||||
site_obj.parse()
|
||||
logger.debug(f"站点 {site.get('name')} 数据解析完成")
|
||||
return SiteUserData(
|
||||
domain=StringUtils.get_url_domain(site.get("url")),
|
||||
userid=site_obj.userid,
|
||||
username=site_obj.username,
|
||||
user_level=site_obj.user_level,
|
||||
join_at=site_obj.join_at,
|
||||
upload=site_obj.upload,
|
||||
download=site_obj.download,
|
||||
ratio=site_obj.ratio,
|
||||
bonus=site_obj.bonus,
|
||||
seeding=site_obj.seeding,
|
||||
seeding_size=site_obj.seeding_size,
|
||||
seeding_info=site_obj.seeding_info.copy() if site_obj.seeding_info else [],
|
||||
leeching=site_obj.leeching,
|
||||
leeching_size=site_obj.leeching_size,
|
||||
message_unread=site_obj.message_unread,
|
||||
message_unread_contents=site_obj.message_unread_contents.copy() if site_obj.message_unread_contents else [],
|
||||
updated_day=datetime.now().strftime('%Y-%m-%d'),
|
||||
err_msg=site_obj.err_msg
|
||||
)
|
||||
finally:
|
||||
site_obj.clear()
|
||||
|
||||
@@ -156,53 +156,57 @@ class SiteParserBase(metaclass=ABCMeta):
|
||||
解析站点信息
|
||||
:return:
|
||||
"""
|
||||
# Cookie模式时,获取站点首页html
|
||||
if self.request_mode == "apikey":
|
||||
if not self.apikey and not self.token:
|
||||
logger.warn(f"{self._site_name} 未设置cookie 或 apikey/token,跳过后续操作")
|
||||
return
|
||||
self._index_html = {}
|
||||
else:
|
||||
# 检查是否已经登录
|
||||
self._index_html = self._get_page_content(url=self._site_url)
|
||||
if not self._parse_logged_in(self._index_html):
|
||||
return
|
||||
# 解析站点页面
|
||||
self._parse_site_page(self._index_html)
|
||||
# 解析用户基础信息
|
||||
if self._user_basic_page:
|
||||
self._parse_user_base_info(
|
||||
self._get_page_content(
|
||||
url=urljoin(self._base_url, self._user_basic_page),
|
||||
params=self._user_basic_params,
|
||||
headers=self._user_basic_headers
|
||||
try:
|
||||
# Cookie模式时,获取站点首页html
|
||||
if self.request_mode == "apikey":
|
||||
if not self.apikey and not self.token:
|
||||
logger.warn(f"{self._site_name} 未设置cookie 或 apikey/token,跳过后续操作")
|
||||
return
|
||||
self._index_html = {}
|
||||
else:
|
||||
# 检查是否已经登录
|
||||
self._index_html = self._get_page_content(url=self._site_url)
|
||||
if not self._parse_logged_in(self._index_html):
|
||||
return
|
||||
# 解析站点页面
|
||||
self._parse_site_page(self._index_html)
|
||||
# 解析用户基础信息
|
||||
if self._user_basic_page:
|
||||
self._parse_user_base_info(
|
||||
self._get_page_content(
|
||||
url=urljoin(self._base_url, self._user_basic_page),
|
||||
params=self._user_basic_params,
|
||||
headers=self._user_basic_headers
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
self._parse_user_base_info(self._index_html)
|
||||
# 解析用户详细信息
|
||||
if self._user_detail_page:
|
||||
self._parse_user_detail_info(
|
||||
self._get_page_content(
|
||||
url=urljoin(self._base_url, self._user_detail_page),
|
||||
params=self._user_detail_params,
|
||||
headers=self._user_detail_headers
|
||||
else:
|
||||
self._parse_user_base_info(self._index_html)
|
||||
# 解析用户详细信息
|
||||
if self._user_detail_page:
|
||||
self._parse_user_detail_info(
|
||||
self._get_page_content(
|
||||
url=urljoin(self._base_url, self._user_detail_page),
|
||||
params=self._user_detail_params,
|
||||
headers=self._user_detail_headers
|
||||
)
|
||||
)
|
||||
)
|
||||
# 解析用户未读消息
|
||||
if settings.SITE_MESSAGE:
|
||||
self._pase_unread_msgs()
|
||||
# 解析用户上传、下载、分享率等信息
|
||||
if self._user_traffic_page:
|
||||
self._parse_user_traffic_info(
|
||||
self._get_page_content(
|
||||
url=urljoin(self._base_url, self._user_traffic_page),
|
||||
params=self._user_traffic_params,
|
||||
headers=self._user_traffic_headers
|
||||
# 解析用户未读消息
|
||||
if settings.SITE_MESSAGE:
|
||||
self._pase_unread_msgs()
|
||||
# 解析用户上传、下载、分享率等信息
|
||||
if self._user_traffic_page:
|
||||
self._parse_user_traffic_info(
|
||||
self._get_page_content(
|
||||
url=urljoin(self._base_url, self._user_traffic_page),
|
||||
params=self._user_traffic_params,
|
||||
headers=self._user_traffic_headers
|
||||
)
|
||||
)
|
||||
)
|
||||
# 解析用户做种信息
|
||||
self._parse_seeding_pages()
|
||||
# 解析用户做种信息
|
||||
self._parse_seeding_pages()
|
||||
finally:
|
||||
# 关闭连接
|
||||
self.close()
|
||||
|
||||
def _pase_unread_msgs(self):
|
||||
"""
|
||||
@@ -430,6 +434,22 @@ class SiteParserBase(metaclass=ABCMeta):
|
||||
"""
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
关闭会话
|
||||
"""
|
||||
if self._session:
|
||||
self._session.close()
|
||||
self._session = None
|
||||
|
||||
def clear(self):
|
||||
"""
|
||||
清除当前解析器的所有信息
|
||||
"""
|
||||
self._index_html = ""
|
||||
self.seeding_info.clear()
|
||||
self.message_unread_contents.clear()
|
||||
|
||||
def to_dict(self):
|
||||
"""
|
||||
转化为字典
|
||||
|
||||
@@ -14,15 +14,18 @@ class DiscuzUserInfo(SiteParserBase):
|
||||
def _parse_user_base_info(self, html_text: str):
|
||||
html_text = self._prepare_html_text(html_text)
|
||||
html = etree.HTML(html_text)
|
||||
|
||||
user_info = html.xpath('//a[contains(@href, "&uid=")]')
|
||||
if user_info:
|
||||
user_id_match = re.search(r"&uid=(\d+)", user_info[0].attrib['href'])
|
||||
if user_id_match and user_id_match.group().strip():
|
||||
self.userid = user_id_match.group(1)
|
||||
self._torrent_seeding_page = f"forum.php?&mod=torrents&cat_5up=on"
|
||||
self._user_detail_page = user_info[0].attrib['href']
|
||||
self.username = user_info[0].text.strip()
|
||||
try:
|
||||
user_info = html.xpath('//a[contains(@href, "&uid=")]')
|
||||
if user_info:
|
||||
user_id_match = re.search(r"&uid=(\d+)", user_info[0].attrib['href'])
|
||||
if user_id_match and user_id_match.group().strip():
|
||||
self.userid = user_id_match.group(1)
|
||||
self._torrent_seeding_page = f"forum.php?&mod=torrents&cat_5up=on"
|
||||
self._user_detail_page = user_info[0].attrib['href']
|
||||
self.username = user_info[0].text.strip()
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
def _parse_site_page(self, html_text: str):
|
||||
pass
|
||||
@@ -34,40 +37,44 @@ class DiscuzUserInfo(SiteParserBase):
|
||||
:return:
|
||||
"""
|
||||
html = etree.HTML(html_text)
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return None
|
||||
try:
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return None
|
||||
|
||||
# 用户等级
|
||||
user_levels_text = html.xpath('//a[contains(@href, "usergroup")]/text()')
|
||||
if user_levels_text:
|
||||
self.user_level = user_levels_text[-1].strip()
|
||||
# 用户等级
|
||||
user_levels_text = html.xpath('//a[contains(@href, "usergroup")]/text()')
|
||||
if user_levels_text:
|
||||
self.user_level = user_levels_text[-1].strip()
|
||||
|
||||
# 加入日期
|
||||
join_at_text = html.xpath('//li[em[text()="注册时间"]]/text()')
|
||||
if join_at_text:
|
||||
self.join_at = StringUtils.unify_datetime_str(join_at_text[0].strip())
|
||||
# 加入日期
|
||||
join_at_text = html.xpath('//li[em[text()="注册时间"]]/text()')
|
||||
if join_at_text:
|
||||
self.join_at = StringUtils.unify_datetime_str(join_at_text[0].strip())
|
||||
|
||||
# 分享率
|
||||
ratio_text = html.xpath('//li[contains(.//text(), "分享率")]//text()')
|
||||
if ratio_text:
|
||||
ratio_match = re.search(r"\(([\d,.]+)\)", ratio_text[0])
|
||||
if ratio_match and ratio_match.group(1).strip():
|
||||
self.bonus = StringUtils.str_float(ratio_match.group(1))
|
||||
# 分享率
|
||||
ratio_text = html.xpath('//li[contains(.//text(), "分享率")]//text()')
|
||||
if ratio_text:
|
||||
ratio_match = re.search(r"\(([\d,.]+)\)", ratio_text[0])
|
||||
if ratio_match and ratio_match.group(1).strip():
|
||||
self.bonus = StringUtils.str_float(ratio_match.group(1))
|
||||
|
||||
# 积分
|
||||
bouns_text = html.xpath('//li[em[text()="积分"]]/text()')
|
||||
if bouns_text:
|
||||
self.bonus = StringUtils.str_float(bouns_text[0].strip())
|
||||
# 积分
|
||||
bouns_text = html.xpath('//li[em[text()="积分"]]/text()')
|
||||
if bouns_text:
|
||||
self.bonus = StringUtils.str_float(bouns_text[0].strip())
|
||||
|
||||
# 上传
|
||||
upload_text = html.xpath('//li[em[contains(text(),"上传量")]]/text()')
|
||||
if upload_text:
|
||||
self.upload = StringUtils.num_filesize(upload_text[0].strip().split('/')[-1])
|
||||
# 上传
|
||||
upload_text = html.xpath('//li[em[contains(text(),"上传量")]]/text()')
|
||||
if upload_text:
|
||||
self.upload = StringUtils.num_filesize(upload_text[0].strip().split('/')[-1])
|
||||
|
||||
# 下载
|
||||
download_text = html.xpath('//li[em[contains(text(),"下载量")]]/text()')
|
||||
if download_text:
|
||||
self.download = StringUtils.num_filesize(download_text[0].strip().split('/')[-1])
|
||||
# 下载
|
||||
download_text = html.xpath('//li[em[contains(text(),"下载量")]]/text()')
|
||||
if download_text:
|
||||
self.download = StringUtils.num_filesize(download_text[0].strip().split('/')[-1])
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]:
|
||||
"""
|
||||
@@ -77,44 +84,48 @@ class DiscuzUserInfo(SiteParserBase):
|
||||
:return: 下页地址
|
||||
"""
|
||||
html = etree.HTML(html_text)
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return None
|
||||
try:
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return None
|
||||
|
||||
size_col = 3
|
||||
seeders_col = 4
|
||||
# 搜索size列
|
||||
if html.xpath('//tr[position()=1]/td[.//img[@class="size"] and .//img[@alt="size"]]'):
|
||||
size_col = len(html.xpath('//tr[position()=1]/td[.//img[@class="size"] '
|
||||
'and .//img[@alt="size"]]/preceding-sibling::td')) + 1
|
||||
# 搜索seeders列
|
||||
if html.xpath('//tr[position()=1]/td[.//img[@class="seeders"] and .//img[@alt="seeders"]]'):
|
||||
seeders_col = len(html.xpath('//tr[position()=1]/td[.//img[@class="seeders"] '
|
||||
'and .//img[@alt="seeders"]]/preceding-sibling::td')) + 1
|
||||
size_col = 3
|
||||
seeders_col = 4
|
||||
# 搜索size列
|
||||
if html.xpath('//tr[position()=1]/td[.//img[@class="size"] and .//img[@alt="size"]]'):
|
||||
size_col = len(html.xpath('//tr[position()=1]/td[.//img[@class="size"] '
|
||||
'and .//img[@alt="size"]]/preceding-sibling::td')) + 1
|
||||
# 搜索seeders列
|
||||
if html.xpath('//tr[position()=1]/td[.//img[@class="seeders"] and .//img[@alt="seeders"]]'):
|
||||
seeders_col = len(html.xpath('//tr[position()=1]/td[.//img[@class="seeders"] '
|
||||
'and .//img[@alt="seeders"]]/preceding-sibling::td')) + 1
|
||||
|
||||
page_seeding = 0
|
||||
page_seeding_size = 0
|
||||
page_seeding_info = []
|
||||
seeding_sizes = html.xpath(f'//tr[position()>1]/td[{size_col}]')
|
||||
seeding_seeders = html.xpath(f'//tr[position()>1]/td[{seeders_col}]//text()')
|
||||
if seeding_sizes and seeding_seeders:
|
||||
page_seeding = len(seeding_sizes)
|
||||
page_seeding = 0
|
||||
page_seeding_size = 0
|
||||
page_seeding_info = []
|
||||
seeding_sizes = html.xpath(f'//tr[position()>1]/td[{size_col}]')
|
||||
seeding_seeders = html.xpath(f'//tr[position()>1]/td[{seeders_col}]//text()')
|
||||
if seeding_sizes and seeding_seeders:
|
||||
page_seeding = len(seeding_sizes)
|
||||
|
||||
for i in range(0, len(seeding_sizes)):
|
||||
size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip())
|
||||
seeders = StringUtils.str_int(seeding_seeders[i])
|
||||
for i in range(0, len(seeding_sizes)):
|
||||
size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip())
|
||||
seeders = StringUtils.str_int(seeding_seeders[i])
|
||||
|
||||
page_seeding_size += size
|
||||
page_seeding_info.append([seeders, size])
|
||||
page_seeding_size += size
|
||||
page_seeding_info.append([seeders, size])
|
||||
|
||||
self.seeding += page_seeding
|
||||
self.seeding_size += page_seeding_size
|
||||
self.seeding_info.extend(page_seeding_info)
|
||||
self.seeding += page_seeding
|
||||
self.seeding_size += page_seeding_size
|
||||
self.seeding_info.extend(page_seeding_info)
|
||||
|
||||
# 是否存在下页数据
|
||||
next_page = None
|
||||
next_page_text = html.xpath('//a[contains(.//text(), "下一页") or contains(.//text(), "下一頁")]/@href')
|
||||
if next_page_text:
|
||||
next_page = next_page_text[-1].strip()
|
||||
# 是否存在下页数据
|
||||
next_page = None
|
||||
next_page_text = html.xpath('//a[contains(.//text(), "下一页") or contains(.//text(), "下一頁")]/@href')
|
||||
if next_page_text:
|
||||
next_page = next_page_text[-1].strip()
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
return next_page
|
||||
|
||||
|
||||
@@ -24,10 +24,13 @@ class FileListSiteUserInfo(SiteParserBase):
|
||||
def _parse_user_base_info(self, html_text: str):
|
||||
html_text = self._prepare_html_text(html_text)
|
||||
html = etree.HTML(html_text)
|
||||
|
||||
ret = html.xpath(f'//a[contains(@href, "userdetails") and contains(@href, "{self.userid}")]//text()')
|
||||
if ret:
|
||||
self.username = str(ret[0])
|
||||
try:
|
||||
ret = html.xpath(f'//a[contains(@href, "userdetails") and contains(@href, "{self.userid}")]//text()')
|
||||
if ret:
|
||||
self.username = str(ret[0])
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
def _parse_user_traffic_info(self, html_text: str):
|
||||
"""
|
||||
@@ -40,39 +43,41 @@ class FileListSiteUserInfo(SiteParserBase):
|
||||
def _parse_user_detail_info(self, html_text: str):
|
||||
html_text = self._prepare_html_text(html_text)
|
||||
html = etree.HTML(html_text)
|
||||
try:
|
||||
upload_html = html.xpath('//table//tr/td[text()="Uploaded"]/following-sibling::td//text()')
|
||||
if upload_html:
|
||||
self.upload = StringUtils.num_filesize(upload_html[0])
|
||||
download_html = html.xpath('//table//tr/td[text()="Downloaded"]/following-sibling::td//text()')
|
||||
if download_html:
|
||||
self.download = StringUtils.num_filesize(download_html[0])
|
||||
|
||||
upload_html = html.xpath('//table//tr/td[text()="Uploaded"]/following-sibling::td//text()')
|
||||
if upload_html:
|
||||
self.upload = StringUtils.num_filesize(upload_html[0])
|
||||
download_html = html.xpath('//table//tr/td[text()="Downloaded"]/following-sibling::td//text()')
|
||||
if download_html:
|
||||
self.download = StringUtils.num_filesize(download_html[0])
|
||||
ratio_html = html.xpath('//table//tr/td[text()="Share ratio"]/following-sibling::td//text()')
|
||||
if ratio_html:
|
||||
share_ratio = StringUtils.str_float(ratio_html[0])
|
||||
else:
|
||||
share_ratio = 0
|
||||
self.ratio = 0 if self.download == 0 else share_ratio
|
||||
|
||||
ratio_html = html.xpath('//table//tr/td[text()="Share ratio"]/following-sibling::td//text()')
|
||||
if ratio_html:
|
||||
share_ratio = StringUtils.str_float(ratio_html[0])
|
||||
else:
|
||||
share_ratio = 0
|
||||
self.ratio = 0 if self.download == 0 else share_ratio
|
||||
seed_html = html.xpath('//table//tr/td[text()="Seed bonus"]/following-sibling::td//text()')
|
||||
if seed_html:
|
||||
self.seeding = StringUtils.str_int(seed_html[1])
|
||||
self.seeding_size = StringUtils.num_filesize(seed_html[3])
|
||||
|
||||
seed_html = html.xpath('//table//tr/td[text()="Seed bonus"]/following-sibling::td//text()')
|
||||
if seed_html:
|
||||
self.seeding = StringUtils.str_int(seed_html[1])
|
||||
self.seeding_size = StringUtils.num_filesize(seed_html[3])
|
||||
user_level_html = html.xpath('//table//tr/td[text()="Class"]/following-sibling::td//text()')
|
||||
if user_level_html:
|
||||
self.user_level = user_level_html[0].strip()
|
||||
|
||||
user_level_html = html.xpath('//table//tr/td[text()="Class"]/following-sibling::td//text()')
|
||||
if user_level_html:
|
||||
self.user_level = user_level_html[0].strip()
|
||||
join_at_html = html.xpath('//table//tr/td[contains(text(), "Join")]/following-sibling::td//text()')
|
||||
if join_at_html:
|
||||
join_at = (join_at_html[0].split("("))[0].strip()
|
||||
self.join_at = StringUtils.unify_datetime_str(join_at)
|
||||
|
||||
join_at_html = html.xpath('//table//tr/td[contains(text(), "Join")]/following-sibling::td//text()')
|
||||
if join_at_html:
|
||||
join_at = (join_at_html[0].split("("))[0].strip()
|
||||
self.join_at = StringUtils.unify_datetime_str(join_at)
|
||||
|
||||
bonus_html = html.xpath('//a[contains(@href, "shop.php")]')
|
||||
if bonus_html:
|
||||
self.bonus = StringUtils.str_float(bonus_html[0].xpath("string(.)").strip())
|
||||
pass
|
||||
bonus_html = html.xpath('//a[contains(@href, "shop.php")]')
|
||||
if bonus_html:
|
||||
self.bonus = StringUtils.str_float(bonus_html[0].xpath("string(.)").strip())
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: Optional[bool] = False) -> Optional[str]:
|
||||
"""
|
||||
@@ -82,28 +87,32 @@ class FileListSiteUserInfo(SiteParserBase):
|
||||
:return: 下页地址
|
||||
"""
|
||||
html = etree.HTML(html_text)
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return None
|
||||
try:
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return None
|
||||
|
||||
size_col = 6
|
||||
seeders_col = 7
|
||||
size_col = 6
|
||||
seeders_col = 7
|
||||
|
||||
page_seeding_size = 0
|
||||
page_seeding_info = []
|
||||
seeding_sizes = html.xpath(f'//table/tr[position()>1]/td[{size_col}]')
|
||||
seeding_seeders = html.xpath(f'//table/tr[position()>1]/td[{seeders_col}]')
|
||||
if seeding_sizes and seeding_seeders:
|
||||
for i in range(0, len(seeding_sizes)):
|
||||
size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip())
|
||||
seeders = StringUtils.str_int(seeding_seeders[i].xpath("string(.)").strip())
|
||||
page_seeding_size = 0
|
||||
page_seeding_info = []
|
||||
seeding_sizes = html.xpath(f'//table/tr[position()>1]/td[{size_col}]')
|
||||
seeding_seeders = html.xpath(f'//table/tr[position()>1]/td[{seeders_col}]')
|
||||
if seeding_sizes and seeding_seeders:
|
||||
for i in range(0, len(seeding_sizes)):
|
||||
size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip())
|
||||
seeders = StringUtils.str_int(seeding_seeders[i].xpath("string(.)").strip())
|
||||
|
||||
page_seeding_size += size
|
||||
page_seeding_info.append([seeders, size])
|
||||
page_seeding_size += size
|
||||
page_seeding_info.append([seeders, size])
|
||||
|
||||
self.seeding_info.extend(page_seeding_info)
|
||||
self.seeding_info.extend(page_seeding_info)
|
||||
|
||||
# 是否存在下页数据
|
||||
next_page = None
|
||||
# 是否存在下页数据
|
||||
next_page = None
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
return next_page
|
||||
|
||||
|
||||
@@ -14,46 +14,49 @@ class GazelleSiteUserInfo(SiteParserBase):
|
||||
def _parse_user_base_info(self, html_text: str):
|
||||
html_text = self._prepare_html_text(html_text)
|
||||
html = etree.HTML(html_text)
|
||||
try:
|
||||
tmps = html.xpath('//a[contains(@href, "user.php?id=")]')
|
||||
if tmps:
|
||||
user_id_match = re.search(r"user.php\?id=(\d+)", tmps[0].attrib['href'])
|
||||
if user_id_match and user_id_match.group().strip():
|
||||
self.userid = user_id_match.group(1)
|
||||
self._torrent_seeding_page = f"torrents.php?type=seeding&userid={self.userid}"
|
||||
self._user_detail_page = f"user.php?id={self.userid}"
|
||||
self.username = tmps[0].text.strip()
|
||||
|
||||
tmps = html.xpath('//a[contains(@href, "user.php?id=")]')
|
||||
if tmps:
|
||||
user_id_match = re.search(r"user.php\?id=(\d+)", tmps[0].attrib['href'])
|
||||
if user_id_match and user_id_match.group().strip():
|
||||
self.userid = user_id_match.group(1)
|
||||
self._torrent_seeding_page = f"torrents.php?type=seeding&userid={self.userid}"
|
||||
self._user_detail_page = f"user.php?id={self.userid}"
|
||||
self.username = tmps[0].text.strip()
|
||||
|
||||
tmps = html.xpath('//*[@id="header-uploaded-value"]/@data-value')
|
||||
if tmps:
|
||||
self.upload = StringUtils.num_filesize(tmps[0])
|
||||
else:
|
||||
tmps = html.xpath('//li[@id="stats_seeding"]/span/text()')
|
||||
tmps = html.xpath('//*[@id="header-uploaded-value"]/@data-value')
|
||||
if tmps:
|
||||
self.upload = StringUtils.num_filesize(tmps[0])
|
||||
else:
|
||||
tmps = html.xpath('//li[@id="stats_seeding"]/span/text()')
|
||||
if tmps:
|
||||
self.upload = StringUtils.num_filesize(tmps[0])
|
||||
|
||||
tmps = html.xpath('//*[@id="header-downloaded-value"]/@data-value')
|
||||
if tmps:
|
||||
self.download = StringUtils.num_filesize(tmps[0])
|
||||
else:
|
||||
tmps = html.xpath('//li[@id="stats_leeching"]/span/text()')
|
||||
tmps = html.xpath('//*[@id="header-downloaded-value"]/@data-value')
|
||||
if tmps:
|
||||
self.download = StringUtils.num_filesize(tmps[0])
|
||||
else:
|
||||
tmps = html.xpath('//li[@id="stats_leeching"]/span/text()')
|
||||
if tmps:
|
||||
self.download = StringUtils.num_filesize(tmps[0])
|
||||
|
||||
self.ratio = 0.0 if self.download <= 0.0 else round(self.upload / self.download, 3)
|
||||
self.ratio = 0.0 if self.download <= 0.0 else round(self.upload / self.download, 3)
|
||||
|
||||
tmps = html.xpath('//a[contains(@href, "bonus.php")]/@data-tooltip')
|
||||
if tmps:
|
||||
bonus_match = re.search(r"([\d,.]+)", tmps[0])
|
||||
if bonus_match and bonus_match.group(1).strip():
|
||||
self.bonus = StringUtils.str_float(bonus_match.group(1))
|
||||
else:
|
||||
tmps = html.xpath('//a[contains(@href, "bonus.php")]')
|
||||
tmps = html.xpath('//a[contains(@href, "bonus.php")]/@data-tooltip')
|
||||
if tmps:
|
||||
bonus_text = tmps[0].xpath("string(.)")
|
||||
bonus_match = re.search(r"([\d,.]+)", bonus_text)
|
||||
bonus_match = re.search(r"([\d,.]+)", tmps[0])
|
||||
if bonus_match and bonus_match.group(1).strip():
|
||||
self.bonus = StringUtils.str_float(bonus_match.group(1))
|
||||
else:
|
||||
tmps = html.xpath('//a[contains(@href, "bonus.php")]')
|
||||
if tmps:
|
||||
bonus_text = tmps[0].xpath("string(.)")
|
||||
bonus_match = re.search(r"([\d,.]+)", bonus_text)
|
||||
if bonus_match and bonus_match.group(1).strip():
|
||||
self.bonus = StringUtils.str_float(bonus_match.group(1))
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
def _parse_site_page(self, html_text: str):
|
||||
pass
|
||||
@@ -65,27 +68,31 @@ class GazelleSiteUserInfo(SiteParserBase):
|
||||
:return:
|
||||
"""
|
||||
html = etree.HTML(html_text)
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return None
|
||||
try:
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return None
|
||||
|
||||
# 用户等级
|
||||
user_levels_text = html.xpath('//*[@id="class-value"]/@data-value')
|
||||
if user_levels_text:
|
||||
self.user_level = user_levels_text[0].strip()
|
||||
else:
|
||||
user_levels_text = html.xpath('//li[contains(text(), "用户等级")]/text()')
|
||||
# 用户等级
|
||||
user_levels_text = html.xpath('//*[@id="class-value"]/@data-value')
|
||||
if user_levels_text:
|
||||
self.user_level = user_levels_text[0].split(':')[1].strip()
|
||||
self.user_level = user_levels_text[0].strip()
|
||||
else:
|
||||
user_levels_text = html.xpath('//li[contains(text(), "用户等级")]/text()')
|
||||
if user_levels_text:
|
||||
self.user_level = user_levels_text[0].split(':')[1].strip()
|
||||
|
||||
# 加入日期
|
||||
join_at_text = html.xpath('//*[@id="join-date-value"]/@data-value')
|
||||
if join_at_text:
|
||||
self.join_at = StringUtils.unify_datetime_str(join_at_text[0].strip())
|
||||
else:
|
||||
join_at_text = html.xpath(
|
||||
'//div[contains(@class, "box_userinfo_stats")]//li[contains(text(), "加入时间")]/span/text()')
|
||||
# 加入日期
|
||||
join_at_text = html.xpath('//*[@id="join-date-value"]/@data-value')
|
||||
if join_at_text:
|
||||
self.join_at = StringUtils.unify_datetime_str(join_at_text[0].strip())
|
||||
else:
|
||||
join_at_text = html.xpath(
|
||||
'//div[contains(@class, "box_userinfo_stats")]//li[contains(text(), "加入时间")]/span/text()')
|
||||
if join_at_text:
|
||||
self.join_at = StringUtils.unify_datetime_str(join_at_text[0].strip())
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: Optional[bool] = False) -> Optional[str]:
|
||||
"""
|
||||
@@ -95,48 +102,52 @@ class GazelleSiteUserInfo(SiteParserBase):
|
||||
:return: 下页地址
|
||||
"""
|
||||
html = etree.HTML(html_text)
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return None
|
||||
try:
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return None
|
||||
|
||||
size_col = 3
|
||||
# 搜索size列
|
||||
if html.xpath('//table[contains(@id, "torrent")]//tr[1]/td'):
|
||||
size_col = len(html.xpath('//table[contains(@id, "torrent")]//tr[1]/td')) - 3
|
||||
# 搜索seeders列
|
||||
seeders_col = size_col + 2
|
||||
size_col = 3
|
||||
# 搜索size列
|
||||
if html.xpath('//table[contains(@id, "torrent")]//tr[1]/td'):
|
||||
size_col = len(html.xpath('//table[contains(@id, "torrent")]//tr[1]/td')) - 3
|
||||
# 搜索seeders列
|
||||
seeders_col = size_col + 2
|
||||
|
||||
page_seeding = 0
|
||||
page_seeding_size = 0
|
||||
page_seeding_info = []
|
||||
seeding_sizes = html.xpath(f'//table[contains(@id, "torrent")]//tr[position()>1]/td[{size_col}]')
|
||||
seeding_seeders = html.xpath(f'//table[contains(@id, "torrent")]//tr[position()>1]/td[{seeders_col}]/text()')
|
||||
if seeding_sizes and seeding_seeders:
|
||||
page_seeding = len(seeding_sizes)
|
||||
page_seeding = 0
|
||||
page_seeding_size = 0
|
||||
page_seeding_info = []
|
||||
seeding_sizes = html.xpath(f'//table[contains(@id, "torrent")]//tr[position()>1]/td[{size_col}]')
|
||||
seeding_seeders = html.xpath(f'//table[contains(@id, "torrent")]//tr[position()>1]/td[{seeders_col}]/text()')
|
||||
if seeding_sizes and seeding_seeders:
|
||||
page_seeding = len(seeding_sizes)
|
||||
|
||||
for i in range(0, len(seeding_sizes)):
|
||||
size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip())
|
||||
seeders = int(seeding_seeders[i])
|
||||
for i in range(0, len(seeding_sizes)):
|
||||
size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip())
|
||||
seeders = int(seeding_seeders[i])
|
||||
|
||||
page_seeding_size += size
|
||||
page_seeding_info.append([seeders, size])
|
||||
page_seeding_size += size
|
||||
page_seeding_info.append([seeders, size])
|
||||
|
||||
if multi_page:
|
||||
self.seeding += page_seeding
|
||||
self.seeding_size += page_seeding_size
|
||||
self.seeding_info.extend(page_seeding_info)
|
||||
else:
|
||||
if not self.seeding:
|
||||
self.seeding = page_seeding
|
||||
if not self.seeding_size:
|
||||
self.seeding_size = page_seeding_size
|
||||
if not self.seeding_info:
|
||||
self.seeding_info = page_seeding_info
|
||||
if multi_page:
|
||||
self.seeding += page_seeding
|
||||
self.seeding_size += page_seeding_size
|
||||
self.seeding_info.extend(page_seeding_info)
|
||||
else:
|
||||
if not self.seeding:
|
||||
self.seeding = page_seeding
|
||||
if not self.seeding_size:
|
||||
self.seeding_size = page_seeding_size
|
||||
if not self.seeding_info:
|
||||
self.seeding_info = page_seeding_info
|
||||
|
||||
# 是否存在下页数据
|
||||
next_page = None
|
||||
next_page_text = html.xpath('//a[contains(.//text(), "Next") or contains(.//text(), "下一页")]/@href')
|
||||
if next_page_text:
|
||||
next_page = next_page_text[-1].strip()
|
||||
# 是否存在下页数据
|
||||
next_page = None
|
||||
next_page_text = html.xpath('//a[contains(.//text(), "Next") or contains(.//text(), "下一页")]/@href')
|
||||
if next_page_text:
|
||||
next_page = next_page_text[-1].strip()
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
return next_page
|
||||
|
||||
|
||||
@@ -14,67 +14,79 @@ class IptSiteUserInfo(SiteParserBase):
|
||||
def _parse_user_base_info(self, html_text: str):
|
||||
html_text = self._prepare_html_text(html_text)
|
||||
html = etree.HTML(html_text)
|
||||
tmps = html.xpath('//a[contains(@href, "/u/")]//text()')
|
||||
tmps_id = html.xpath('//a[contains(@href, "/u/")]/@href')
|
||||
if tmps:
|
||||
self.username = str(tmps[-1])
|
||||
if tmps_id:
|
||||
user_id_match = re.search(r"/u/(\d+)", tmps_id[0])
|
||||
if user_id_match and user_id_match.group().strip():
|
||||
self.userid = user_id_match.group(1)
|
||||
self._user_detail_page = f"user.php?u={self.userid}"
|
||||
self._torrent_seeding_page = f"peers?u={self.userid}"
|
||||
try:
|
||||
tmps = html.xpath('//a[contains(@href, "/u/")]//text()')
|
||||
tmps_id = html.xpath('//a[contains(@href, "/u/")]/@href')
|
||||
if tmps:
|
||||
self.username = str(tmps[-1])
|
||||
if tmps_id:
|
||||
user_id_match = re.search(r"/u/(\d+)", tmps_id[0])
|
||||
if user_id_match and user_id_match.group().strip():
|
||||
self.userid = user_id_match.group(1)
|
||||
self._user_detail_page = f"user.php?u={self.userid}"
|
||||
self._torrent_seeding_page = f"peers?u={self.userid}"
|
||||
|
||||
tmps = html.xpath('//div[@class = "stats"]/div/div')
|
||||
if tmps:
|
||||
self.upload = StringUtils.num_filesize(str(tmps[0].xpath('span/text()')[1]).strip())
|
||||
self.download = StringUtils.num_filesize(str(tmps[0].xpath('span/text()')[2]).strip())
|
||||
self.seeding = StringUtils.str_int(tmps[0].xpath('a')[2].xpath('text()')[0])
|
||||
self.leeching = StringUtils.str_int(tmps[0].xpath('a')[2].xpath('text()')[1])
|
||||
self.ratio = StringUtils.str_float(str(tmps[0].xpath('span/text()')[0]).strip().replace('-', '0'))
|
||||
self.bonus = StringUtils.str_float(tmps[0].xpath('a')[3].xpath('text()')[0])
|
||||
tmps = html.xpath('//div[@class = "stats"]/div/div')
|
||||
if tmps:
|
||||
self.upload = StringUtils.num_filesize(str(tmps[0].xpath('span/text()')[1]).strip())
|
||||
self.download = StringUtils.num_filesize(str(tmps[0].xpath('span/text()')[2]).strip())
|
||||
self.seeding = StringUtils.str_int(tmps[0].xpath('a')[2].xpath('text()')[0])
|
||||
self.leeching = StringUtils.str_int(tmps[0].xpath('a')[2].xpath('text()')[1])
|
||||
self.ratio = StringUtils.str_float(str(tmps[0].xpath('span/text()')[0]).strip().replace('-', '0'))
|
||||
self.bonus = StringUtils.str_float(tmps[0].xpath('a')[3].xpath('text()')[0])
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
def _parse_site_page(self, html_text: str):
|
||||
pass
|
||||
|
||||
def _parse_user_detail_info(self, html_text: str):
|
||||
html = etree.HTML(html_text)
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return
|
||||
try:
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return
|
||||
|
||||
user_levels_text = html.xpath('//tr/th[text()="Class"]/following-sibling::td[1]/text()')
|
||||
if user_levels_text:
|
||||
self.user_level = user_levels_text[0].strip()
|
||||
user_levels_text = html.xpath('//tr/th[text()="Class"]/following-sibling::td[1]/text()')
|
||||
if user_levels_text:
|
||||
self.user_level = user_levels_text[0].strip()
|
||||
|
||||
# 加入日期
|
||||
join_at_text = html.xpath('//tr/th[text()="Join date"]/following-sibling::td[1]/text()')
|
||||
if join_at_text:
|
||||
self.join_at = StringUtils.unify_datetime_str(join_at_text[0].split(' (')[0])
|
||||
# 加入日期
|
||||
join_at_text = html.xpath('//tr/th[text()="Join date"]/following-sibling::td[1]/text()')
|
||||
if join_at_text:
|
||||
self.join_at = StringUtils.unify_datetime_str(join_at_text[0].split(' (')[0])
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: bool = False) -> Optional[str]:
|
||||
html = etree.HTML(html_text)
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return None
|
||||
# seeding start
|
||||
seeding_end_pos = 3
|
||||
if html.xpath('//tr/td[text() = "Leechers"]'):
|
||||
seeding_end_pos = len(html.xpath('//tr/td[text() = "Leechers"]/../preceding-sibling::tr')) + 1
|
||||
seeding_end_pos = seeding_end_pos - 3
|
||||
try:
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return None
|
||||
# seeding start
|
||||
seeding_end_pos = 3
|
||||
if html.xpath('//tr/td[text() = "Leechers"]'):
|
||||
seeding_end_pos = len(html.xpath('//tr/td[text() = "Leechers"]/../preceding-sibling::tr')) + 1
|
||||
seeding_end_pos = seeding_end_pos - 3
|
||||
|
||||
page_seeding = 0
|
||||
page_seeding_size = 0
|
||||
seeding_torrents = html.xpath('//tr/td[text() = "Seeders"]/../following-sibling::tr/td[position()=6]/text()')
|
||||
if seeding_torrents:
|
||||
page_seeding = seeding_end_pos
|
||||
for per_size in seeding_torrents[:seeding_end_pos]:
|
||||
if '(' in per_size and ')' in per_size:
|
||||
per_size = per_size.split('(')[-1]
|
||||
per_size = per_size.split(')')[0]
|
||||
page_seeding = 0
|
||||
page_seeding_size = 0
|
||||
seeding_torrents = html.xpath('//tr/td[text() = "Seeders"]/../following-sibling::tr/td[position()=6]/text()')
|
||||
if seeding_torrents:
|
||||
page_seeding = seeding_end_pos
|
||||
for per_size in seeding_torrents[:seeding_end_pos]:
|
||||
if '(' in per_size and ')' in per_size:
|
||||
per_size = per_size.split('(')[-1]
|
||||
per_size = per_size.split(')')[0]
|
||||
|
||||
page_seeding_size += StringUtils.num_filesize(per_size)
|
||||
page_seeding_size += StringUtils.num_filesize(per_size)
|
||||
|
||||
self.seeding = page_seeding
|
||||
self.seeding_size = page_seeding_size
|
||||
self.seeding = page_seeding
|
||||
self.seeding_size = page_seeding_size
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
def _parse_user_traffic_info(self, html_text: str):
|
||||
pass
|
||||
|
||||
@@ -23,12 +23,16 @@ class NexusAudiencesSiteUserInfo(NexusPhpSiteUserInfo):
|
||||
if not html_text:
|
||||
return
|
||||
html = etree.HTML(html_text)
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return
|
||||
total_row = html.xpath('//table[@class="table table-bordered"]//tr[td[1][normalize-space()="Total"]]')
|
||||
if not total_row:
|
||||
return
|
||||
seeding_count = total_row[0].xpath('./td[2]/text()')
|
||||
seeding_size = total_row[0].xpath('./td[3]/text()')
|
||||
self.seeding = StringUtils.str_int(seeding_count[0]) if seeding_count else 0
|
||||
self.seeding_size = StringUtils.num_filesize(seeding_size[0].strip()) if seeding_size else 0
|
||||
try:
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return
|
||||
total_row = html.xpath('//table[@class="table table-bordered"]//tr[td[1][normalize-space()="Total"]]')
|
||||
if not total_row:
|
||||
return
|
||||
seeding_count = total_row[0].xpath('./td[2]/text()')
|
||||
seeding_size = total_row[0].xpath('./td[3]/text()')
|
||||
self.seeding = StringUtils.str_int(seeding_count[0]) if seeding_count else 0
|
||||
self.seeding_size = StringUtils.num_filesize(seeding_size[0].strip()) if seeding_size else 0
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
@@ -17,21 +17,25 @@ class NexusHhanclubSiteUserInfo(NexusPhpSiteUserInfo):
|
||||
html_text = self._prepare_html_text(html_text)
|
||||
html = etree.HTML(html_text)
|
||||
|
||||
# 上传、下载、分享率
|
||||
upload_match = re.search(r"[_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+[KMGTPI]*B)",
|
||||
html.xpath('//*[@id="user-info-panel"]/div[2]/div[2]/div[4]/text()')[0])
|
||||
download_match = re.search(r"[_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+[KMGTPI]*B)",
|
||||
html.xpath('//*[@id="user-info-panel"]/div[2]/div[2]/div[5]/text()')[0])
|
||||
ratio_match = re.search(r"分享率][::_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+)",
|
||||
html.xpath('//*[@id="user-info-panel"]/div[2]/div[1]/div[1]/div/text()')[0])
|
||||
try:
|
||||
# 上传、下载、分享率
|
||||
upload_match = re.search(r"[_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+[KMGTPI]*B)",
|
||||
html.xpath('//*[@id="user-info-panel"]/div[2]/div[2]/div[4]/text()')[0])
|
||||
download_match = re.search(r"[_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+[KMGTPI]*B)",
|
||||
html.xpath('//*[@id="user-info-panel"]/div[2]/div[2]/div[5]/text()')[0])
|
||||
ratio_match = re.search(r"分享率][::_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+)",
|
||||
html.xpath('//*[@id="user-info-panel"]/div[2]/div[1]/div[1]/div/text()')[0])
|
||||
|
||||
# 计算分享率
|
||||
self.upload = StringUtils.num_filesize(upload_match.group(1).strip()) if upload_match else 0
|
||||
self.download = StringUtils.num_filesize(download_match.group(1).strip()) if download_match else 0
|
||||
# 优先使用页面上的分享率
|
||||
calc_ratio = 0.0 if self.download <= 0.0 else round(self.upload / self.download, 3)
|
||||
self.ratio = StringUtils.str_float(ratio_match.group(1)) if (
|
||||
ratio_match and ratio_match.group(1).strip()) else calc_ratio
|
||||
# 计算分享率
|
||||
self.upload = StringUtils.num_filesize(upload_match.group(1).strip()) if upload_match else 0
|
||||
self.download = StringUtils.num_filesize(download_match.group(1).strip()) if download_match else 0
|
||||
# 优先使用页面上的分享率
|
||||
calc_ratio = 0.0 if self.download <= 0.0 else round(self.upload / self.download, 3)
|
||||
self.ratio = StringUtils.str_float(ratio_match.group(1)) if (
|
||||
ratio_match and ratio_match.group(1).strip()) else calc_ratio
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
def _parse_user_detail_info(self, html_text: str):
|
||||
"""
|
||||
@@ -42,12 +46,16 @@ class NexusHhanclubSiteUserInfo(NexusPhpSiteUserInfo):
|
||||
super()._parse_user_detail_info(html_text)
|
||||
|
||||
html = etree.HTML(html_text)
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return
|
||||
# 加入时间
|
||||
join_at_text = html.xpath('//*[@id="mainContent"]/div/div[2]/div[4]/div[3]/span[2]/text()[1]')
|
||||
if join_at_text:
|
||||
self.join_at = StringUtils.unify_datetime_str(join_at_text[0].split(' (')[0].strip())
|
||||
try:
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return
|
||||
# 加入时间
|
||||
join_at_text = html.xpath('//*[@id="mainContent"]/div/div[2]/div[4]/div[3]/span[2]/text()[1]')
|
||||
if join_at_text:
|
||||
self.join_at = StringUtils.unify_datetime_str(join_at_text[0].split(' (')[0].strip())
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
def _get_user_level(self, html):
|
||||
super()._get_user_level(html)
|
||||
|
||||
@@ -34,21 +34,25 @@ class NexusPhpSiteUserInfo(SiteParserBase):
|
||||
:return:
|
||||
"""
|
||||
html = etree.HTML(html_text)
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return
|
||||
try:
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return
|
||||
|
||||
message_labels = html.xpath('//a[@href="messages.php"]/..')
|
||||
message_labels.extend(html.xpath('//a[contains(@href, "messages.php")]/..'))
|
||||
if message_labels:
|
||||
message_text = message_labels[0].xpath("string(.)")
|
||||
message_labels = html.xpath('//a[@href="messages.php"]/..')
|
||||
message_labels.extend(html.xpath('//a[contains(@href, "messages.php")]/..'))
|
||||
if message_labels:
|
||||
message_text = message_labels[0].xpath("string(.)")
|
||||
|
||||
logger.debug(f"{self._site_name} 消息原始信息 {message_text}")
|
||||
message_unread_match = re.findall(r"[^Date](信息箱\s*|\((?![^)]*:)|你有\xa0)(\d+)", message_text)
|
||||
logger.debug(f"{self._site_name} 消息原始信息 {message_text}")
|
||||
message_unread_match = re.findall(r"[^Date](信息箱\s*|\((?![^)]*:)|你有\xa0)(\d+)", message_text)
|
||||
|
||||
if message_unread_match and len(message_unread_match[-1]) == 2:
|
||||
self.message_unread = StringUtils.str_int(message_unread_match[-1][1])
|
||||
elif message_text.isdigit():
|
||||
self.message_unread = StringUtils.str_int(message_text)
|
||||
if message_unread_match and len(message_unread_match[-1]) == 2:
|
||||
self.message_unread = StringUtils.str_int(message_unread_match[-1][1])
|
||||
elif message_text.isdigit():
|
||||
self.message_unread = StringUtils.str_int(message_text)
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
def _parse_user_base_info(self, html_text: str):
|
||||
"""
|
||||
@@ -61,18 +65,23 @@ class NexusPhpSiteUserInfo(SiteParserBase):
|
||||
self._parse_message_unread(html_text)
|
||||
|
||||
html = etree.HTML(html_text)
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return
|
||||
try:
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return
|
||||
|
||||
ret = html.xpath(f'//a[contains(@href, "userdetails") and contains(@href, "{self.userid}")]//b//text()')
|
||||
if ret:
|
||||
self.username = str(ret[0])
|
||||
return
|
||||
ret = html.xpath(f'//a[contains(@href, "userdetails") and contains(@href, "{self.userid}")]//text()')
|
||||
if ret:
|
||||
self.username = str(ret[0])
|
||||
ret = html.xpath(f'//a[contains(@href, "userdetails") and contains(@href, "{self.userid}")]//b//text()')
|
||||
if ret:
|
||||
self.username = str(ret[0])
|
||||
return
|
||||
ret = html.xpath(f'//a[contains(@href, "userdetails") and contains(@href, "{self.userid}")]//text()')
|
||||
if ret:
|
||||
self.username = str(ret[0])
|
||||
|
||||
ret = html.xpath('//a[contains(@href, "userdetails")]//strong//text()')
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
ret = html.xpath('//a[contains(@href, "userdetails")]//strong//text()')
|
||||
if ret:
|
||||
self.username = str(ret[0])
|
||||
return
|
||||
@@ -98,28 +107,32 @@ class NexusPhpSiteUserInfo(SiteParserBase):
|
||||
self.leeching = StringUtils.str_int(leeching_match.group(2)) if leeching_match and leeching_match.group(
|
||||
2).strip() else 0
|
||||
html = etree.HTML(html_text)
|
||||
has_ucoin, self.bonus = self._parse_ucoin(html)
|
||||
if has_ucoin:
|
||||
return
|
||||
tmps = html.xpath('//a[contains(@href,"mybonus")]/text()') if html else None
|
||||
if tmps:
|
||||
bonus_text = str(tmps[0]).strip()
|
||||
bonus_match = re.search(r"([\d,.]+)", bonus_text)
|
||||
if bonus_match and bonus_match.group(1).strip():
|
||||
self.bonus = StringUtils.str_float(bonus_match.group(1))
|
||||
return
|
||||
bonus_match = re.search(r"mybonus.[\[\]::<>/a-zA-Z_\-=\"'\s#;.(使用魔力值豆]+\s*([\d,.]+)[<()&\s]", html_text)
|
||||
try:
|
||||
if bonus_match and bonus_match.group(1).strip():
|
||||
self.bonus = StringUtils.str_float(bonus_match.group(1))
|
||||
has_ucoin, self.bonus = self._parse_ucoin(html)
|
||||
if has_ucoin:
|
||||
return
|
||||
bonus_match = re.search(r"[魔力值|\]][\[\]::<>/a-zA-Z_\-=\"'\s#;]+\s*([\d,.]+|\"[\d,.]+\")[<>()&\s]",
|
||||
html_text,
|
||||
flags=re.S)
|
||||
if bonus_match and bonus_match.group(1).strip():
|
||||
self.bonus = StringUtils.str_float(bonus_match.group(1).strip('"'))
|
||||
except Exception as err:
|
||||
logger.error(f"{self._site_name} 解析魔力值出错, 错误信息: {str(err)}")
|
||||
tmps = html.xpath('//a[contains(@href,"mybonus")]/text()') if html else None
|
||||
if tmps:
|
||||
bonus_text = str(tmps[0]).strip()
|
||||
bonus_match = re.search(r"([\d,.]+)", bonus_text)
|
||||
if bonus_match and bonus_match.group(1).strip():
|
||||
self.bonus = StringUtils.str_float(bonus_match.group(1))
|
||||
return
|
||||
bonus_match = re.search(r"mybonus.[\[\]::<>/a-zA-Z_\-=\"'\s#;.(使用魔力值豆]+\s*([\d,.]+)[<()&\s]", html_text)
|
||||
try:
|
||||
if bonus_match and bonus_match.group(1).strip():
|
||||
self.bonus = StringUtils.str_float(bonus_match.group(1))
|
||||
return
|
||||
bonus_match = re.search(r"[魔力值|\]][\[\]::<>/a-zA-Z_\-=\"'\s#;]+\s*([\d,.]+|\"[\d,.]+\")[<>()&\s]",
|
||||
html_text,
|
||||
flags=re.S)
|
||||
if bonus_match and bonus_match.group(1).strip():
|
||||
self.bonus = StringUtils.str_float(bonus_match.group(1).strip('"'))
|
||||
except Exception as err:
|
||||
logger.error(f"{self._site_name} 解析魔力值出错, 错误信息: {str(err)}")
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
@staticmethod
|
||||
def _parse_ucoin(html):
|
||||
@@ -155,72 +168,76 @@ class NexusPhpSiteUserInfo(SiteParserBase):
|
||||
:return: 下页地址
|
||||
"""
|
||||
html = etree.HTML(str(html_text).replace(r'\/', '/'))
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return None
|
||||
try:
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return None
|
||||
|
||||
# 首页存在扩展链接,使用扩展链接
|
||||
seeding_url_text = html.xpath('//a[contains(@href,"torrents.php") '
|
||||
'and contains(@href,"seeding")]/@href')
|
||||
if multi_page is False and seeding_url_text and seeding_url_text[0].strip():
|
||||
self._torrent_seeding_page = seeding_url_text[0].strip()
|
||||
return self._torrent_seeding_page
|
||||
# 首页存在扩展链接,使用扩展链接
|
||||
seeding_url_text = html.xpath('//a[contains(@href,"torrents.php") '
|
||||
'and contains(@href,"seeding")]/@href')
|
||||
if multi_page is False and seeding_url_text and seeding_url_text[0].strip():
|
||||
self._torrent_seeding_page = seeding_url_text[0].strip()
|
||||
return self._torrent_seeding_page
|
||||
|
||||
size_col = 3
|
||||
seeders_col = 4
|
||||
# 搜索size列
|
||||
size_col_xpath = '//tr[position()=1]/' \
|
||||
'td[(img[@class="size"] and img[@alt="size"])' \
|
||||
' or (text() = "大小")' \
|
||||
' or (a/img[@class="size" and @alt="size"])]'
|
||||
if html.xpath(size_col_xpath):
|
||||
size_col = len(html.xpath(f'{size_col_xpath}/preceding-sibling::td')) + 1
|
||||
# 搜索seeders列
|
||||
seeders_col_xpath = '//tr[position()=1]/' \
|
||||
'td[(img[@class="seeders"] and img[@alt="seeders"])' \
|
||||
' or (text() = "在做种")' \
|
||||
' or (a/img[@class="seeders" and @alt="seeders"])]'
|
||||
if html.xpath(seeders_col_xpath):
|
||||
seeders_col = len(html.xpath(f'{seeders_col_xpath}/preceding-sibling::td')) + 1
|
||||
size_col = 3
|
||||
seeders_col = 4
|
||||
# 搜索size列
|
||||
size_col_xpath = '//tr[position()=1]/' \
|
||||
'td[(img[@class="size"] and img[@alt="size"])' \
|
||||
' or (text() = "大小")' \
|
||||
' or (a/img[@class="size" and @alt="size"])]'
|
||||
if html.xpath(size_col_xpath):
|
||||
size_col = len(html.xpath(f'{size_col_xpath}/preceding-sibling::td')) + 1
|
||||
# 搜索seeders列
|
||||
seeders_col_xpath = '//tr[position()=1]/' \
|
||||
'td[(img[@class="seeders"] and img[@alt="seeders"])' \
|
||||
' or (text() = "在做种")' \
|
||||
' or (a/img[@class="seeders" and @alt="seeders"])]'
|
||||
if html.xpath(seeders_col_xpath):
|
||||
seeders_col = len(html.xpath(f'{seeders_col_xpath}/preceding-sibling::td')) + 1
|
||||
|
||||
page_seeding = 0
|
||||
page_seeding_size = 0
|
||||
page_seeding_info = []
|
||||
# 如果 table class="torrents",则增加table[@class="torrents"]
|
||||
table_class = '//table[@class="torrents"]' if html.xpath('//table[@class="torrents"]') else ''
|
||||
seeding_sizes = html.xpath(f'{table_class}//tr[position()>1]/td[{size_col}]')
|
||||
seeding_seeders = html.xpath(f'{table_class}//tr[position()>1]/td[{seeders_col}]/b/a/text()')
|
||||
if not seeding_seeders:
|
||||
seeding_seeders = html.xpath(f'{table_class}//tr[position()>1]/td[{seeders_col}]//text()')
|
||||
if seeding_sizes and seeding_seeders:
|
||||
page_seeding = len(seeding_sizes)
|
||||
page_seeding = 0
|
||||
page_seeding_size = 0
|
||||
page_seeding_info = []
|
||||
# 如果 table class="torrents",则增加table[@class="torrents"]
|
||||
table_class = '//table[@class="torrents"]' if html.xpath('//table[@class="torrents"]') else ''
|
||||
seeding_sizes = html.xpath(f'{table_class}//tr[position()>1]/td[{size_col}]')
|
||||
seeding_seeders = html.xpath(f'{table_class}//tr[position()>1]/td[{seeders_col}]/b/a/text()')
|
||||
if not seeding_seeders:
|
||||
seeding_seeders = html.xpath(f'{table_class}//tr[position()>1]/td[{seeders_col}]//text()')
|
||||
if seeding_sizes and seeding_seeders:
|
||||
page_seeding = len(seeding_sizes)
|
||||
|
||||
for i in range(0, len(seeding_sizes)):
|
||||
size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip())
|
||||
seeders = StringUtils.str_int(seeding_seeders[i])
|
||||
for i in range(0, len(seeding_sizes)):
|
||||
size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip())
|
||||
seeders = StringUtils.str_int(seeding_seeders[i])
|
||||
|
||||
page_seeding_size += size
|
||||
page_seeding_info.append([seeders, size])
|
||||
page_seeding_size += size
|
||||
page_seeding_info.append([seeders, size])
|
||||
|
||||
self.seeding += page_seeding
|
||||
self.seeding_size += page_seeding_size
|
||||
self.seeding_info.extend(page_seeding_info)
|
||||
self.seeding += page_seeding
|
||||
self.seeding_size += page_seeding_size
|
||||
self.seeding_info.extend(page_seeding_info)
|
||||
|
||||
# 是否存在下页数据
|
||||
next_page = None
|
||||
next_page_text = html.xpath(
|
||||
'//a[contains(.//text(), "下一页") or contains(.//text(), "下一頁") or contains(.//text(), ">")]/@href')
|
||||
|
||||
# 防止识别到详情页
|
||||
while next_page_text:
|
||||
next_page = next_page_text.pop().strip()
|
||||
if not next_page.startswith('details.php'):
|
||||
break
|
||||
# 是否存在下页数据
|
||||
next_page = None
|
||||
next_page_text = html.xpath(
|
||||
'//a[contains(.//text(), "下一页") or contains(.//text(), "下一頁") or contains(.//text(), ">")]/@href')
|
||||
|
||||
# fix up page url
|
||||
if next_page:
|
||||
if self.userid not in next_page:
|
||||
next_page = f'{next_page}&userid={self.userid}&type=seeding'
|
||||
# 防止识别到详情页
|
||||
while next_page_text:
|
||||
next_page = next_page_text.pop().strip()
|
||||
if not next_page.startswith('details.php'):
|
||||
break
|
||||
next_page = None
|
||||
|
||||
# fix up page url
|
||||
if next_page:
|
||||
if self.userid not in next_page:
|
||||
next_page = f'{next_page}&userid={self.userid}&type=seeding'
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
return next_page
|
||||
|
||||
@@ -231,57 +248,61 @@ class NexusPhpSiteUserInfo(SiteParserBase):
|
||||
:return:
|
||||
"""
|
||||
html = etree.HTML(html_text)
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return
|
||||
try:
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return
|
||||
|
||||
self._get_user_level(html)
|
||||
self._get_user_level(html)
|
||||
|
||||
self._fixup_traffic_info(html)
|
||||
self._fixup_traffic_info(html)
|
||||
|
||||
# 加入日期
|
||||
join_at_text = html.xpath(
|
||||
'//tr/td[text()="加入日期" or text()="注册日期" or *[text()="加入日期"]]/following-sibling::td[1]//text()'
|
||||
'|//div/b[text()="加入日期"]/../text()')
|
||||
if join_at_text:
|
||||
self.join_at = StringUtils.unify_datetime_str(join_at_text[0].split(' (')[0].strip())
|
||||
# 加入日期
|
||||
join_at_text = html.xpath(
|
||||
'//tr/td[text()="加入日期" or text()="注册日期" or *[text()="加入日期"]]/following-sibling::td[1]//text()'
|
||||
'|//div/b[text()="加入日期"]/../text()')
|
||||
if join_at_text:
|
||||
self.join_at = StringUtils.unify_datetime_str(join_at_text[0].split(' (')[0].strip())
|
||||
|
||||
# 做种体积 & 做种数
|
||||
# seeding 页面获取不到的话,此处再获取一次
|
||||
seeding_sizes = html.xpath('//tr/td[text()="当前上传"]/following-sibling::td[1]//'
|
||||
'table[tr[1][td[4 and text()="尺寸"]]]//tr[position()>1]/td[4]')
|
||||
seeding_seeders = html.xpath('//tr/td[text()="当前上传"]/following-sibling::td[1]//'
|
||||
'table[tr[1][td[5 and text()="做种者"]]]//tr[position()>1]/td[5]//text()')
|
||||
tmp_seeding = len(seeding_sizes)
|
||||
tmp_seeding_size = 0
|
||||
tmp_seeding_info = []
|
||||
for i in range(0, len(seeding_sizes)):
|
||||
size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip())
|
||||
seeders = StringUtils.str_int(seeding_seeders[i])
|
||||
# 做种体积 & 做种数
|
||||
# seeding 页面获取不到的话,此处再获取一次
|
||||
seeding_sizes = html.xpath('//tr/td[text()="当前上传"]/following-sibling::td[1]//'
|
||||
'table[tr[1][td[4 and text()="尺寸"]]]//tr[position()>1]/td[4]')
|
||||
seeding_seeders = html.xpath('//tr/td[text()="当前上传"]/following-sibling::td[1]//'
|
||||
'table[tr[1][td[5 and text()="做种者"]]]//tr[position()>1]/td[5]//text()')
|
||||
tmp_seeding = len(seeding_sizes)
|
||||
tmp_seeding_size = 0
|
||||
tmp_seeding_info = []
|
||||
for i in range(0, len(seeding_sizes)):
|
||||
size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip())
|
||||
seeders = StringUtils.str_int(seeding_seeders[i])
|
||||
|
||||
tmp_seeding_size += size
|
||||
tmp_seeding_info.append([seeders, size])
|
||||
tmp_seeding_size += size
|
||||
tmp_seeding_info.append([seeders, size])
|
||||
|
||||
if not self.seeding_size:
|
||||
self.seeding_size = tmp_seeding_size
|
||||
if not self.seeding:
|
||||
self.seeding = tmp_seeding
|
||||
if not self.seeding_info:
|
||||
self.seeding_info = tmp_seeding_info
|
||||
if not self.seeding_size:
|
||||
self.seeding_size = tmp_seeding_size
|
||||
if not self.seeding:
|
||||
self.seeding = tmp_seeding
|
||||
if not self.seeding_info:
|
||||
self.seeding_info = tmp_seeding_info
|
||||
|
||||
seeding_sizes = html.xpath('//tr/td[text()="做种统计"]/following-sibling::td[1]//text()')
|
||||
if seeding_sizes:
|
||||
seeding_match = re.search(r"总做种数:\s+(\d+)", seeding_sizes[0], re.IGNORECASE)
|
||||
seeding_size_match = re.search(r"总做种体积:\s+([\d,.\s]+[KMGTPI]*B)", seeding_sizes[0], re.IGNORECASE)
|
||||
tmp_seeding = StringUtils.str_int(seeding_match.group(1)) if (
|
||||
seeding_match and seeding_match.group(1)) else 0
|
||||
tmp_seeding_size = StringUtils.num_filesize(
|
||||
seeding_size_match.group(1).strip()) if seeding_size_match else 0
|
||||
if not self.seeding_size:
|
||||
self.seeding_size = tmp_seeding_size
|
||||
if not self.seeding:
|
||||
self.seeding = tmp_seeding
|
||||
seeding_sizes = html.xpath('//tr/td[text()="做种统计"]/following-sibling::td[1]//text()')
|
||||
if seeding_sizes:
|
||||
seeding_match = re.search(r"总做种数:\s+(\d+)", seeding_sizes[0], re.IGNORECASE)
|
||||
seeding_size_match = re.search(r"总做种体积:\s+([\d,.\s]+[KMGTPI]*B)", seeding_sizes[0], re.IGNORECASE)
|
||||
tmp_seeding = StringUtils.str_int(seeding_match.group(1)) if (
|
||||
seeding_match and seeding_match.group(1)) else 0
|
||||
tmp_seeding_size = StringUtils.num_filesize(
|
||||
seeding_size_match.group(1).strip()) if seeding_size_match else 0
|
||||
if not self.seeding_size:
|
||||
self.seeding_size = tmp_seeding_size
|
||||
if not self.seeding:
|
||||
self.seeding = tmp_seeding
|
||||
|
||||
self._fixup_torrent_seeding_page(html)
|
||||
self._fixup_torrent_seeding_page(html)
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
def _fixup_torrent_seeding_page(self, html):
|
||||
"""
|
||||
@@ -348,43 +369,51 @@ class NexusPhpSiteUserInfo(SiteParserBase):
|
||||
|
||||
def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]:
|
||||
html = etree.HTML(html_text)
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return None
|
||||
try:
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return None
|
||||
|
||||
message_links = html.xpath('//tr[not(./td/img[@alt="Read"])]/td/a[contains(@href, "viewmessage")]/@href')
|
||||
msg_links.extend(message_links)
|
||||
# 是否存在下页数据
|
||||
next_page = None
|
||||
next_page_text = html.xpath('//a[contains(.//text(), "下一页") or contains(.//text(), "下一頁")]/@href')
|
||||
if next_page_text:
|
||||
next_page = next_page_text[-1].strip()
|
||||
message_links = html.xpath('//tr[not(./td/img[@alt="Read"])]/td/a[contains(@href, "viewmessage")]/@href')
|
||||
msg_links.extend(message_links)
|
||||
# 是否存在下页数据
|
||||
next_page = None
|
||||
next_page_text = html.xpath('//a[contains(.//text(), "下一页") or contains(.//text(), "下一頁")]/@href')
|
||||
if next_page_text:
|
||||
next_page = next_page_text[-1].strip()
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
return next_page
|
||||
|
||||
def _parse_message_content(self, html_text):
|
||||
html = etree.HTML(html_text)
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return None, None, None
|
||||
# 标题
|
||||
message_head_text = None
|
||||
message_head = html.xpath('//h1/text()'
|
||||
'|//div[@class="layui-card-header"]/span[1]/text()')
|
||||
if message_head:
|
||||
message_head_text = message_head[-1].strip()
|
||||
try:
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return None, None, None
|
||||
# 标题
|
||||
message_head_text = None
|
||||
message_head = html.xpath('//h1/text()'
|
||||
'|//div[@class="layui-card-header"]/span[1]/text()')
|
||||
if message_head:
|
||||
message_head_text = message_head[-1].strip()
|
||||
|
||||
# 消息时间
|
||||
message_date_text = None
|
||||
message_date = html.xpath('//h1/following-sibling::table[.//tr/td[@class="colhead"]]//tr[2]/td[2]'
|
||||
'|//div[@class="layui-card-header"]/span[2]/span[2]')
|
||||
if message_date:
|
||||
message_date_text = message_date[0].xpath("string(.)").strip()
|
||||
# 消息时间
|
||||
message_date_text = None
|
||||
message_date = html.xpath('//h1/following-sibling::table[.//tr/td[@class="colhead"]]//tr[2]/td[2]'
|
||||
'|//div[@class="layui-card-header"]/span[2]/span[2]')
|
||||
if message_date:
|
||||
message_date_text = message_date[0].xpath("string(.)").strip()
|
||||
|
||||
# 消息内容
|
||||
message_content_text = None
|
||||
message_content = html.xpath('//h1/following-sibling::table[.//tr/td[@class="colhead"]]//tr[3]/td'
|
||||
'|//div[contains(@class,"layui-card-body")]')
|
||||
if message_content:
|
||||
message_content_text = message_content[0].xpath("string(.)").strip()
|
||||
# 消息内容
|
||||
message_content_text = None
|
||||
message_content = html.xpath('//h1/following-sibling::table[.//tr/td[@class="colhead"]]//tr[3]/td'
|
||||
'|//div[contains(@class,"layui-card-body")]')
|
||||
if message_content:
|
||||
message_content_text = message_content[0].xpath("string(.)").strip()
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
return message_head_text, message_date_text, message_content_text
|
||||
|
||||
|
||||
@@ -114,48 +114,56 @@ class NexusRabbitSiteUserInfo(SiteParserBase):
|
||||
def _parse_user_base_info(self, html_text: str):
|
||||
"""只有奶糖余额才需要在 base 中获取,其它均可以在详情页拿到"""
|
||||
html = etree.HTML(html_text)
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return
|
||||
bonus = html.xpath(
|
||||
'//div[contains(text(), "奶糖余额")]/following-sibling::div[1]/text()'
|
||||
)
|
||||
if bonus:
|
||||
self.bonus = StringUtils.str_float(bonus[0].strip())
|
||||
try:
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return
|
||||
bonus = html.xpath(
|
||||
'//div[contains(text(), "奶糖余额")]/following-sibling::div[1]/text()'
|
||||
)
|
||||
if bonus:
|
||||
self.bonus = StringUtils.str_float(bonus[0].strip())
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
def _parse_user_detail_info(self, html_text: str):
|
||||
html = etree.HTML(html_text)
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return
|
||||
# 缩小一下查找范围,所有的信息都在这个 div 里
|
||||
user_info = html.xpath('//div[contains(@class, "layui-hares-user-info-right")]')
|
||||
if not user_info:
|
||||
return
|
||||
user_info = user_info[0]
|
||||
# 用户名
|
||||
if username := user_info.xpath(
|
||||
'.//span[contains(text(), "用户名")]/a/span/text()'
|
||||
):
|
||||
self.username = username[0].strip()
|
||||
# 等级
|
||||
if user_level := user_info.xpath('.//span[contains(text(), "等级")]/b/text()'):
|
||||
self.user_level = user_level[0].strip()
|
||||
# 加入日期
|
||||
if join_date := user_info.xpath('.//span[contains(text(), "注册日期")]/text()'):
|
||||
join_date = join_date[0].strip().split("\r")[0].removeprefix("注册日期:")
|
||||
self.join_at = StringUtils.unify_datetime_str(join_date)
|
||||
# 上传量
|
||||
if upload := user_info.xpath('.//span[contains(text(), "上传量")]/text()'):
|
||||
self.upload = StringUtils.num_filesize(
|
||||
upload[0].strip().removeprefix("上传量:")
|
||||
)
|
||||
# 下载量
|
||||
if download := user_info.xpath('.//span[contains(text(), "下载量")]/text()'):
|
||||
self.download = StringUtils.num_filesize(
|
||||
download[0].strip().removeprefix("下载量:")
|
||||
)
|
||||
# 分享率
|
||||
if ratio := user_info.xpath('.//span[contains(text(), "分享率")]/em/text()'):
|
||||
self.ratio = StringUtils.str_float(ratio[0].strip())
|
||||
try:
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return
|
||||
# 缩小一下查找范围,所有的信息都在这个 div 里
|
||||
user_info = html.xpath('//div[contains(@class, "layui-hares-user-info-right")]')
|
||||
if not user_info:
|
||||
return
|
||||
user_info = user_info[0]
|
||||
# 用户名
|
||||
if username := user_info.xpath(
|
||||
'.//span[contains(text(), "用户名")]/a/span/text()'
|
||||
):
|
||||
self.username = username[0].strip()
|
||||
# 等级
|
||||
if user_level := user_info.xpath('.//span[contains(text(), "等级")]/b/text()'):
|
||||
self.user_level = user_level[0].strip()
|
||||
# 加入日期
|
||||
if join_date := user_info.xpath('.//span[contains(text(), "注册日期")]/text()'):
|
||||
join_date = join_date[0].strip().split("\r")[0].removeprefix("注册日期:")
|
||||
self.join_at = StringUtils.unify_datetime_str(join_date)
|
||||
# 上传量
|
||||
if upload := user_info.xpath('.//span[contains(text(), "上传量")]/text()'):
|
||||
self.upload = StringUtils.num_filesize(
|
||||
upload[0].strip().removeprefix("上传量:")
|
||||
)
|
||||
# 下载量
|
||||
if download := user_info.xpath('.//span[contains(text(), "下载量")]/text()'):
|
||||
self.download = StringUtils.num_filesize(
|
||||
download[0].strip().removeprefix("下载量:")
|
||||
)
|
||||
# 分享率
|
||||
if ratio := user_info.xpath('.//span[contains(text(), "分享率")]/em/text()'):
|
||||
self.ratio = StringUtils.str_float(ratio[0].strip())
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
def _parse_message_content(self, html_text):
|
||||
"""
|
||||
|
||||
@@ -24,9 +24,13 @@ class SmallHorseSiteUserInfo(SiteParserBase):
|
||||
def _parse_user_base_info(self, html_text: str):
|
||||
html_text = self._prepare_html_text(html_text)
|
||||
html = etree.HTML(html_text)
|
||||
ret = html.xpath('//a[contains(@href, "user.php")]//text()')
|
||||
if ret:
|
||||
self.username = str(ret[0])
|
||||
try:
|
||||
ret = html.xpath('//a[contains(@href, "user.php")]//text()')
|
||||
if ret:
|
||||
self.username = str(ret[0])
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
def _parse_user_traffic_info(self, html_text: str):
|
||||
"""
|
||||
@@ -36,21 +40,25 @@ class SmallHorseSiteUserInfo(SiteParserBase):
|
||||
"""
|
||||
html_text = self._prepare_html_text(html_text)
|
||||
html = etree.HTML(html_text)
|
||||
tmps = html.xpath('//ul[@class = "stats nobullet"]')
|
||||
if tmps:
|
||||
if tmps[1].xpath("li") and tmps[1].xpath("li")[0].xpath("span//text()"):
|
||||
self.join_at = StringUtils.unify_datetime_str(tmps[1].xpath("li")[0].xpath("span//text()")[0])
|
||||
self.upload = StringUtils.num_filesize(str(tmps[1].xpath("li")[2].xpath("text()")[0]).split(":")[1].strip())
|
||||
self.download = StringUtils.num_filesize(
|
||||
str(tmps[1].xpath("li")[3].xpath("text()")[0]).split(":")[1].strip())
|
||||
if tmps[1].xpath("li")[4].xpath("span//text()"):
|
||||
self.ratio = StringUtils.str_float(str(tmps[1].xpath("li")[4].xpath("span//text()")[0]).replace('∞', '0'))
|
||||
else:
|
||||
self.ratio = StringUtils.str_float(str(tmps[1].xpath("li")[5].xpath("text()")[0]).split(":")[1])
|
||||
self.bonus = StringUtils.str_float(str(tmps[1].xpath("li")[5].xpath("text()")[0]).split(":")[1])
|
||||
self.user_level = str(tmps[3].xpath("li")[0].xpath("text()")[0]).split(":")[1].strip()
|
||||
self.leeching = StringUtils.str_int(
|
||||
(tmps[4].xpath("li")[6].xpath("text()")[0]).split(":")[1].replace("[", ""))
|
||||
try:
|
||||
tmps = html.xpath('//ul[@class = "stats nobullet"]')
|
||||
if tmps:
|
||||
if tmps[1].xpath("li") and tmps[1].xpath("li")[0].xpath("span//text()"):
|
||||
self.join_at = StringUtils.unify_datetime_str(tmps[1].xpath("li")[0].xpath("span//text()")[0])
|
||||
self.upload = StringUtils.num_filesize(str(tmps[1].xpath("li")[2].xpath("text()")[0]).split(":")[1].strip())
|
||||
self.download = StringUtils.num_filesize(
|
||||
str(tmps[1].xpath("li")[3].xpath("text()")[0]).split(":")[1].strip())
|
||||
if tmps[1].xpath("li")[4].xpath("span//text()"):
|
||||
self.ratio = StringUtils.str_float(str(tmps[1].xpath("li")[4].xpath("span//text()")[0]).replace('∞', '0'))
|
||||
else:
|
||||
self.ratio = StringUtils.str_float(str(tmps[1].xpath("li")[5].xpath("text()")[0]).split(":")[1])
|
||||
self.bonus = StringUtils.str_float(str(tmps[1].xpath("li")[5].xpath("text()")[0]).split(":")[1])
|
||||
self.user_level = str(tmps[3].xpath("li")[0].xpath("text()")[0]).split(":")[1].strip()
|
||||
self.leeching = StringUtils.str_int(
|
||||
(tmps[4].xpath("li")[6].xpath("text()")[0]).split(":")[1].replace("[", ""))
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
def _parse_user_detail_info(self, html_text: str):
|
||||
pass
|
||||
@@ -63,39 +71,42 @@ class SmallHorseSiteUserInfo(SiteParserBase):
|
||||
:return: 下页地址
|
||||
"""
|
||||
html = etree.HTML(html_text)
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return None
|
||||
try:
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return None
|
||||
|
||||
size_col = 6
|
||||
seeders_col = 8
|
||||
size_col = 6
|
||||
seeders_col = 8
|
||||
|
||||
page_seeding = 0
|
||||
page_seeding_size = 0
|
||||
page_seeding_info = []
|
||||
seeding_sizes = html.xpath(f'//table[@id="torrent_table"]//tr[position()>1]/td[{size_col}]')
|
||||
seeding_seeders = html.xpath(f'//table[@id="torrent_table"]//tr[position()>1]/td[{seeders_col}]')
|
||||
if seeding_sizes and seeding_seeders:
|
||||
page_seeding = len(seeding_sizes)
|
||||
page_seeding = 0
|
||||
page_seeding_size = 0
|
||||
page_seeding_info = []
|
||||
seeding_sizes = html.xpath(f'//table[@id="torrent_table"]//tr[position()>1]/td[{size_col}]')
|
||||
seeding_seeders = html.xpath(f'//table[@id="torrent_table"]//tr[position()>1]/td[{seeders_col}]')
|
||||
if seeding_sizes and seeding_seeders:
|
||||
page_seeding = len(seeding_sizes)
|
||||
|
||||
for i in range(0, len(seeding_sizes)):
|
||||
size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip())
|
||||
seeders = StringUtils.str_int(seeding_seeders[i].xpath("string(.)").strip())
|
||||
for i in range(0, len(seeding_sizes)):
|
||||
size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip())
|
||||
seeders = StringUtils.str_int(seeding_seeders[i].xpath("string(.)").strip())
|
||||
|
||||
page_seeding_size += size
|
||||
page_seeding_info.append([seeders, size])
|
||||
page_seeding_size += size
|
||||
page_seeding_info.append([seeders, size])
|
||||
|
||||
self.seeding += page_seeding
|
||||
self.seeding_size += page_seeding_size
|
||||
self.seeding_info.extend(page_seeding_info)
|
||||
|
||||
# 是否存在下页数据
|
||||
next_page = None
|
||||
next_pages = html.xpath('//ul[@class="pagination"]/li[contains(@class,"active")]/following-sibling::li')
|
||||
if next_pages and len(next_pages) > 1:
|
||||
page_num = next_pages[0].xpath("string(.)").strip()
|
||||
if page_num.isdigit():
|
||||
next_page = f"{self._torrent_seeding_page}&page={page_num}"
|
||||
self.seeding += page_seeding
|
||||
self.seeding_size += page_seeding_size
|
||||
self.seeding_info.extend(page_seeding_info)
|
||||
|
||||
# 是否存在下页数据
|
||||
next_page = None
|
||||
next_pages = html.xpath('//ul[@class="pagination"]/li[contains(@class,"active")]/following-sibling::li')
|
||||
if next_pages and len(next_pages) > 1:
|
||||
page_num = next_pages[0].xpath("string(.)").strip()
|
||||
if page_num.isdigit():
|
||||
next_page = f"{self._torrent_seeding_page}&page={page_num}"
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
return next_page
|
||||
|
||||
def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]:
|
||||
|
||||
@@ -32,29 +32,33 @@ class TorrentLeechSiteUserInfo(SiteParserBase):
|
||||
"""
|
||||
html_text = self._prepare_html_text(html_text)
|
||||
html = etree.HTML(html_text)
|
||||
upload_html = html.xpath('//div[contains(@class,"profile-uploaded")]//span/text()')
|
||||
if upload_html:
|
||||
self.upload = StringUtils.num_filesize(upload_html[0])
|
||||
download_html = html.xpath('//div[contains(@class,"profile-downloaded")]//span/text()')
|
||||
if download_html:
|
||||
self.download = StringUtils.num_filesize(download_html[0])
|
||||
ratio_html = html.xpath('//div[contains(@class,"profile-ratio")]//span/text()')
|
||||
if ratio_html:
|
||||
self.ratio = StringUtils.str_float(ratio_html[0].replace('∞', '0'))
|
||||
try:
|
||||
upload_html = html.xpath('//div[contains(@class,"profile-uploaded")]//span/text()')
|
||||
if upload_html:
|
||||
self.upload = StringUtils.num_filesize(upload_html[0])
|
||||
download_html = html.xpath('//div[contains(@class,"profile-downloaded")]//span/text()')
|
||||
if download_html:
|
||||
self.download = StringUtils.num_filesize(download_html[0])
|
||||
ratio_html = html.xpath('//div[contains(@class,"profile-ratio")]//span/text()')
|
||||
if ratio_html:
|
||||
self.ratio = StringUtils.str_float(ratio_html[0].replace('∞', '0'))
|
||||
|
||||
user_level_html = html.xpath('//table[contains(@class, "profileViewTable")]'
|
||||
'//tr/td[text()="Class"]/following-sibling::td/text()')
|
||||
if user_level_html:
|
||||
self.user_level = user_level_html[0].strip()
|
||||
user_level_html = html.xpath('//table[contains(@class, "profileViewTable")]'
|
||||
'//tr/td[text()="Class"]/following-sibling::td/text()')
|
||||
if user_level_html:
|
||||
self.user_level = user_level_html[0].strip()
|
||||
|
||||
join_at_html = html.xpath('//table[contains(@class, "profileViewTable")]'
|
||||
'//tr/td[text()="Registration date"]/following-sibling::td/text()')
|
||||
if join_at_html:
|
||||
self.join_at = StringUtils.unify_datetime_str(join_at_html[0].strip())
|
||||
join_at_html = html.xpath('//table[contains(@class, "profileViewTable")]'
|
||||
'//tr/td[text()="Registration date"]/following-sibling::td/text()')
|
||||
if join_at_html:
|
||||
self.join_at = StringUtils.unify_datetime_str(join_at_html[0].strip())
|
||||
|
||||
bonus_html = html.xpath('//span[contains(@class, "total-TL-points")]/text()')
|
||||
if bonus_html:
|
||||
self.bonus = StringUtils.str_float(bonus_html[0].strip())
|
||||
bonus_html = html.xpath('//span[contains(@class, "total-TL-points")]/text()')
|
||||
if bonus_html:
|
||||
self.bonus = StringUtils.str_float(bonus_html[0].strip())
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
def _parse_user_detail_info(self, html_text: str):
|
||||
pass
|
||||
@@ -67,33 +71,37 @@ class TorrentLeechSiteUserInfo(SiteParserBase):
|
||||
:return: 下页地址
|
||||
"""
|
||||
html = etree.HTML(html_text)
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return None
|
||||
try:
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return None
|
||||
|
||||
size_col = 2
|
||||
seeders_col = 7
|
||||
size_col = 2
|
||||
seeders_col = 7
|
||||
|
||||
page_seeding = 0
|
||||
page_seeding_size = 0
|
||||
page_seeding_info = []
|
||||
seeding_sizes = html.xpath(f'//tbody/tr/td[{size_col}]')
|
||||
seeding_seeders = html.xpath(f'//tbody/tr/td[{seeders_col}]/text()')
|
||||
if seeding_sizes and seeding_seeders:
|
||||
page_seeding = len(seeding_sizes)
|
||||
page_seeding = 0
|
||||
page_seeding_size = 0
|
||||
page_seeding_info = []
|
||||
seeding_sizes = html.xpath(f'//tbody/tr/td[{size_col}]')
|
||||
seeding_seeders = html.xpath(f'//tbody/tr/td[{seeders_col}]/text()')
|
||||
if seeding_sizes and seeding_seeders:
|
||||
page_seeding = len(seeding_sizes)
|
||||
|
||||
for i in range(0, len(seeding_sizes)):
|
||||
size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip())
|
||||
seeders = StringUtils.str_int(seeding_seeders[i])
|
||||
for i in range(0, len(seeding_sizes)):
|
||||
size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip())
|
||||
seeders = StringUtils.str_int(seeding_seeders[i])
|
||||
|
||||
page_seeding_size += size
|
||||
page_seeding_info.append([seeders, size])
|
||||
page_seeding_size += size
|
||||
page_seeding_info.append([seeders, size])
|
||||
|
||||
self.seeding += page_seeding
|
||||
self.seeding_size += page_seeding_size
|
||||
self.seeding_info.extend(page_seeding_info)
|
||||
self.seeding += page_seeding
|
||||
self.seeding_size += page_seeding_size
|
||||
self.seeding_info.extend(page_seeding_info)
|
||||
|
||||
# 是否存在下页数据
|
||||
next_page = None
|
||||
# 是否存在下页数据
|
||||
next_page = None
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
return next_page
|
||||
|
||||
|
||||
@@ -14,21 +14,24 @@ class Unit3dSiteUserInfo(SiteParserBase):
|
||||
def _parse_user_base_info(self, html_text: str):
|
||||
html_text = self._prepare_html_text(html_text)
|
||||
html = etree.HTML(html_text)
|
||||
try:
|
||||
tmps = html.xpath('//a[contains(@href, "/users/") and contains(@href, "settings")]/@href')
|
||||
if tmps:
|
||||
user_name_match = re.search(r"/users/(.+)/settings", tmps[0])
|
||||
if user_name_match and user_name_match.group().strip():
|
||||
self.username = user_name_match.group(1)
|
||||
self._torrent_seeding_page = f"/users/{self.username}/active?perPage=100&client=&seeding=include"
|
||||
self._user_detail_page = f"/users/{self.username}"
|
||||
|
||||
tmps = html.xpath('//a[contains(@href, "/users/") and contains(@href, "settings")]/@href')
|
||||
if tmps:
|
||||
user_name_match = re.search(r"/users/(.+)/settings", tmps[0])
|
||||
if user_name_match and user_name_match.group().strip():
|
||||
self.username = user_name_match.group(1)
|
||||
self._torrent_seeding_page = f"/users/{self.username}/active?perPage=100&client=&seeding=include"
|
||||
self._user_detail_page = f"/users/{self.username}"
|
||||
|
||||
tmps = html.xpath('//a[contains(@href, "bonus/earnings")]')
|
||||
if tmps:
|
||||
bonus_text = tmps[0].xpath("string(.)")
|
||||
bonus_match = re.search(r"([\d,.]+)", bonus_text)
|
||||
if bonus_match and bonus_match.group(1).strip():
|
||||
self.bonus = StringUtils.str_float(bonus_match.group(1))
|
||||
tmps = html.xpath('//a[contains(@href, "bonus/earnings")]')
|
||||
if tmps:
|
||||
bonus_text = tmps[0].xpath("string(.)")
|
||||
bonus_match = re.search(r"([\d,.]+)", bonus_text)
|
||||
if bonus_match and bonus_match.group(1).strip():
|
||||
self.bonus = StringUtils.str_float(bonus_match.group(1))
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
def _parse_site_page(self, html_text: str):
|
||||
pass
|
||||
@@ -40,21 +43,25 @@ class Unit3dSiteUserInfo(SiteParserBase):
|
||||
:return:
|
||||
"""
|
||||
html = etree.HTML(html_text)
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return None
|
||||
try:
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return None
|
||||
|
||||
# 用户等级
|
||||
user_levels_text = html.xpath('//div[contains(@class, "content")]//span[contains(@class, "badge-user")]/text()')
|
||||
if user_levels_text:
|
||||
self.user_level = user_levels_text[0].strip()
|
||||
# 用户等级
|
||||
user_levels_text = html.xpath('//div[contains(@class, "content")]//span[contains(@class, "badge-user")]/text()')
|
||||
if user_levels_text:
|
||||
self.user_level = user_levels_text[0].strip()
|
||||
|
||||
# 加入日期
|
||||
join_at_text = html.xpath('//div[contains(@class, "content")]//h4[contains(text(), "注册日期") '
|
||||
'or contains(text(), "註冊日期") '
|
||||
'or contains(text(), "Registration date")]/text()')
|
||||
if join_at_text:
|
||||
self.join_at = StringUtils.unify_datetime_str(
|
||||
join_at_text[0].replace('注册日期', '').replace('註冊日期', '').replace('Registration date', ''))
|
||||
# 加入日期
|
||||
join_at_text = html.xpath('//div[contains(@class, "content")]//h4[contains(text(), "注册日期") '
|
||||
'or contains(text(), "註冊日期") '
|
||||
'or contains(text(), "Registration date")]/text()')
|
||||
if join_at_text:
|
||||
self.join_at = StringUtils.unify_datetime_str(
|
||||
join_at_text[0].replace('注册日期', '').replace('註冊日期', '').replace('Registration date', ''))
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: Optional[bool] = False) -> Optional[str]:
|
||||
"""
|
||||
@@ -64,44 +71,48 @@ class Unit3dSiteUserInfo(SiteParserBase):
|
||||
:return: 下页地址
|
||||
"""
|
||||
html = etree.HTML(html_text)
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return None
|
||||
try:
|
||||
if not StringUtils.is_valid_html_element(html):
|
||||
return None
|
||||
|
||||
size_col = 9
|
||||
seeders_col = 2
|
||||
# 搜索size列
|
||||
if html.xpath('//thead//th[contains(@class,"size")]'):
|
||||
size_col = len(html.xpath('//thead//th[contains(@class,"size")][1]/preceding-sibling::th')) + 1
|
||||
# 搜索seeders列
|
||||
if html.xpath('//thead//th[contains(@class,"seeders")]'):
|
||||
seeders_col = len(html.xpath('//thead//th[contains(@class,"seeders")]/preceding-sibling::th')) + 1
|
||||
size_col = 9
|
||||
seeders_col = 2
|
||||
# 搜索size列
|
||||
if html.xpath('//thead//th[contains(@class,"size")]'):
|
||||
size_col = len(html.xpath('//thead//th[contains(@class,"size")][1]/preceding-sibling::th')) + 1
|
||||
# 搜索seeders列
|
||||
if html.xpath('//thead//th[contains(@class,"seeders")]'):
|
||||
seeders_col = len(html.xpath('//thead//th[contains(@class,"seeders")]/preceding-sibling::th')) + 1
|
||||
|
||||
page_seeding = 0
|
||||
page_seeding_size = 0
|
||||
page_seeding_info = []
|
||||
seeding_sizes = html.xpath(f'//tr[position()]/td[{size_col}]')
|
||||
seeding_seeders = html.xpath(f'//tr[position()]/td[{seeders_col}]')
|
||||
if seeding_sizes and seeding_seeders:
|
||||
page_seeding = len(seeding_sizes)
|
||||
page_seeding = 0
|
||||
page_seeding_size = 0
|
||||
page_seeding_info = []
|
||||
seeding_sizes = html.xpath(f'//tr[position()]/td[{size_col}]')
|
||||
seeding_seeders = html.xpath(f'//tr[position()]/td[{seeders_col}]')
|
||||
if seeding_sizes and seeding_seeders:
|
||||
page_seeding = len(seeding_sizes)
|
||||
|
||||
for i in range(0, len(seeding_sizes)):
|
||||
size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip())
|
||||
seeders = StringUtils.str_int(seeding_seeders[i].xpath("string(.)").strip())
|
||||
for i in range(0, len(seeding_sizes)):
|
||||
size = StringUtils.num_filesize(seeding_sizes[i].xpath("string(.)").strip())
|
||||
seeders = StringUtils.str_int(seeding_seeders[i].xpath("string(.)").strip())
|
||||
|
||||
page_seeding_size += size
|
||||
page_seeding_info.append([seeders, size])
|
||||
page_seeding_size += size
|
||||
page_seeding_info.append([seeders, size])
|
||||
|
||||
self.seeding += page_seeding
|
||||
self.seeding_size += page_seeding_size
|
||||
self.seeding_info.extend(page_seeding_info)
|
||||
self.seeding += page_seeding
|
||||
self.seeding_size += page_seeding_size
|
||||
self.seeding_info.extend(page_seeding_info)
|
||||
|
||||
# 是否存在下页数据
|
||||
next_page = None
|
||||
next_pages = html.xpath('//ul[@class="pagination"]/li[contains(@class,"active")]/following-sibling::li')
|
||||
if next_pages and len(next_pages) > 1:
|
||||
page_num = next_pages[0].xpath("string(.)").strip()
|
||||
if page_num.isdigit():
|
||||
next_page = f"{self._torrent_seeding_page}&page={page_num}"
|
||||
# 是否存在下页数据
|
||||
next_page = None
|
||||
next_pages = html.xpath('//ul[@class="pagination"]/li[contains(@class,"active")]/following-sibling::li')
|
||||
if next_pages and len(next_pages) > 1:
|
||||
page_num = next_pages[0].xpath("string(.)").strip()
|
||||
if page_num.isdigit():
|
||||
next_page = f"{self._torrent_seeding_page}&page={page_num}"
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
return next_page
|
||||
|
||||
|
||||
@@ -0,0 +1,704 @@
|
||||
import datetime
|
||||
import re
|
||||
import traceback
|
||||
from typing import Any, Optional
|
||||
from typing import List
|
||||
from urllib.parse import quote, urlencode, urlparse, parse_qs
|
||||
|
||||
from jinja2 import Template
|
||||
from pyquery import PyQuery
|
||||
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
class SiteSpider:
|
||||
"""
|
||||
站点爬虫
|
||||
"""
|
||||
|
||||
@property
|
||||
def __class__(self):
|
||||
return object
|
||||
|
||||
@property
|
||||
def __dict__(self):
|
||||
return {}
|
||||
|
||||
@property
|
||||
def __dir__(self):
|
||||
raise AttributeError(f"Cannot read protected attribute!")
|
||||
|
||||
def __init__(self,
|
||||
indexer: dict,
|
||||
keyword: Optional[str] = None,
|
||||
mtype: MediaType = None,
|
||||
cat: Optional[str] = None,
|
||||
page: Optional[int] = 0,
|
||||
referer: Optional[str] = None):
|
||||
"""
|
||||
设置查询参数
|
||||
:param indexer: 索引器
|
||||
:param keyword: 搜索关键字,如果数组则为批量搜索
|
||||
:param mtype: 媒体类型
|
||||
:param cat: 搜索分类
|
||||
:param page: 页码
|
||||
:param referer: Referer
|
||||
"""
|
||||
if not indexer:
|
||||
return
|
||||
self.keyword = keyword
|
||||
self.cat = cat
|
||||
self.mtype = mtype
|
||||
self.indexerid = indexer.get('id')
|
||||
self.indexername = indexer.get('name')
|
||||
self.search = indexer.get('search')
|
||||
self.batch = indexer.get('batch')
|
||||
self.browse = indexer.get('browse')
|
||||
self.category = indexer.get('category')
|
||||
self.list = indexer.get('torrents').get('list', {})
|
||||
self.fields = indexer.get('torrents').get('fields')
|
||||
if not keyword and self.browse:
|
||||
self.list = self.browse.get('list') or self.list
|
||||
self.fields = self.browse.get('fields') or self.fields
|
||||
self.domain = indexer.get('domain')
|
||||
self.result_num = int(indexer.get('result_num') or 100)
|
||||
self._timeout = int(indexer.get('timeout') or 15)
|
||||
self.page = page
|
||||
if self.domain and not str(self.domain).endswith("/"):
|
||||
self.domain = self.domain + "/"
|
||||
self.ua = indexer.get('ua') or settings.USER_AGENT
|
||||
self.proxies = settings.PROXY if indexer.get('proxy') else None
|
||||
self.proxy_server = settings.PROXY_SERVER if indexer.get('proxy') else None
|
||||
self.cookie = indexer.get('cookie')
|
||||
self.referer = referer
|
||||
# 初始化属性
|
||||
self.is_error = False
|
||||
self.torrents_info = {}
|
||||
self.torrents_info_array = []
|
||||
|
||||
def get_torrents(self) -> List[dict]:
|
||||
"""
|
||||
开始请求
|
||||
"""
|
||||
if not self.search or not self.domain:
|
||||
return []
|
||||
|
||||
# 种子搜索相对路径
|
||||
paths = self.search.get('paths', [])
|
||||
torrentspath = ""
|
||||
if len(paths) == 1:
|
||||
torrentspath = paths[0].get('path', '')
|
||||
else:
|
||||
for path in paths:
|
||||
if path.get("type") == "all" and not self.mtype:
|
||||
torrentspath = path.get('path')
|
||||
break
|
||||
elif path.get("type") == "movie" and self.mtype == MediaType.MOVIE:
|
||||
torrentspath = path.get('path')
|
||||
break
|
||||
elif path.get("type") == "tv" and self.mtype == MediaType.TV:
|
||||
torrentspath = path.get('path')
|
||||
break
|
||||
|
||||
# 精确搜索
|
||||
if self.keyword:
|
||||
if isinstance(self.keyword, list):
|
||||
# 批量查询
|
||||
if self.batch:
|
||||
delimiter = self.batch.get('delimiter') or ' '
|
||||
space_replace = self.batch.get('space_replace') or ' '
|
||||
search_word = delimiter.join([str(k).replace(' ',
|
||||
space_replace) for k in self.keyword])
|
||||
else:
|
||||
search_word = " ".join(self.keyword)
|
||||
# 查询模式:或
|
||||
search_mode = "1"
|
||||
else:
|
||||
# 单个查询
|
||||
search_word = self.keyword
|
||||
# 查询模式与
|
||||
search_mode = "0"
|
||||
|
||||
# 搜索URL
|
||||
indexer_params = self.search.get("params", {}).copy()
|
||||
if indexer_params:
|
||||
search_area = indexer_params.get('search_area')
|
||||
# search_area非0表示支持imdbid搜索
|
||||
if (search_area and
|
||||
(not self.keyword or not self.keyword.startswith('tt'))):
|
||||
# 支持imdbid搜索,但关键字不是imdbid时,不启用imdbid搜索
|
||||
indexer_params.pop('search_area')
|
||||
# 变量字典
|
||||
inputs_dict = {
|
||||
"keyword": search_word
|
||||
}
|
||||
# 查询参数,默认查询标题
|
||||
params = {
|
||||
"search_mode": search_mode,
|
||||
"search_area": 0,
|
||||
"page": self.page or 0,
|
||||
"notnewword": 1
|
||||
}
|
||||
# 额外参数
|
||||
for key, value in indexer_params.items():
|
||||
params.update({
|
||||
"%s" % key: str(value).format(**inputs_dict)
|
||||
})
|
||||
# 分类条件
|
||||
if self.category:
|
||||
if self.mtype == MediaType.TV:
|
||||
cats = self.category.get("tv") or []
|
||||
elif self.mtype == MediaType.MOVIE:
|
||||
cats = self.category.get("movie") or []
|
||||
else:
|
||||
cats = (self.category.get("movie") or []) + (self.category.get("tv") or [])
|
||||
for cat in cats:
|
||||
if self.cat and str(cat.get("id")) not in self.cat:
|
||||
continue
|
||||
if self.category.get("field"):
|
||||
value = params.get(self.category.get("field"), "")
|
||||
params.update({
|
||||
"%s" % self.category.get("field"): value + self.category.get("delimiter",
|
||||
' ') + cat.get("id")
|
||||
})
|
||||
else:
|
||||
params.update({
|
||||
"cat%s" % cat.get("id"): 1
|
||||
})
|
||||
searchurl = self.domain + torrentspath + "?" + urlencode(params)
|
||||
else:
|
||||
# 变量字典
|
||||
inputs_dict = {
|
||||
"keyword": quote(search_word),
|
||||
"page": self.page or 0
|
||||
}
|
||||
# 无额外参数
|
||||
searchurl = self.domain + str(torrentspath).format(**inputs_dict)
|
||||
|
||||
# 列表浏览
|
||||
else:
|
||||
# 变量字典
|
||||
inputs_dict = {
|
||||
"page": self.page or 0,
|
||||
"keyword": ""
|
||||
}
|
||||
# 有单独浏览路径
|
||||
if self.browse:
|
||||
torrentspath = self.browse.get("path")
|
||||
if self.browse.get("start"):
|
||||
start_page = int(self.browse.get("start")) + int(self.page or 0)
|
||||
inputs_dict.update({
|
||||
"page": start_page
|
||||
})
|
||||
elif self.page:
|
||||
torrentspath = torrentspath + f"?page={self.page}"
|
||||
# 搜索Url
|
||||
searchurl = self.domain + str(torrentspath).format(**inputs_dict)
|
||||
|
||||
logger.info(f"开始请求:{searchurl}")
|
||||
|
||||
# requests请求
|
||||
ret = RequestUtils(
|
||||
ua=self.ua,
|
||||
cookies=self.cookie,
|
||||
timeout=self._timeout,
|
||||
referer=self.referer,
|
||||
proxies=self.proxies
|
||||
).get_res(searchurl, allow_redirects=True)
|
||||
# 解析返回
|
||||
return self.parse(
|
||||
RequestUtils.get_decoded_html_content(
|
||||
ret,
|
||||
performance_mode=settings.ENCODING_DETECTION_PERFORMANCE_MODE,
|
||||
confidence_threshold=settings.ENCODING_DETECTION_MIN_CONFIDENCE
|
||||
)
|
||||
)
|
||||
|
||||
def __get_title(self, torrent: Any):
|
||||
# title default text
|
||||
if 'title' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('title', {})
|
||||
if 'selector' in selector:
|
||||
self.torrents_info['title'] = self._safe_query(torrent, selector)
|
||||
elif 'text' in selector:
|
||||
render_dict = {}
|
||||
if "title_default" in self.fields:
|
||||
title_default_selector = self.fields.get('title_default', {})
|
||||
title_default = self._safe_query(torrent, title_default_selector)
|
||||
render_dict.update({'title_default': title_default})
|
||||
if "title_optional" in self.fields:
|
||||
title_optional_selector = self.fields.get('title_optional', {})
|
||||
title_optional = self._safe_query(torrent, title_optional_selector)
|
||||
render_dict.update({'title_optional': title_optional})
|
||||
self.torrents_info['title'] = Template(selector.get('text')).render(fields=render_dict)
|
||||
self.torrents_info['title'] = self.__filter_text(self.torrents_info.get('title'),
|
||||
selector.get('filters'))
|
||||
|
||||
def __get_description(self, torrent: Any):
|
||||
# description text
|
||||
if 'description' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('description', {})
|
||||
if "selector" in selector or "selectors" in selector:
|
||||
# 对于selectors情况,需要特殊处理selector_config
|
||||
desc_selector = selector.copy()
|
||||
if "selectors" in selector and "selector" not in selector:
|
||||
desc_selector["selector"] = selector.get("selectors", "")
|
||||
self.torrents_info['description'] = self._safe_query(torrent, desc_selector)
|
||||
elif "text" in selector:
|
||||
render_dict = {}
|
||||
if "tags" in self.fields:
|
||||
tags_selector = self.fields.get('tags', {})
|
||||
tag = self._safe_query(torrent, tags_selector)
|
||||
render_dict.update({'tags': tag})
|
||||
if "subject" in self.fields:
|
||||
subject_selector = self.fields.get('subject', {})
|
||||
subject = self._safe_query(torrent, subject_selector)
|
||||
render_dict.update({'subject': subject})
|
||||
if "description_free_forever" in self.fields:
|
||||
description_free_forever_selector = self.fields.get("description_free_forever", {})
|
||||
description_free_forever = self._safe_query(torrent, description_free_forever_selector)
|
||||
render_dict.update({"description_free_forever": description_free_forever})
|
||||
if "description_normal" in self.fields:
|
||||
description_normal_selector = self.fields.get("description_normal", {})
|
||||
description_normal = self._safe_query(torrent, description_normal_selector)
|
||||
render_dict.update({"description_normal": description_normal})
|
||||
self.torrents_info['description'] = Template(selector.get('text')).render(fields=render_dict)
|
||||
self.torrents_info['description'] = self.__filter_text(self.torrents_info.get('description'),
|
||||
selector.get('filters'))
|
||||
|
||||
def __get_detail(self, torrent: Any):
|
||||
# details page text
|
||||
if 'details' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('details', {})
|
||||
item = self._safe_query(torrent, selector)
|
||||
detail_link = self.__filter_text(item, selector.get('filters'))
|
||||
if detail_link:
|
||||
if not detail_link.startswith("http"):
|
||||
if detail_link.startswith("//"):
|
||||
self.torrents_info['page_url'] = self.domain.split(":")[0] + ":" + detail_link
|
||||
elif detail_link.startswith("/"):
|
||||
self.torrents_info['page_url'] = self.domain + detail_link[1:]
|
||||
else:
|
||||
self.torrents_info['page_url'] = self.domain + detail_link
|
||||
else:
|
||||
self.torrents_info['page_url'] = detail_link
|
||||
|
||||
def __get_download(self, torrent: Any):
|
||||
# download link text
|
||||
if 'download' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('download', {})
|
||||
item = self._safe_query(torrent, selector)
|
||||
download_link = self.__filter_text(item, selector.get('filters'))
|
||||
if download_link:
|
||||
if not download_link.startswith("http") \
|
||||
and not download_link.startswith("magnet"):
|
||||
_scheme, _domain = StringUtils.get_url_netloc(self.domain)
|
||||
if _domain in download_link:
|
||||
if download_link.startswith("/"):
|
||||
self.torrents_info['enclosure'] = f"{_scheme}:{download_link}"
|
||||
else:
|
||||
self.torrents_info['enclosure'] = f"{_scheme}://{download_link}"
|
||||
else:
|
||||
if download_link.startswith("/"):
|
||||
self.torrents_info['enclosure'] = f"{self.domain}{download_link[1:]}"
|
||||
else:
|
||||
self.torrents_info['enclosure'] = f"{self.domain}{download_link}"
|
||||
else:
|
||||
self.torrents_info['enclosure'] = download_link
|
||||
|
||||
def __get_imdbid(self, torrent: Any):
|
||||
# imdbid
|
||||
if "imdbid" not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('imdbid', {})
|
||||
item = self._safe_query(torrent, selector)
|
||||
self.torrents_info['imdbid'] = self.__filter_text(item, selector.get('filters'))
|
||||
|
||||
def __get_size(self, torrent: Any):
|
||||
# torrent size int
|
||||
if 'size' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('size', {})
|
||||
item = self._safe_query(torrent, selector)
|
||||
if item:
|
||||
size_val = item.replace("\n", "").strip()
|
||||
size_val = self.__filter_text(size_val,
|
||||
selector.get('filters'))
|
||||
self.torrents_info['size'] = StringUtils.num_filesize(size_val)
|
||||
else:
|
||||
self.torrents_info['size'] = 0
|
||||
|
||||
def __get_leechers(self, torrent: Any):
|
||||
# torrent leechers int
|
||||
if 'leechers' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('leechers', {})
|
||||
item = self._safe_query(torrent, selector)
|
||||
if item:
|
||||
peers_val = item.split("/")[0]
|
||||
peers_val = peers_val.replace(",", "")
|
||||
peers_val = self.__filter_text(peers_val, selector.get('filters'))
|
||||
self.torrents_info['peers'] = int(peers_val) if peers_val and peers_val.isdigit() else 0
|
||||
else:
|
||||
self.torrents_info['peers'] = 0
|
||||
|
||||
def __get_seeders(self, torrent: Any):
|
||||
# torrent seeders int
|
||||
if 'seeders' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('seeders', {})
|
||||
item = self._safe_query(torrent, selector)
|
||||
if item:
|
||||
seeders_val = item.split("/")[0]
|
||||
seeders_val = seeders_val.replace(",", "")
|
||||
seeders_val = self.__filter_text(seeders_val, selector.get('filters'))
|
||||
self.torrents_info['seeders'] = int(seeders_val) if seeders_val and seeders_val.isdigit() else 0
|
||||
else:
|
||||
self.torrents_info['seeders'] = 0
|
||||
|
||||
def __get_grabs(self, torrent: Any):
|
||||
# torrent grabs int
|
||||
if 'grabs' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('grabs', {})
|
||||
item = self._safe_query(torrent, selector)
|
||||
if item:
|
||||
grabs_val = item.split("/")[0]
|
||||
grabs_val = grabs_val.replace(",", "")
|
||||
grabs_val = self.__filter_text(grabs_val, selector.get('filters'))
|
||||
self.torrents_info['grabs'] = int(grabs_val) if grabs_val and grabs_val.isdigit() else 0
|
||||
else:
|
||||
self.torrents_info['grabs'] = 0
|
||||
|
||||
def __get_pubdate(self, torrent: Any):
|
||||
# torrent pubdate yyyy-mm-dd hh:mm:ss
|
||||
if 'date_added' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('date_added', {})
|
||||
pubdate_str = self._safe_query(torrent, selector)
|
||||
if pubdate_str:
|
||||
pubdate_str = pubdate_str.replace('\n', ' ').strip()
|
||||
self.torrents_info['pubdate'] = self.__filter_text(pubdate_str, selector.get('filters'))
|
||||
|
||||
def __get_date_elapsed(self, torrent: Any):
|
||||
# torrent date elapsed text
|
||||
if 'date_elapsed' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('date_elapsed', {})
|
||||
date_elapsed = self._safe_query(torrent, selector)
|
||||
self.torrents_info['date_elapsed'] = self.__filter_text(date_elapsed, selector.get('filters'))
|
||||
|
||||
def __get_downloadvolumefactor(self, torrent: Any):
|
||||
# downloadvolumefactor int
|
||||
selector = self.fields.get('downloadvolumefactor', {})
|
||||
if not selector:
|
||||
return
|
||||
self.torrents_info['downloadvolumefactor'] = 1
|
||||
if 'case' in selector:
|
||||
for downloadvolumefactorselector in list(selector.get('case', {}).keys()):
|
||||
downloadvolumefactor = torrent(downloadvolumefactorselector)
|
||||
try:
|
||||
if len(downloadvolumefactor) > 0:
|
||||
self.torrents_info['downloadvolumefactor'] = selector.get('case', {}).get(
|
||||
downloadvolumefactorselector)
|
||||
break
|
||||
finally:
|
||||
downloadvolumefactor.clear()
|
||||
del downloadvolumefactor
|
||||
elif "selector" in selector:
|
||||
item = self._safe_query(torrent, selector)
|
||||
if item:
|
||||
downloadvolumefactor = re.search(r'(\d+\.?\d*)', item)
|
||||
if downloadvolumefactor:
|
||||
self.torrents_info['downloadvolumefactor'] = int(downloadvolumefactor.group(1))
|
||||
|
||||
def __get_uploadvolumefactor(self, torrent: Any):
|
||||
# uploadvolumefactor int
|
||||
selector = self.fields.get('uploadvolumefactor', {})
|
||||
if not selector:
|
||||
return
|
||||
self.torrents_info['uploadvolumefactor'] = 1
|
||||
if 'case' in selector:
|
||||
for uploadvolumefactorselector in list(selector.get('case', {}).keys()):
|
||||
uploadvolumefactor = torrent(uploadvolumefactorselector)
|
||||
try:
|
||||
if len(uploadvolumefactor) > 0:
|
||||
self.torrents_info['uploadvolumefactor'] = selector.get('case', {}).get(
|
||||
uploadvolumefactorselector)
|
||||
break
|
||||
finally:
|
||||
uploadvolumefactor.clear()
|
||||
del uploadvolumefactor
|
||||
elif "selector" in selector:
|
||||
item = self._safe_query(torrent, selector)
|
||||
if item:
|
||||
uploadvolumefactor = re.search(r'(\d+\.?\d*)', item)
|
||||
if uploadvolumefactor:
|
||||
self.torrents_info['uploadvolumefactor'] = int(uploadvolumefactor.group(1))
|
||||
|
||||
def __get_labels(self, torrent: Any):
|
||||
# labels ['label1', 'label2']
|
||||
if 'labels' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('labels', {})
|
||||
if not selector.get('selector'):
|
||||
self.torrents_info['labels'] = []
|
||||
return
|
||||
|
||||
# labels需要特殊处理,因为它返回的是列表
|
||||
labels = torrent(selector.get("selector", "")).clone()
|
||||
try:
|
||||
self.__remove(labels, selector)
|
||||
items = self.__attribute_or_text(labels, selector)
|
||||
if items:
|
||||
self.torrents_info['labels'] = [item for item in items if item]
|
||||
else:
|
||||
self.torrents_info['labels'] = []
|
||||
finally:
|
||||
labels.clear()
|
||||
del labels
|
||||
|
||||
def __get_free_date(self, torrent: Any):
|
||||
# free date yyyy-mm-dd hh:mm:ss
|
||||
if 'freedate' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('freedate', {})
|
||||
freedate = self._safe_query(torrent, selector)
|
||||
self.torrents_info['freedate'] = self.__filter_text(freedate, selector.get('filters'))
|
||||
|
||||
def __get_hit_and_run(self, torrent: Any):
|
||||
# hitandrun True/False
|
||||
if 'hr' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('hr', {})
|
||||
hit_and_run = torrent(selector.get('selector', ''))
|
||||
try:
|
||||
if hit_and_run:
|
||||
self.torrents_info['hit_and_run'] = True
|
||||
else:
|
||||
self.torrents_info['hit_and_run'] = False
|
||||
finally:
|
||||
hit_and_run.clear()
|
||||
del hit_and_run
|
||||
|
||||
def __get_category(self, torrent: Any):
|
||||
# category 电影/电视剧
|
||||
if 'category' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('category', {})
|
||||
category_value = self._safe_query(torrent, selector)
|
||||
category_value = self.__filter_text(category_value, selector.get('filters'))
|
||||
if category_value and self.category:
|
||||
tv_cats = [str(cat.get("id")) for cat in self.category.get("tv") or []]
|
||||
movie_cats = [str(cat.get("id")) for cat in self.category.get("movie") or []]
|
||||
if category_value in tv_cats \
|
||||
and category_value not in movie_cats:
|
||||
self.torrents_info['category'] = MediaType.TV.value
|
||||
elif category_value in movie_cats:
|
||||
self.torrents_info['category'] = MediaType.MOVIE.value
|
||||
else:
|
||||
self.torrents_info['category'] = MediaType.UNKNOWN.value
|
||||
else:
|
||||
self.torrents_info['category'] = MediaType.UNKNOWN.value
|
||||
|
||||
def _safe_query(self, torrent: Any, selector_config: Optional[dict]) -> Optional[str]:
|
||||
"""
|
||||
安全地执行PyQuery查询并自动清理资源
|
||||
:param torrent: PyQuery对象
|
||||
:param selector_config: 选择器配置
|
||||
:return: 处理后的结果
|
||||
"""
|
||||
if not selector_config or not selector_config.get('selector'):
|
||||
return None
|
||||
|
||||
query_obj = torrent(selector_config.get('selector', '')).clone()
|
||||
try:
|
||||
self.__remove(query_obj, selector_config)
|
||||
items = self.__attribute_or_text(query_obj, selector_config)
|
||||
return self.__index(items, selector_config)
|
||||
finally:
|
||||
query_obj.clear()
|
||||
del query_obj
|
||||
|
||||
def get_info(self, torrent: Any) -> dict:
|
||||
"""
|
||||
解析单条种子数据
|
||||
"""
|
||||
# 每次调用时重新初始化,避免数据累积
|
||||
self.torrents_info = {}
|
||||
try:
|
||||
# 标题
|
||||
self.__get_title(torrent)
|
||||
# 描述
|
||||
self.__get_description(torrent)
|
||||
# 详情页面
|
||||
self.__get_detail(torrent)
|
||||
# 下载链接
|
||||
self.__get_download(torrent)
|
||||
# 完成数
|
||||
self.__get_grabs(torrent)
|
||||
# 下载数
|
||||
self.__get_leechers(torrent)
|
||||
# 做种数
|
||||
self.__get_seeders(torrent)
|
||||
# 大小
|
||||
self.__get_size(torrent)
|
||||
# IMDBID
|
||||
self.__get_imdbid(torrent)
|
||||
# 下载系数
|
||||
self.__get_downloadvolumefactor(torrent)
|
||||
# 上传系数
|
||||
self.__get_uploadvolumefactor(torrent)
|
||||
# 发布时间
|
||||
self.__get_pubdate(torrent)
|
||||
# 已发布时间
|
||||
self.__get_date_elapsed(torrent)
|
||||
# 免费载止时间
|
||||
self.__get_free_date(torrent)
|
||||
# 标签
|
||||
self.__get_labels(torrent)
|
||||
# HR
|
||||
self.__get_hit_and_run(torrent)
|
||||
# 分类
|
||||
self.__get_category(torrent)
|
||||
# 返回当前种子信息的副本,而不是引用
|
||||
return self.torrents_info.copy() if self.torrents_info else {}
|
||||
except Exception as err:
|
||||
logger.error("%s 搜索出现错误:%s" % (self.indexername, str(err)))
|
||||
return {}
|
||||
finally:
|
||||
self.torrents_info.clear()
|
||||
|
||||
@staticmethod
|
||||
def __filter_text(text: Optional[str], filters: Optional[List[dict]]) -> str:
|
||||
"""
|
||||
对文件进行处理
|
||||
"""
|
||||
if not text or not filters or not isinstance(filters, list):
|
||||
return text
|
||||
if not isinstance(text, str):
|
||||
text = str(text)
|
||||
for filter_item in filters:
|
||||
if not text:
|
||||
break
|
||||
method_name = filter_item.get("name")
|
||||
try:
|
||||
args = filter_item.get("args")
|
||||
if method_name == "re_search" and isinstance(args, list):
|
||||
rematch = re.search(r"%s" % args[0], text)
|
||||
if rematch:
|
||||
text = rematch.group(args[-1])
|
||||
elif method_name == "split" and isinstance(args, list):
|
||||
text = text.split(r"%s" % args[0])[args[-1]]
|
||||
elif method_name == "replace" and isinstance(args, list):
|
||||
text = text.replace(r"%s" % args[0], r"%s" % args[-1])
|
||||
elif method_name == "dateparse" and isinstance(args, str):
|
||||
text = text.replace("\n", " ").strip()
|
||||
text = datetime.datetime.strptime(text, r"%s" % args)
|
||||
elif method_name == "strip":
|
||||
text = text.strip()
|
||||
elif method_name == "appendleft":
|
||||
text = f"{args}{text}"
|
||||
elif method_name == "querystring":
|
||||
parsed_url = urlparse(str(text))
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
param_value = query_params.get(args)
|
||||
text = param_value[0] if param_value else ''
|
||||
except Exception as err:
|
||||
logger.debug(f'过滤器 {method_name} 处理失败:{str(err)} - {traceback.format_exc()}')
|
||||
return text.strip()
|
||||
|
||||
@staticmethod
|
||||
def __remove(item: Any, selector: Optional[dict]):
|
||||
"""
|
||||
移除元素
|
||||
"""
|
||||
if selector and "remove" in selector:
|
||||
removelist = selector.get('remove', '').split(', ')
|
||||
for v in removelist:
|
||||
item.remove(v)
|
||||
|
||||
@staticmethod
|
||||
def __attribute_or_text(item: Any, selector: Optional[dict]) -> list:
|
||||
if not selector:
|
||||
return item
|
||||
if not item:
|
||||
return []
|
||||
if 'attribute' in selector:
|
||||
items = [i.attr(selector.get('attribute')) for i in item.items() if i]
|
||||
else:
|
||||
items = [i.text() for i in item.items() if i]
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
def __index(items: Optional[list], selector: Optional[dict]) -> Optional[str]:
|
||||
if not items:
|
||||
return None
|
||||
if selector:
|
||||
if "contents" in selector \
|
||||
and len(items) > int(selector.get("contents")):
|
||||
item = items[0].split("\n")[selector.get("contents")]
|
||||
elif "index" in selector \
|
||||
and len(items) > int(selector.get("index")):
|
||||
item = items[int(selector.get("index"))]
|
||||
else:
|
||||
item = items[0]
|
||||
else:
|
||||
item = items[0]
|
||||
return item
|
||||
|
||||
def parse(self, html_text: str) -> List[dict]:
|
||||
"""
|
||||
解析整个页面
|
||||
"""
|
||||
if not html_text:
|
||||
self.is_error = True
|
||||
return []
|
||||
|
||||
# 清空旧结果
|
||||
self.torrents_info_array = []
|
||||
html_doc = None
|
||||
try:
|
||||
# 解析站点文本对象
|
||||
html_doc = PyQuery(html_text)
|
||||
# 种子筛选器
|
||||
torrents_selector = self.list.get('selector', '')
|
||||
# 遍历种子html列表
|
||||
for i, torn in enumerate(html_doc(torrents_selector)):
|
||||
if i >= int(self.result_num):
|
||||
break
|
||||
# 创建临时PyQuery对象进行解析
|
||||
torrent_query = PyQuery(torn)
|
||||
try:
|
||||
# 直接获取种子信息,避免深拷贝
|
||||
torrent_info = self.get_info(torrent_query)
|
||||
if torrent_info:
|
||||
# 浅拷贝即可,减少内存使用
|
||||
self.torrents_info_array.append(torrent_info)
|
||||
finally:
|
||||
# 显式删除临时PyQuery对象
|
||||
torrent_query.clear()
|
||||
del torrent_query
|
||||
# 返回数组的副本,防止被后续清理操作影响
|
||||
return self.torrents_info_array.copy()
|
||||
except Exception as err:
|
||||
self.is_error = True
|
||||
logger.warn(f"错误:{self.indexername} {str(err)}")
|
||||
return []
|
||||
finally:
|
||||
# 清理种子缓存
|
||||
self.torrents_info_array.clear()
|
||||
# 清理HTML文档对象
|
||||
if html_doc is not None:
|
||||
html_doc.clear()
|
||||
del html_doc
|
||||
# 清理html_text引用
|
||||
del html_text
|
||||
|
||||
@@ -30,6 +30,7 @@ class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]):
|
||||
event_data: schemas.ConfigChangeEventData = event.event_data
|
||||
if event_data.key not in [SystemConfigKey.MediaServers.value]:
|
||||
return
|
||||
logger.info("配置变更,重新初始化Jellyfin模块...")
|
||||
self.init_module()
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -30,6 +30,7 @@ class PlexModule(_ModuleBase, _MediaServerBase[Plex]):
|
||||
event_data: schemas.ConfigChangeEventData = event.event_data
|
||||
if event_data.key not in [SystemConfigKey.MediaServers.value]:
|
||||
return
|
||||
logger.info("配置变更,重新初始化Plex模块...")
|
||||
self.init_module()
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -84,7 +84,7 @@ class Plex:
|
||||
logger.error(f"Authentication failed: {e}")
|
||||
return None
|
||||
|
||||
@cached(maxsize=100, ttl=86400)
|
||||
@cached(maxsize=32, ttl=86400)
|
||||
def __get_library_images(self, library_key: str, mtype: int) -> Optional[List[str]]:
|
||||
"""
|
||||
获取媒体服务器最近添加的媒体的图片列表
|
||||
@@ -788,7 +788,7 @@ class Plex:
|
||||
|
||||
# 合并排序
|
||||
for hub in hubs:
|
||||
for item in hub.items:
|
||||
for item in hub.items():
|
||||
sub_result.append(item)
|
||||
sub_result.sort(key=lambda x: x.addedAt, reverse=True)
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
|
||||
event_data: schemas.ConfigChangeEventData = event.event_data
|
||||
if event_data.key not in [SystemConfigKey.Downloaders.value]:
|
||||
return
|
||||
logger.info("配置变更,重新加载Qbittorrent模块...")
|
||||
self.init_module()
|
||||
|
||||
@staticmethod
|
||||
@@ -165,19 +166,23 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
|
||||
if error:
|
||||
return None, None, None, "无法连接qbittorrent下载器"
|
||||
if torrents:
|
||||
for torrent in torrents:
|
||||
# 名称与大小相等则认为是同一个种子
|
||||
if torrent.get("name") == torrent_name and torrent.get("total_size") == torrent_size:
|
||||
torrent_hash = torrent.get("hash")
|
||||
torrent_tags = [str(tag).strip() for tag in torrent.get("tags").split(',')]
|
||||
logger.warn(f"下载器中已存在该种子任务:{torrent_hash} - {torrent.get('name')}")
|
||||
# 给种子打上标签
|
||||
if "已整理" in torrent_tags:
|
||||
server.remove_torrents_tag(ids=torrent_hash, tag=['已整理'])
|
||||
if settings.TORRENT_TAG and settings.TORRENT_TAG not in torrent_tags:
|
||||
logger.info(f"给种子 {torrent_hash} 打上标签:{settings.TORRENT_TAG}")
|
||||
server.set_torrents_tag(ids=torrent_hash, tags=[settings.TORRENT_TAG])
|
||||
return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, f"下载任务已存在"
|
||||
try:
|
||||
for torrent in torrents:
|
||||
# 名称与大小相等则认为是同一个种子
|
||||
if torrent.get("name") == torrent_name and torrent.get("total_size") == torrent_size:
|
||||
torrent_hash = torrent.get("hash")
|
||||
torrent_tags = [str(tag).strip() for tag in torrent.get("tags").split(',')]
|
||||
logger.warn(f"下载器中已存在该种子任务:{torrent_hash} - {torrent.get('name')}")
|
||||
# 给种子打上标签
|
||||
if "已整理" in torrent_tags:
|
||||
server.remove_torrents_tag(ids=torrent_hash, tag=['已整理'])
|
||||
if settings.TORRENT_TAG and settings.TORRENT_TAG not in torrent_tags:
|
||||
logger.info(f"给种子 {torrent_hash} 打上标签:{settings.TORRENT_TAG}")
|
||||
server.set_torrents_tag(ids=torrent_hash, tags=[settings.TORRENT_TAG])
|
||||
return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, f"下载任务已存在"
|
||||
finally:
|
||||
torrents.clear()
|
||||
del torrents
|
||||
return None, None, None, f"添加种子任务失败:{content}"
|
||||
else:
|
||||
# 获取种子Hash
|
||||
@@ -195,16 +200,19 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
|
||||
file_ids = []
|
||||
# 需要的集清单
|
||||
sucess_epidised = []
|
||||
|
||||
for torrent_file in torrent_files:
|
||||
file_id = torrent_file.get("id")
|
||||
file_name = torrent_file.get("name")
|
||||
meta_info = MetaInfo(file_name)
|
||||
if not meta_info.episode_list \
|
||||
or not set(meta_info.episode_list).issubset(episodes):
|
||||
file_ids.append(file_id)
|
||||
else:
|
||||
sucess_epidised = list(set(sucess_epidised).union(set(meta_info.episode_list)))
|
||||
try:
|
||||
for torrent_file in torrent_files:
|
||||
file_id = torrent_file.get("id")
|
||||
file_name = torrent_file.get("name")
|
||||
meta_info = MetaInfo(file_name)
|
||||
if not meta_info.episode_list \
|
||||
or not set(meta_info.episode_list).issubset(episodes):
|
||||
file_ids.append(file_id)
|
||||
else:
|
||||
sucess_epidised = list(set(sucess_epidised).union(set(meta_info.episode_list)))
|
||||
finally:
|
||||
torrent_files.clear()
|
||||
del torrent_files
|
||||
if sucess_epidised and file_ids:
|
||||
# 选择文件
|
||||
server.set_files(torrent_hash=torrent_hash, file_ids=file_ids, priority=0)
|
||||
@@ -243,69 +251,82 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
|
||||
if hashs:
|
||||
# 按Hash获取
|
||||
for name, server in servers.items():
|
||||
torrents, _ = server.get_torrents(ids=hashs, tags=settings.TORRENT_TAG)
|
||||
for torrent in torrents or []:
|
||||
content_path = torrent.get("content_path")
|
||||
if content_path:
|
||||
torrent_path = Path(content_path)
|
||||
else:
|
||||
torrent_path = Path(torrent.get('save_path')) / torrent.get('name')
|
||||
ret_torrents.append(TransferTorrent(
|
||||
downloader=name,
|
||||
title=torrent.get('name'),
|
||||
path=torrent_path,
|
||||
hash=torrent.get('hash'),
|
||||
size=torrent.get('total_size'),
|
||||
tags=torrent.get('tags'),
|
||||
progress=torrent.get('progress') * 100,
|
||||
state="paused" if torrent.get('state') in ("paused", "pausedDL") else "downloading",
|
||||
))
|
||||
torrents, _ = server.get_torrents(ids=hashs, tags=settings.TORRENT_TAG) or []
|
||||
try:
|
||||
for torrent in torrents:
|
||||
content_path = torrent.get("content_path")
|
||||
if content_path:
|
||||
torrent_path = Path(content_path)
|
||||
else:
|
||||
torrent_path = Path(torrent.get('save_path')) / torrent.get('name')
|
||||
ret_torrents.append(TransferTorrent(
|
||||
downloader=name,
|
||||
title=torrent.get('name'),
|
||||
path=torrent_path,
|
||||
hash=torrent.get('hash'),
|
||||
size=torrent.get('total_size'),
|
||||
tags=torrent.get('tags'),
|
||||
progress=torrent.get('progress') * 100,
|
||||
state="paused" if torrent.get('state') in ("paused", "pausedDL") else "downloading",
|
||||
))
|
||||
finally:
|
||||
torrents.clear()
|
||||
del torrents
|
||||
elif status == TorrentStatus.TRANSFER:
|
||||
# 获取已完成且未整理的
|
||||
for name, server in servers.items():
|
||||
torrents = server.get_completed_torrents(tags=settings.TORRENT_TAG)
|
||||
for torrent in torrents or []:
|
||||
tags = torrent.get("tags") or []
|
||||
if "已整理" in tags:
|
||||
continue
|
||||
# 内容路径
|
||||
content_path = torrent.get("content_path")
|
||||
if content_path:
|
||||
torrent_path = Path(content_path)
|
||||
else:
|
||||
torrent_path = torrent.get('save_path') / torrent.get('name')
|
||||
ret_torrents.append(TransferTorrent(
|
||||
downloader=name,
|
||||
title=torrent.get('name'),
|
||||
path=torrent_path,
|
||||
hash=torrent.get('hash'),
|
||||
tags=torrent.get('tags')
|
||||
))
|
||||
torrents = server.get_completed_torrents(tags=settings.TORRENT_TAG) or []
|
||||
try:
|
||||
for torrent in torrents:
|
||||
tags = torrent.get("tags") or []
|
||||
if "已整理" in tags:
|
||||
continue
|
||||
# 内容路径
|
||||
content_path = torrent.get("content_path")
|
||||
if content_path:
|
||||
torrent_path = Path(content_path)
|
||||
else:
|
||||
torrent_path = torrent.get('save_path') / torrent.get('name')
|
||||
ret_torrents.append(TransferTorrent(
|
||||
downloader=name,
|
||||
title=torrent.get('name'),
|
||||
path=torrent_path,
|
||||
hash=torrent.get('hash'),
|
||||
tags=torrent.get('tags')
|
||||
))
|
||||
finally:
|
||||
torrents.clear()
|
||||
del torrents
|
||||
elif status == TorrentStatus.DOWNLOADING:
|
||||
# 获取正在下载的任务
|
||||
for name, server in servers.items():
|
||||
torrents = server.get_downloading_torrents(tags=settings.TORRENT_TAG)
|
||||
for torrent in torrents or []:
|
||||
meta = MetaInfo(torrent.get('name'))
|
||||
ret_torrents.append(DownloadingTorrent(
|
||||
downloader=name,
|
||||
hash=torrent.get('hash'),
|
||||
title=torrent.get('name'),
|
||||
name=meta.name,
|
||||
year=meta.year,
|
||||
season_episode=meta.season_episode,
|
||||
progress=torrent.get('progress') * 100,
|
||||
size=torrent.get('total_size'),
|
||||
state="paused" if torrent.get('state') in ("paused", "pausedDL") else "downloading",
|
||||
dlspeed=StringUtils.str_filesize(torrent.get('dlspeed')),
|
||||
upspeed=StringUtils.str_filesize(torrent.get('upspeed')),
|
||||
left_time=StringUtils.str_secends(
|
||||
(torrent.get('total_size') - torrent.get('completed')) / torrent.get(
|
||||
'dlspeed')) if torrent.get(
|
||||
'dlspeed') > 0 else ''
|
||||
))
|
||||
torrents = server.get_downloading_torrents(tags=settings.TORRENT_TAG) or []
|
||||
try:
|
||||
for torrent in torrents:
|
||||
meta = MetaInfo(torrent.get('name'))
|
||||
ret_torrents.append(DownloadingTorrent(
|
||||
downloader=name,
|
||||
hash=torrent.get('hash'),
|
||||
title=torrent.get('name'),
|
||||
name=meta.name,
|
||||
year=meta.year,
|
||||
season_episode=meta.season_episode,
|
||||
progress=torrent.get('progress') * 100,
|
||||
size=torrent.get('total_size'),
|
||||
state="paused" if torrent.get('state') in ("paused", "pausedDL") else "downloading",
|
||||
dlspeed=StringUtils.str_filesize(torrent.get('dlspeed')),
|
||||
upspeed=StringUtils.str_filesize(torrent.get('upspeed')),
|
||||
left_time=StringUtils.str_secends(
|
||||
(torrent.get('total_size') - torrent.get('completed')) / torrent.get(
|
||||
'dlspeed')) if torrent.get(
|
||||
'dlspeed') > 0 else ''
|
||||
))
|
||||
finally:
|
||||
torrents.clear()
|
||||
del torrents
|
||||
else:
|
||||
return None
|
||||
return ret_torrents # noqa
|
||||
|
||||
def transfer_completed(self, hashs: str, downloader: Optional[str] = None) -> None:
|
||||
"""
|
||||
|
||||
@@ -12,16 +12,9 @@ from app.utils.string import StringUtils
|
||||
|
||||
|
||||
class Qbittorrent:
|
||||
_host: Optional[str] = None
|
||||
_port: int = None
|
||||
_username: Optional[str] = None
|
||||
_password: Optional[str] = None
|
||||
_category: Optional[bool] = False
|
||||
_sequentail: Optional[bool] = False
|
||||
_force_resume: Optional[bool] = False
|
||||
|
||||
qbc: Client = None
|
||||
|
||||
"""
|
||||
qbittorrent下载器
|
||||
"""
|
||||
def __init__(self, host: Optional[str] = None, port: int = None,
|
||||
username: Optional[str] = None, password: Optional[str] = None,
|
||||
category: Optional[bool] = False, sequentail: Optional[bool] = False,
|
||||
@@ -43,8 +36,7 @@ class Qbittorrent:
|
||||
self._sequentail = sequentail
|
||||
self._force_resume = force_resume
|
||||
self._first_last_piece = first_last_piece
|
||||
if self._host and self._port:
|
||||
self.qbc = self.__login_qbittorrent()
|
||||
self.qbc = self.__login_qbittorrent()
|
||||
|
||||
def is_inactive(self) -> bool:
|
||||
"""
|
||||
@@ -65,6 +57,8 @@ class Qbittorrent:
|
||||
连接qbittorrent
|
||||
:return: qbittorrent对象
|
||||
"""
|
||||
if not self._host or not self._port:
|
||||
return None
|
||||
try:
|
||||
# 登录
|
||||
logger.info(f"正在连接 qbittorrent:{self._host}:{self._port}")
|
||||
@@ -104,10 +98,14 @@ class Qbittorrent:
|
||||
results = []
|
||||
if not isinstance(tags, list):
|
||||
tags = [tags]
|
||||
for torrent in torrents:
|
||||
torrent_tags = [str(tag).strip() for tag in torrent.get("tags").split(',')]
|
||||
if set(tags).issubset(set(torrent_tags)):
|
||||
results.append(torrent)
|
||||
try:
|
||||
for torrent in torrents:
|
||||
torrent_tags = [str(tag).strip() for tag in torrent.get("tags").split(',')]
|
||||
if set(tags).issubset(set(torrent_tags)):
|
||||
results.append(torrent)
|
||||
finally:
|
||||
torrents.clear()
|
||||
del torrents
|
||||
return results, False
|
||||
return torrents or [], False
|
||||
except Exception as err:
|
||||
|
||||
@@ -32,6 +32,7 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
|
||||
event_data: ConfigChangeEventData = event.event_data
|
||||
if event_data.key not in [SystemConfigKey.Notifications.value]:
|
||||
return
|
||||
logger.info("配置变更,重新加载Slack模块...")
|
||||
self.init_module()
|
||||
|
||||
@staticmethod
|
||||
@@ -81,8 +82,7 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
pass
|
||||
|
||||
def message_parser(self, source: str, body: Any, form: Any,
|
||||
args: Any) -> Optional[CommingMessage]:
|
||||
def message_parser(self, source: str, body: Any, form: Any, args: Any) -> Optional[CommingMessage]:
|
||||
"""
|
||||
解析消息内容,返回字典,注意以下约定值:
|
||||
userid: 用户ID
|
||||
@@ -219,8 +219,32 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
|
||||
username = msg_json.get("user")
|
||||
elif msg_json.get("type") == "block_actions":
|
||||
userid = msg_json.get("user", {}).get("id")
|
||||
text = msg_json.get("actions")[0].get("value")
|
||||
callback_data = msg_json.get("actions")[0].get("value")
|
||||
# 使用CALLBACK前缀标识按钮回调
|
||||
text = f"CALLBACK:{callback_data}"
|
||||
username = msg_json.get("user", {}).get("name")
|
||||
|
||||
# 获取原消息信息用于编辑
|
||||
message_info = msg_json.get("message", {})
|
||||
# Slack消息的时间戳作为消息ID
|
||||
message_ts = message_info.get("ts")
|
||||
channel_id = msg_json.get("channel", {}).get("id") or msg_json.get("container", {}).get("channel_id")
|
||||
|
||||
logger.info(f"收到来自 {client_config.name} 的Slack按钮回调:"
|
||||
f"userid={userid}, username={username}, callback_data={callback_data}")
|
||||
|
||||
# 创建包含回调信息的CommingMessage
|
||||
return CommingMessage(
|
||||
channel=MessageChannel.Slack,
|
||||
source=client_config.name,
|
||||
userid=userid,
|
||||
username=username,
|
||||
text=text,
|
||||
is_callback=True,
|
||||
callback_data=callback_data,
|
||||
message_id=message_ts,
|
||||
chat_id=channel_id
|
||||
)
|
||||
elif msg_json.get("type") == "event_callback":
|
||||
userid = msg_json.get('event', {}).get('user')
|
||||
text = re.sub(r"<@[0-9A-Z]+>", "", msg_json.get("event", {}).get("text"), flags=re.IGNORECASE).strip()
|
||||
@@ -259,7 +283,10 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
|
||||
client: Slack = self.get_instance(conf.name)
|
||||
if client:
|
||||
client.send_msg(title=message.title, text=message.text,
|
||||
image=message.image, userid=userid, link=message.link)
|
||||
image=message.image, userid=userid, link=message.link,
|
||||
buttons=message.buttons,
|
||||
original_message_id=message.original_message_id,
|
||||
original_chat_id=message.original_chat_id)
|
||||
|
||||
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:
|
||||
"""
|
||||
@@ -273,7 +300,10 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
|
||||
continue
|
||||
client: Slack = self.get_instance(conf.name)
|
||||
if client:
|
||||
client.send_medias_msg(title=message.title, medias=medias, userid=message.userid)
|
||||
client.send_medias_msg(title=message.title, medias=medias, userid=message.userid,
|
||||
buttons=message.buttons,
|
||||
original_message_id=message.original_message_id,
|
||||
original_chat_id=message.original_chat_id)
|
||||
|
||||
def post_torrents_message(self, message: Notification, torrents: List[Context]) -> None:
|
||||
"""
|
||||
@@ -288,4 +318,29 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
|
||||
client: Slack = self.get_instance(conf.name)
|
||||
if client:
|
||||
client.send_torrents_msg(title=message.title, torrents=torrents,
|
||||
userid=message.userid)
|
||||
userid=message.userid, buttons=message.buttons,
|
||||
original_message_id=message.original_message_id,
|
||||
original_chat_id=message.original_chat_id)
|
||||
|
||||
def delete_message(self, channel: MessageChannel, source: str,
|
||||
message_id: str, chat_id: Optional[str] = None) -> bool:
|
||||
"""
|
||||
删除消息
|
||||
:param channel: 消息渠道
|
||||
:param source: 指定的消息源
|
||||
:param message_id: 消息ID(Slack中为时间戳)
|
||||
:param chat_id: 聊天ID(频道ID)
|
||||
:return: 删除是否成功
|
||||
"""
|
||||
success = False
|
||||
for conf in self.get_configs().values():
|
||||
if channel != self._channel:
|
||||
break
|
||||
if source != conf.name:
|
||||
continue
|
||||
client: Slack = self.get_instance(conf.name)
|
||||
if client:
|
||||
result = client.delete_msg(message_id=message_id, chat_id=chat_id)
|
||||
if result:
|
||||
success = True
|
||||
return success
|
||||
|
||||
@@ -13,18 +13,16 @@ from app.core.metainfo import MetaInfo
|
||||
from app.log import logger
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
lock = Lock()
|
||||
|
||||
|
||||
class Slack:
|
||||
|
||||
_client: WebClient = None
|
||||
_service: SocketModeHandler = None
|
||||
_ds_url = f"http://127.0.0.1:{settings.PORT}/api/v1/message?token={settings.API_TOKEN}"
|
||||
_channel = ""
|
||||
|
||||
def __init__(self, SLACK_OAUTH_TOKEN: Optional[str] = None, SLACK_APP_TOKEN: Optional[str] = None,
|
||||
def __init__(self, SLACK_OAUTH_TOKEN: Optional[str] = None, SLACK_APP_TOKEN: Optional[str] = None,
|
||||
SLACK_CHANNEL: Optional[str] = None, **kwargs):
|
||||
|
||||
if not SLACK_OAUTH_TOKEN or not SLACK_APP_TOKEN:
|
||||
@@ -52,7 +50,7 @@ class Slack:
|
||||
with requests.post(self._ds_url, json=message, timeout=10) as local_res:
|
||||
logger.debug("message: %s processed, response is: %s" % (message, local_res.text))
|
||||
|
||||
@slack_app.action(re.compile(r"actionId-\d+"))
|
||||
@slack_app.action(re.compile(r"actionId-.*"))
|
||||
def slack_action(ack, body):
|
||||
ack()
|
||||
with requests.post(self._ds_url, json=body, timeout=60) as local_res:
|
||||
@@ -101,15 +99,21 @@ class Slack:
|
||||
"""
|
||||
return True if self._client else False
|
||||
|
||||
def send_msg(self, title: str, text: Optional[str] = None, image: Optional[str] = None, link: Optional[str] = None, userid: Optional[str] = None):
|
||||
def send_msg(self, title: str, text: Optional[str] = None,
|
||||
image: Optional[str] = None, link: Optional[str] = None,
|
||||
userid: Optional[str] = None, buttons: Optional[List[List[dict]]] = None,
|
||||
original_message_id: Optional[str] = None,
|
||||
original_chat_id: Optional[str] = None):
|
||||
"""
|
||||
发送Telegram消息
|
||||
发送Slack消息
|
||||
:param title: 消息标题
|
||||
:param text: 消息内容
|
||||
:param image: 消息图片地址
|
||||
:param link: 点击消息转转的URL
|
||||
:param userid: 用户ID,如有则只发消息给该用户
|
||||
:user_id: 发送消息的目标用户ID,为空则发给管理员
|
||||
:param buttons: 消息按钮列表,格式为 [[{"text": "按钮文本", "callback_data": "回调数据", "url": "链接"}]]
|
||||
:param original_message_id: 原消息的时间戳,如果提供则编辑原消息
|
||||
:param original_chat_id: 原消息的频道ID,编辑消息时需要
|
||||
"""
|
||||
if not self._client:
|
||||
return False, "消息客户端未就绪"
|
||||
@@ -139,8 +143,42 @@ class Slack:
|
||||
"image_url": f"{image}",
|
||||
"alt_text": f"{title}"
|
||||
}})
|
||||
# 链接
|
||||
if link:
|
||||
# 自定义按钮
|
||||
if buttons:
|
||||
for button_row in buttons:
|
||||
elements = []
|
||||
for button in button_row:
|
||||
if "url" in button:
|
||||
# URL按钮
|
||||
elements.append({
|
||||
"type": "button",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": button["text"],
|
||||
"emoji": True
|
||||
},
|
||||
"url": button["url"],
|
||||
"action_id": f"actionId-url-{button.get('text', 'url')}-{len(elements)}"
|
||||
})
|
||||
else:
|
||||
# 回调按钮
|
||||
elements.append({
|
||||
"type": "button",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": button["text"],
|
||||
"emoji": True
|
||||
},
|
||||
"value": button["callback_data"],
|
||||
"action_id": f"actionId-{button['callback_data']}"
|
||||
})
|
||||
if elements:
|
||||
blocks.append({
|
||||
"type": "actions",
|
||||
"elements": elements
|
||||
})
|
||||
elif link:
|
||||
# 默认链接按钮
|
||||
blocks.append({
|
||||
"type": "actions",
|
||||
"elements": [
|
||||
@@ -157,21 +195,41 @@ class Slack:
|
||||
}
|
||||
]
|
||||
})
|
||||
# 发送
|
||||
result = self._client.chat_postMessage(
|
||||
channel=channel,
|
||||
text=message_text[:1000],
|
||||
blocks=blocks,
|
||||
mrkdwn=True
|
||||
)
|
||||
|
||||
# 判断是编辑消息还是发送新消息
|
||||
if original_message_id and original_chat_id:
|
||||
# 编辑消息
|
||||
result = self._client.chat_update(
|
||||
channel=original_chat_id,
|
||||
ts=original_message_id,
|
||||
text=message_text[:1000],
|
||||
blocks=blocks or []
|
||||
)
|
||||
else:
|
||||
# 发送新消息
|
||||
result = self._client.chat_postMessage(
|
||||
channel=channel,
|
||||
text=message_text[:1000],
|
||||
blocks=blocks,
|
||||
mrkdwn=True
|
||||
)
|
||||
return True, result
|
||||
except Exception as msg_e:
|
||||
logger.error(f"Slack消息发送失败: {msg_e}")
|
||||
return False, str(msg_e)
|
||||
|
||||
def send_medias_msg(self, medias: List[MediaInfo], userid: Optional[str] = None, title: Optional[str] = None) -> Optional[bool]:
|
||||
def send_medias_msg(self, medias: List[MediaInfo], userid: Optional[str] = None, title: Optional[str] = None,
|
||||
buttons: Optional[List[List[dict]]] = None,
|
||||
original_message_id: Optional[str] = None,
|
||||
original_chat_id: Optional[str] = None) -> Optional[bool]:
|
||||
"""
|
||||
发送列表类消息
|
||||
发送媒体列表消息
|
||||
:param medias: 媒体信息列表
|
||||
:param userid: 用户ID,如有则只发消息给该用户
|
||||
:param title: 消息标题
|
||||
:param buttons: 按钮列表,格式:[[{"text": "按钮文本", "callback_data": "回调数据"}]]
|
||||
:param original_message_id: 原消息的时间戳,如果提供则编辑原消息
|
||||
:param original_chat_id: 原消息的频道ID,编辑消息时需要
|
||||
"""
|
||||
if not self._client:
|
||||
return False
|
||||
@@ -198,64 +256,148 @@ class Slack:
|
||||
"type": "divider"
|
||||
})
|
||||
index = 1
|
||||
for media in medias:
|
||||
if media.get_poster_image():
|
||||
if media.vote_star:
|
||||
text = f"{index}. *<{media.detail_link}|{media.title_year}>*" \
|
||||
f"\n类型:{media.type.value}" \
|
||||
f"\n{media.vote_star}" \
|
||||
f"\n{media.get_overview_string(50)}"
|
||||
else:
|
||||
text = f"{index}. *<{media.detail_link}|{media.title_year}>*" \
|
||||
f"\n类型:{media.type.value}" \
|
||||
f"\n{media.get_overview_string(50)}"
|
||||
blocks.append(
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": text
|
||||
},
|
||||
"accessory": {
|
||||
"type": "image",
|
||||
"image_url": f"{media.get_poster_image()}",
|
||||
"alt_text": f"{media.title_year}"
|
||||
}
|
||||
}
|
||||
)
|
||||
blocks.append(
|
||||
{
|
||||
"type": "actions",
|
||||
"elements": [
|
||||
{
|
||||
"type": "button",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "选择",
|
||||
"emoji": True
|
||||
},
|
||||
"value": f"{index}",
|
||||
"action_id": f"actionId-{index}"
|
||||
|
||||
# 如果有自定义按钮,先添加所有媒体项,然后添加统一的按钮
|
||||
if buttons:
|
||||
# 添加媒体列表(不带单独的选择按钮)
|
||||
for media in medias:
|
||||
if media.get_poster_image():
|
||||
if media.vote_star:
|
||||
text = f"{index}. *<{media.detail_link}|{media.title_year}>*" \
|
||||
f"\n类型:{media.type.value}" \
|
||||
f"\n{media.vote_star}" \
|
||||
f"\n{media.get_overview_string(50)}"
|
||||
else:
|
||||
text = f"{index}. *<{media.detail_link}|{media.title_year}>*" \
|
||||
f"\n类型:{media.type.value}" \
|
||||
f"\n{media.get_overview_string(50)}"
|
||||
blocks.append(
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": text
|
||||
},
|
||||
"accessory": {
|
||||
"type": "image",
|
||||
"image_url": f"{media.get_poster_image()}",
|
||||
"alt_text": f"{media.title_year}"
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
index += 1
|
||||
# 发送
|
||||
result = self._client.chat_postMessage(
|
||||
channel=channel,
|
||||
text=title,
|
||||
blocks=blocks
|
||||
)
|
||||
}
|
||||
)
|
||||
index += 1
|
||||
|
||||
# 添加统一的自定义按钮(在所有媒体项之后)
|
||||
for button_row in buttons:
|
||||
elements = []
|
||||
for button in button_row:
|
||||
if "url" in button:
|
||||
elements.append({
|
||||
"type": "button",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": button["text"],
|
||||
"emoji": True
|
||||
},
|
||||
"url": button["url"],
|
||||
"action_id": f"actionId-url-{button.get('text', 'url')}-{len(elements)}"
|
||||
})
|
||||
else:
|
||||
elements.append({
|
||||
"type": "button",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": button["text"],
|
||||
"emoji": True
|
||||
},
|
||||
"value": button["callback_data"],
|
||||
"action_id": f"actionId-{button['callback_data']}"
|
||||
})
|
||||
if elements:
|
||||
blocks.append({
|
||||
"type": "actions",
|
||||
"elements": elements
|
||||
})
|
||||
else:
|
||||
# 使用默认的每个媒体项单独按钮
|
||||
for media in medias:
|
||||
if media.get_poster_image():
|
||||
if media.vote_star:
|
||||
text = f"{index}. *<{media.detail_link}|{media.title_year}>*" \
|
||||
f"\n类型:{media.type.value}" \
|
||||
f"\n{media.vote_star}" \
|
||||
f"\n{media.get_overview_string(50)}"
|
||||
else:
|
||||
text = f"{index}. *<{media.detail_link}|{media.title_year}>*" \
|
||||
f"\n类型:{media.type.value}" \
|
||||
f"\n{media.get_overview_string(50)}"
|
||||
blocks.append(
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": text
|
||||
},
|
||||
"accessory": {
|
||||
"type": "image",
|
||||
"image_url": f"{media.get_poster_image()}",
|
||||
"alt_text": f"{media.title_year}"
|
||||
}
|
||||
}
|
||||
)
|
||||
# 使用默认选择按钮
|
||||
blocks.append(
|
||||
{
|
||||
"type": "actions",
|
||||
"elements": [
|
||||
{
|
||||
"type": "button",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "选择",
|
||||
"emoji": True
|
||||
},
|
||||
"value": f"{index}",
|
||||
"action_id": f"actionId-{index}"
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
index += 1
|
||||
|
||||
# 判断是编辑消息还是发送新消息
|
||||
if original_message_id and original_chat_id:
|
||||
# 编辑消息
|
||||
result = self._client.chat_update(
|
||||
channel=original_chat_id,
|
||||
ts=original_message_id,
|
||||
text=title,
|
||||
blocks=blocks or []
|
||||
)
|
||||
else:
|
||||
# 发送新消息
|
||||
result = self._client.chat_postMessage(
|
||||
channel=channel,
|
||||
text=title,
|
||||
blocks=blocks
|
||||
)
|
||||
return True if result else False
|
||||
except Exception as msg_e:
|
||||
logger.error(f"Slack消息发送失败: {msg_e}")
|
||||
return False
|
||||
|
||||
def send_torrents_msg(self, torrents: List[Context],
|
||||
userid: Optional[str] = None, title: Optional[str] = None) -> Optional[bool]:
|
||||
def send_torrents_msg(self, torrents: List[Context], userid: Optional[str] = None, title: Optional[str] = None,
|
||||
buttons: Optional[List[List[dict]]] = None,
|
||||
original_message_id: Optional[str] = None,
|
||||
original_chat_id: Optional[str] = None) -> Optional[bool]:
|
||||
"""
|
||||
发送列表消息
|
||||
发送种子列表消息
|
||||
:param torrents: 种子信息列表
|
||||
:param userid: 用户ID,如有则只发消息给该用户
|
||||
:param title: 消息标题
|
||||
:param buttons: 按钮列表,格式:[[{"text": "按钮文本", "callback_data": "回调数据"}]]
|
||||
:param original_message_id: 原消息的时间戳,如果提供则编辑原消息
|
||||
:param original_chat_id: 原消息的频道ID,编辑消息时需要
|
||||
"""
|
||||
if not self._client:
|
||||
return None
|
||||
@@ -279,60 +421,172 @@ class Slack:
|
||||
}]
|
||||
# 列表
|
||||
index = 1
|
||||
for context in torrents:
|
||||
torrent = context.torrent_info
|
||||
site_name = torrent.site_name
|
||||
meta = MetaInfo(torrent.title, torrent.description)
|
||||
link = torrent.page_url
|
||||
title = f"{meta.season_episode} " \
|
||||
f"{meta.resource_term} " \
|
||||
f"{meta.video_term} " \
|
||||
f"{meta.release_group}"
|
||||
title = re.sub(r"\s+", " ", title).strip()
|
||||
free = torrent.volume_factor
|
||||
seeder = f"{torrent.seeders}↑"
|
||||
description = torrent.description
|
||||
text = f"{index}. 【{site_name}】<{link}|{title}> " \
|
||||
f"{StringUtils.str_filesize(torrent.size)} {free} {seeder}\n" \
|
||||
f"{description}"
|
||||
blocks.append(
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": text
|
||||
|
||||
# 如果有自定义按钮,先添加种子列表,然后添加统一的按钮
|
||||
if buttons:
|
||||
# 添加种子列表(不带单独的选择按钮)
|
||||
for context in torrents:
|
||||
torrent = context.torrent_info
|
||||
site_name = torrent.site_name
|
||||
meta = MetaInfo(torrent.title, torrent.description)
|
||||
link = torrent.page_url
|
||||
title_text = f"{meta.season_episode} " \
|
||||
f"{meta.resource_term} " \
|
||||
f"{meta.video_term} " \
|
||||
f"{meta.release_group}"
|
||||
title_text = re.sub(r"\s+", " ", title_text).strip()
|
||||
free = torrent.volume_factor
|
||||
seeder = f"{torrent.seeders}↑"
|
||||
description = torrent.description
|
||||
text = f"{index}. 【{site_name}】<{link}|{title_text}> " \
|
||||
f"{StringUtils.str_filesize(torrent.size)} {free} {seeder}\n" \
|
||||
f"{description}"
|
||||
blocks.append(
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": text
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
blocks.append(
|
||||
{
|
||||
"type": "actions",
|
||||
"elements": [
|
||||
{
|
||||
)
|
||||
index += 1
|
||||
|
||||
# 添加统一的自定义按钮
|
||||
for button_row in buttons:
|
||||
elements = []
|
||||
for button in button_row:
|
||||
if "url" in button:
|
||||
elements.append({
|
||||
"type": "button",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "选择",
|
||||
"text": button["text"],
|
||||
"emoji": True
|
||||
},
|
||||
"value": f"{index}",
|
||||
"action_id": f"actionId-{index}"
|
||||
"url": button["url"],
|
||||
"action_id": f"actionId-url-{button.get('text', 'url')}-{len(elements)}"
|
||||
})
|
||||
else:
|
||||
elements.append({
|
||||
"type": "button",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": button["text"],
|
||||
"emoji": True
|
||||
},
|
||||
"value": button["callback_data"],
|
||||
"action_id": f"actionId-{button['callback_data']}"
|
||||
})
|
||||
if elements:
|
||||
blocks.append({
|
||||
"type": "actions",
|
||||
"elements": elements
|
||||
})
|
||||
else:
|
||||
# 使用默认的每个种子单独按钮
|
||||
for context in torrents:
|
||||
torrent = context.torrent_info
|
||||
site_name = torrent.site_name
|
||||
meta = MetaInfo(torrent.title, torrent.description)
|
||||
link = torrent.page_url
|
||||
title_text = f"{meta.season_episode} " \
|
||||
f"{meta.resource_term} " \
|
||||
f"{meta.video_term} " \
|
||||
f"{meta.release_group}"
|
||||
title_text = re.sub(r"\s+", " ", title_text).strip()
|
||||
free = torrent.volume_factor
|
||||
seeder = f"{torrent.seeders}↑"
|
||||
description = torrent.description
|
||||
text = f"{index}. 【{site_name}】<{link}|{title_text}> " \
|
||||
f"{StringUtils.str_filesize(torrent.size)} {free} {seeder}\n" \
|
||||
f"{description}"
|
||||
blocks.append(
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": text
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
blocks.append(
|
||||
{
|
||||
"type": "actions",
|
||||
"elements": [
|
||||
{
|
||||
"type": "button",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "选择",
|
||||
"emoji": True
|
||||
},
|
||||
"value": f"{index}",
|
||||
"action_id": f"actionId-{index}"
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
index += 1
|
||||
|
||||
# 判断是编辑消息还是发送新消息
|
||||
if original_message_id and original_chat_id:
|
||||
# 编辑消息
|
||||
result = self._client.chat_update(
|
||||
channel=original_chat_id,
|
||||
ts=original_message_id,
|
||||
text=title,
|
||||
blocks=blocks or []
|
||||
)
|
||||
else:
|
||||
# 发送新消息
|
||||
result = self._client.chat_postMessage(
|
||||
channel=channel,
|
||||
text=title,
|
||||
blocks=blocks
|
||||
)
|
||||
index += 1
|
||||
# 发送
|
||||
result = self._client.chat_postMessage(
|
||||
channel=channel,
|
||||
text=title,
|
||||
blocks=blocks
|
||||
)
|
||||
return True if result else False
|
||||
except Exception as msg_e:
|
||||
logger.error(f"Slack消息发送失败: {msg_e}")
|
||||
return False
|
||||
|
||||
def delete_msg(self, message_id: str, chat_id: Optional[str] = None) -> Optional[bool]:
|
||||
"""
|
||||
删除Slack消息
|
||||
:param message_id: 消息时间戳(Slack消息ID)
|
||||
:param chat_id: 频道ID
|
||||
:return: 删除是否成功
|
||||
"""
|
||||
if not self._client:
|
||||
return None
|
||||
|
||||
try:
|
||||
# 确定要删除消息的频道ID
|
||||
if chat_id:
|
||||
target_channel = chat_id
|
||||
else:
|
||||
target_channel = self.__find_public_channel()
|
||||
|
||||
if not target_channel:
|
||||
logger.error("无法确定要删除消息的Slack频道")
|
||||
return False
|
||||
|
||||
# 删除消息
|
||||
result = self._client.chat_delete(
|
||||
channel=target_channel,
|
||||
ts=message_id
|
||||
)
|
||||
|
||||
if result.get("ok"):
|
||||
logger.info(f"成功删除Slack消息: channel={target_channel}, ts={message_id}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"删除Slack消息失败: {result.get('error', 'unknown error')}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"删除Slack消息异常: {str(e)}")
|
||||
return False
|
||||
|
||||
def __find_public_channel(self):
|
||||
"""
|
||||
查找公共频道
|
||||
|
||||
@@ -104,20 +104,24 @@ class SubtitleModule(_ModuleBase):
|
||||
logger.warn(f"读取页面代码失败:{torrent.page_url}")
|
||||
return
|
||||
html = etree.HTML(res.text)
|
||||
sublink_list = []
|
||||
for xpath in self._SITE_SUBTITLE_XPATH:
|
||||
sublinks = html.xpath(xpath)
|
||||
if sublinks:
|
||||
for sublink in sublinks:
|
||||
if not sublink:
|
||||
continue
|
||||
if not sublink.startswith("http"):
|
||||
base_url = StringUtils.get_base_url(torrent.page_url)
|
||||
if sublink.startswith("/"):
|
||||
sublink = "%s%s" % (base_url, sublink)
|
||||
else:
|
||||
sublink = "%s/%s" % (base_url, sublink)
|
||||
sublink_list.append(sublink)
|
||||
try:
|
||||
sublink_list = []
|
||||
for xpath in self._SITE_SUBTITLE_XPATH:
|
||||
sublinks = html.xpath(xpath)
|
||||
if sublinks:
|
||||
for sublink in sublinks:
|
||||
if not sublink:
|
||||
continue
|
||||
if not sublink.startswith("http"):
|
||||
base_url = StringUtils.get_base_url(torrent.page_url)
|
||||
if sublink.startswith("/"):
|
||||
sublink = "%s%s" % (base_url, sublink)
|
||||
else:
|
||||
sublink = "%s/%s" % (base_url, sublink)
|
||||
sublink_list.append(sublink)
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
# 下载所有字幕文件
|
||||
for sublink in sublink_list:
|
||||
logger.info(f"找到字幕下载链接:{sublink},开始下载...")
|
||||
|
||||
@@ -30,6 +30,7 @@ class SynologyChatModule(_ModuleBase, _MessageBase[SynologyChat]):
|
||||
event_data: ConfigChangeEventData = event.event_data
|
||||
if event_data.key not in [SystemConfigKey.Notifications.value]:
|
||||
return
|
||||
logger.info("配置变更,重新加载SynologyChat模块...")
|
||||
self.init_module()
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -9,7 +9,8 @@ from app.core.event import eventmanager
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase, _MessageBase
|
||||
from app.modules.telegram.telegram import Telegram
|
||||
from app.schemas import MessageChannel, CommingMessage, Notification, CommandRegisterEventData, ConfigChangeEventData
|
||||
from app.schemas import MessageChannel, CommingMessage, Notification, CommandRegisterEventData, ConfigChangeEventData, \
|
||||
NotificationConf
|
||||
from app.schemas.types import ModuleType, ChainEventType, SystemConfigKey, EventType
|
||||
from app.utils.structures import DictUtils
|
||||
|
||||
@@ -35,6 +36,7 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
||||
event_data: ConfigChangeEventData = event.event_data
|
||||
if event_data.key not in [SystemConfigKey.Notifications.value]:
|
||||
return
|
||||
logger.info("配置变更,重新加载Telegram模块...")
|
||||
self.init_module()
|
||||
|
||||
@staticmethod
|
||||
@@ -98,6 +100,7 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
||||
:return: 渠道、消息体
|
||||
"""
|
||||
"""
|
||||
普通消息格式:
|
||||
{
|
||||
'update_id': ,
|
||||
'message': {
|
||||
@@ -119,6 +122,16 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
||||
'text': ''
|
||||
}
|
||||
}
|
||||
|
||||
按钮回调格式:
|
||||
{
|
||||
'callback_query': {
|
||||
'id': '',
|
||||
'from': {...},
|
||||
'message': {...},
|
||||
'data': 'callback_data'
|
||||
}
|
||||
}
|
||||
"""
|
||||
# 获取服务配置
|
||||
client_config = self.get_config(source)
|
||||
@@ -130,32 +143,88 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
||||
except Exception as err:
|
||||
logger.debug(f"解析Telegram消息失败:{str(err)}")
|
||||
return None
|
||||
|
||||
if message:
|
||||
text = message.get("text")
|
||||
user_id = message.get("from", {}).get("id")
|
||||
# 获取用户名
|
||||
user_name = message.get("from", {}).get("username")
|
||||
if text:
|
||||
logger.info(f"收到来自 {client_config.name} 的Telegram消息:"
|
||||
f"userid={user_id}, username={user_name}, text={text}")
|
||||
# 检查权限
|
||||
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")
|
||||
if text.startswith("/"):
|
||||
if admin_users \
|
||||
and str(user_id) not in admin_users.split(',') \
|
||||
and str(user_id) != chat_id:
|
||||
client.send_msg(title="只有管理员才有权限执行此命令", userid=user_id)
|
||||
return None
|
||||
else:
|
||||
if user_list \
|
||||
and not str(user_id) in user_list.split(','):
|
||||
logger.info(f"用户{user_id}不在用户白名单中,无法使用此机器人")
|
||||
client.send_msg(title="你不在用户白名单中,无法使用此机器人", userid=user_id)
|
||||
return None
|
||||
return CommingMessage(channel=MessageChannel.Telegram, source=client_config.name,
|
||||
userid=user_id, username=user_name, text=text)
|
||||
# 处理按钮回调
|
||||
if "callback_query" in message:
|
||||
return self._handle_callback_query(message, client_config)
|
||||
|
||||
# 处理普通消息
|
||||
return self._handle_text_message(message, client_config, client)
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _handle_callback_query(message: dict, client_config: NotificationConf) -> Optional[CommingMessage]:
|
||||
"""
|
||||
处理按钮回调查询
|
||||
"""
|
||||
callback_query = message.get("callback_query", {})
|
||||
user_info = callback_query.get("from", {})
|
||||
callback_data = callback_query.get("data", "")
|
||||
user_id = user_info.get("id")
|
||||
user_name = user_info.get("username")
|
||||
|
||||
if callback_data and user_id:
|
||||
logger.info(f"收到来自 {client_config.name} 的Telegram按钮回调:"
|
||||
f"userid={user_id}, username={user_name}, callback_data={callback_data}")
|
||||
|
||||
# 将callback_data作为特殊格式的text返回,以便主程序识别这是按钮回调
|
||||
callback_text = f"CALLBACK:{callback_data}"
|
||||
|
||||
# 创建包含完整回调信息的CommingMessage
|
||||
return CommingMessage(
|
||||
channel=MessageChannel.Telegram,
|
||||
source=client_config.name,
|
||||
userid=user_id,
|
||||
username=user_name,
|
||||
text=callback_text,
|
||||
is_callback=True,
|
||||
callback_data=callback_data,
|
||||
message_id=callback_query.get("message", {}).get("message_id"),
|
||||
chat_id=str(callback_query.get("message", {}).get("chat", {}).get("id", "")),
|
||||
callback_query=callback_query
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _handle_text_message(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")
|
||||
|
||||
if text and user_id:
|
||||
logger.info(f"收到来自 {client_config.name} 的Telegram消息:"
|
||||
f"userid={user_id}, username={user_name}, text={text}")
|
||||
|
||||
# 检查权限
|
||||
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")
|
||||
|
||||
if text.startswith("/"):
|
||||
if admin_users \
|
||||
and str(user_id) not in admin_users.split(',') \
|
||||
and str(user_id) != chat_id:
|
||||
client.send_msg(title="只有管理员才有权限执行此命令", userid=user_id)
|
||||
return None
|
||||
else:
|
||||
if user_list \
|
||||
and str(user_id) not in user_list.split(','):
|
||||
logger.info(f"用户{user_id}不在用户白名单中,无法使用此机器人")
|
||||
client.send_msg(title="你不在用户白名单中,无法使用此机器人", userid=user_id)
|
||||
return None
|
||||
|
||||
return CommingMessage(
|
||||
channel=MessageChannel.Telegram,
|
||||
source=client_config.name,
|
||||
userid=user_id,
|
||||
username=user_name,
|
||||
text=text
|
||||
)
|
||||
return None
|
||||
|
||||
def post_message(self, message: Notification) -> None:
|
||||
@@ -177,7 +246,10 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
||||
client: Telegram = self.get_instance(conf.name)
|
||||
if client:
|
||||
client.send_msg(title=message.title, text=message.text,
|
||||
image=message.image, userid=userid, link=message.link)
|
||||
image=message.image, userid=userid, link=message.link,
|
||||
buttons=message.buttons,
|
||||
original_message_id=message.original_message_id,
|
||||
original_chat_id=message.original_chat_id)
|
||||
|
||||
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:
|
||||
"""
|
||||
@@ -192,7 +264,10 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
||||
client: Telegram = self.get_instance(conf.name)
|
||||
if client:
|
||||
client.send_medias_msg(title=message.title, medias=medias,
|
||||
userid=message.userid, link=message.link)
|
||||
userid=message.userid, link=message.link,
|
||||
buttons=message.buttons,
|
||||
original_message_id=message.original_message_id,
|
||||
original_chat_id=message.original_chat_id)
|
||||
|
||||
def post_torrents_message(self, message: Notification, torrents: List[Context]) -> None:
|
||||
"""
|
||||
@@ -207,7 +282,33 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
||||
client: Telegram = self.get_instance(conf.name)
|
||||
if client:
|
||||
client.send_torrents_msg(title=message.title, torrents=torrents,
|
||||
userid=message.userid, link=message.link)
|
||||
userid=message.userid, link=message.link,
|
||||
buttons=message.buttons,
|
||||
original_message_id=message.original_message_id,
|
||||
original_chat_id=message.original_chat_id)
|
||||
|
||||
def delete_message(self, channel: MessageChannel, source: str,
|
||||
message_id: int, chat_id: Optional[int] = None) -> bool:
|
||||
"""
|
||||
删除消息
|
||||
:param channel: 消息渠道
|
||||
:param source: 指定的消息源
|
||||
:param message_id: 消息ID
|
||||
:param chat_id: 聊天ID
|
||||
:return: 删除是否成功
|
||||
"""
|
||||
success = False
|
||||
for conf in self.get_configs().values():
|
||||
if channel != self._channel:
|
||||
break
|
||||
if source != conf.name:
|
||||
continue
|
||||
client: Telegram = self.get_instance(conf.name)
|
||||
if client:
|
||||
result = client.delete_msg(message_id=message_id, chat_id=chat_id)
|
||||
if result:
|
||||
success = True
|
||||
return success
|
||||
|
||||
def register_commands(self, commands: Dict[str, dict]):
|
||||
"""
|
||||
|
||||
@@ -3,11 +3,13 @@ import threading
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
from typing import Optional, List, Dict
|
||||
from typing import Optional, List, Dict, Callable
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import telebot
|
||||
from telebot import apihelper
|
||||
from telebot.types import InputFile
|
||||
from telebot.types import InputFile, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from telebot.types import InputMediaPhoto
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo, Context
|
||||
@@ -17,13 +19,12 @@ from app.utils.common import retry
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
apihelper.proxy = settings.PROXY
|
||||
|
||||
|
||||
class Telegram:
|
||||
_ds_url = f"http://127.0.0.1:{settings.PORT}/api/v1/message?token={settings.API_TOKEN}"
|
||||
_event = Event()
|
||||
_bot: telebot.TeleBot = None
|
||||
_callback_handlers: Dict[str, Callable] = {} # 存储回调处理器
|
||||
|
||||
def __init__(self, TELEGRAM_TOKEN: Optional[str] = None, TELEGRAM_CHAT_ID: Optional[str] = None, **kwargs):
|
||||
"""
|
||||
@@ -38,6 +39,12 @@ class Telegram:
|
||||
self._telegram_chat_id = TELEGRAM_CHAT_ID
|
||||
# 初始化机器人
|
||||
if self._telegram_token and self._telegram_chat_id:
|
||||
# telegram bot api 地址,格式:https://api.telegram.org
|
||||
if kwargs.get("API_URL"):
|
||||
apihelper.API_URL = urljoin(kwargs["API_URL"], '/bot{0}/{1}')
|
||||
apihelper.FILE_URL = urljoin(kwargs["API_URL"], '/file/bot{0}/{1}')
|
||||
else:
|
||||
apihelper.proxy = settings.PROXY
|
||||
# bot
|
||||
_bot = telebot.TeleBot(self._telegram_token, parse_mode="Markdown")
|
||||
# 记录句柄
|
||||
@@ -52,7 +59,44 @@ class Telegram:
|
||||
|
||||
@_bot.message_handler(func=lambda message: True)
|
||||
def echo_all(message):
|
||||
RequestUtils(timeout=5).post_res(self._ds_url, json=message.json)
|
||||
RequestUtils(timeout=15).post_res(self._ds_url, json=message.json)
|
||||
|
||||
@_bot.callback_query_handler(func=lambda call: True)
|
||||
def callback_query(call):
|
||||
"""
|
||||
处理按钮点击回调
|
||||
"""
|
||||
try:
|
||||
# 解析回调数据
|
||||
callback_data = call.data
|
||||
user_id = str(call.from_user.id)
|
||||
|
||||
logger.info(f"收到按钮回调:{callback_data},用户:{user_id}")
|
||||
|
||||
# 发送回调数据给主程序处理
|
||||
callback_json = {
|
||||
"callback_query": {
|
||||
"id": call.id,
|
||||
"from": call.from_user.to_dict(),
|
||||
"message": {
|
||||
"message_id": call.message.message_id,
|
||||
"chat": {
|
||||
"id": call.message.chat.id,
|
||||
}
|
||||
},
|
||||
"data": callback_data
|
||||
}
|
||||
}
|
||||
|
||||
# 先确认回调,避免用户看到loading状态
|
||||
_bot.answer_callback_query(call.id)
|
||||
|
||||
# 发送给主程序处理
|
||||
RequestUtils(timeout=15).post_res(self._ds_url, json=callback_json)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理按钮回调失败:{str(e)}")
|
||||
_bot.answer_callback_query(call.id, "处理失败,请重试")
|
||||
|
||||
def run_polling():
|
||||
"""
|
||||
@@ -75,7 +119,10 @@ class Telegram:
|
||||
return self._bot is not None
|
||||
|
||||
def send_msg(self, title: str, text: Optional[str] = None, image: Optional[str] = None,
|
||||
userid: Optional[str] = None, link: Optional[str] = None) -> Optional[bool]:
|
||||
userid: Optional[str] = None, link: Optional[str] = None,
|
||||
buttons: Optional[List[List[dict]]] = None,
|
||||
original_message_id: Optional[int] = None,
|
||||
original_chat_id: Optional[str] = None) -> Optional[bool]:
|
||||
"""
|
||||
发送Telegram消息
|
||||
:param title: 消息标题
|
||||
@@ -83,6 +130,9 @@ class Telegram:
|
||||
:param image: 消息图片地址
|
||||
:param userid: 用户ID,如有则只发消息给该用户
|
||||
:param link: 跳转链接
|
||||
:param buttons: 按钮列表,格式:[[{"text": "按钮文本", "callback_data": "回调数据"}]]
|
||||
:param original_message_id: 原消息ID,如果提供则编辑原消息
|
||||
:param original_chat_id: 原消息的聊天ID,编辑消息时需要
|
||||
:userid: 发送消息的目标用户ID,为空则发给管理员
|
||||
"""
|
||||
if not self._telegram_token or not self._telegram_chat_id:
|
||||
@@ -108,16 +158,37 @@ class Telegram:
|
||||
else:
|
||||
chat_id = self._telegram_chat_id
|
||||
|
||||
return self.__send_request(userid=chat_id, image=image, caption=caption)
|
||||
# 创建按钮键盘
|
||||
reply_markup = None
|
||||
if buttons:
|
||||
reply_markup = self._create_inline_keyboard(buttons)
|
||||
|
||||
# 判断是编辑消息还是发送新消息
|
||||
if original_message_id and original_chat_id:
|
||||
# 编辑消息
|
||||
return self.__edit_message(original_chat_id, original_message_id, caption, buttons, image)
|
||||
else:
|
||||
# 发送新消息
|
||||
return self.__send_request(userid=chat_id, image=image, caption=caption, reply_markup=reply_markup)
|
||||
|
||||
except Exception as msg_e:
|
||||
logger.error(f"发送消息失败:{msg_e}")
|
||||
return False
|
||||
|
||||
def send_medias_msg(self, medias: List[MediaInfo], userid: Optional[str] = None,
|
||||
title: Optional[str] = None, link: Optional[str] = None) -> Optional[bool]:
|
||||
title: Optional[str] = None, link: Optional[str] = None,
|
||||
buttons: Optional[List[List[Dict]]] = None,
|
||||
original_message_id: Optional[int] = None,
|
||||
original_chat_id: Optional[str] = None) -> Optional[bool]:
|
||||
"""
|
||||
发送媒体列表消息
|
||||
:param medias: 媒体信息列表
|
||||
:param userid: 用户ID,如有则只发消息给该用户
|
||||
:param title: 消息标题
|
||||
:param link: 跳转链接
|
||||
:param buttons: 按钮列表,格式:[[{"text": "按钮文本", "callback_data": "回调数据"}]]
|
||||
:param original_message_id: 原消息ID,如果提供则编辑原消息
|
||||
:param original_chat_id: 原消息的聊天ID,编辑消息时需要
|
||||
"""
|
||||
if not self._telegram_token or not self._telegram_chat_id:
|
||||
return None
|
||||
@@ -150,26 +221,44 @@ class Telegram:
|
||||
else:
|
||||
chat_id = self._telegram_chat_id
|
||||
|
||||
return self.__send_request(userid=chat_id, image=image, caption=caption)
|
||||
# 创建按钮键盘
|
||||
reply_markup = None
|
||||
if buttons:
|
||||
reply_markup = self._create_inline_keyboard(buttons)
|
||||
|
||||
# 判断是编辑消息还是发送新消息
|
||||
if original_message_id and original_chat_id:
|
||||
# 编辑消息
|
||||
return self.__edit_message(original_chat_id, original_message_id, caption, buttons, image)
|
||||
else:
|
||||
# 发送新消息
|
||||
return self.__send_request(userid=chat_id, image=image, caption=caption, reply_markup=reply_markup)
|
||||
|
||||
except Exception as msg_e:
|
||||
logger.error(f"发送消息失败:{msg_e}")
|
||||
return False
|
||||
|
||||
def send_torrents_msg(self, torrents: List[Context],
|
||||
userid: Optional[str] = None, title: Optional[str] = None, link: Optional[str] = None) -> Optional[bool]:
|
||||
userid: Optional[str] = None, title: Optional[str] = None,
|
||||
link: Optional[str] = None, buttons: Optional[List[List[Dict]]] = None,
|
||||
original_message_id: Optional[int] = None,
|
||||
original_chat_id: Optional[str] = None) -> Optional[bool]:
|
||||
"""
|
||||
发送列表消息
|
||||
发送种子列表消息
|
||||
:param torrents: 种子信息列表
|
||||
:param userid: 用户ID,如有则只发消息给该用户
|
||||
:param title: 消息标题
|
||||
:param link: 跳转链接
|
||||
:param buttons: 按钮列表,格式:[[{"text": "按钮文本", "callback_data": "回调数据"}]]
|
||||
:param original_message_id: 原消息ID,如果提供则编辑原消息
|
||||
:param original_chat_id: 原消息的聊天ID,编辑消息时需要
|
||||
"""
|
||||
if not self._telegram_token or not self._telegram_chat_id:
|
||||
return None
|
||||
|
||||
if not torrents:
|
||||
return False
|
||||
|
||||
try:
|
||||
index, caption = 1, "*%s*" % title
|
||||
mediainfo = torrents[0].media_info
|
||||
image = torrents[0].media_info.get_message_image()
|
||||
for context in torrents:
|
||||
torrent = context.torrent_info
|
||||
site_name = torrent.site_name
|
||||
@@ -194,20 +283,142 @@ class Telegram:
|
||||
else:
|
||||
chat_id = self._telegram_chat_id
|
||||
|
||||
return self.__send_request(userid=chat_id, caption=caption,
|
||||
image=mediainfo.get_message_image())
|
||||
# 创建按钮键盘
|
||||
reply_markup = None
|
||||
if buttons:
|
||||
reply_markup = self._create_inline_keyboard(buttons)
|
||||
|
||||
# 判断是编辑消息还是发送新消息
|
||||
if original_message_id and original_chat_id:
|
||||
# 编辑消息(种子消息通常没有图片)
|
||||
return self.__edit_message(original_chat_id, original_message_id, caption, buttons, image)
|
||||
else:
|
||||
# 发送新消息
|
||||
return self.__send_request(userid=chat_id, image=image, caption=caption, reply_markup=reply_markup)
|
||||
|
||||
except Exception as msg_e:
|
||||
logger.error(f"发送消息失败:{msg_e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _create_inline_keyboard(buttons: List[List[Dict]]) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
创建内联键盘
|
||||
:param buttons: 按钮配置,格式:[[{"text": "按钮文本", "callback_data": "回调数据", "url": "链接"}]]
|
||||
:return: InlineKeyboardMarkup对象
|
||||
"""
|
||||
keyboard = []
|
||||
for row in buttons:
|
||||
button_row = []
|
||||
for button in row:
|
||||
if "url" in button:
|
||||
# URL按钮
|
||||
btn = InlineKeyboardButton(text=button["text"], url=button["url"])
|
||||
else:
|
||||
# 回调按钮
|
||||
btn = InlineKeyboardButton(text=button["text"], callback_data=button["callback_data"])
|
||||
button_row.append(btn)
|
||||
keyboard.append(button_row)
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
def answer_callback_query(self, callback_query_id: int, text: Optional[str] = None,
|
||||
show_alert: bool = False) -> Optional[bool]:
|
||||
"""
|
||||
回应回调查询
|
||||
"""
|
||||
if not self._bot:
|
||||
return None
|
||||
|
||||
try:
|
||||
self._bot.answer_callback_query(callback_query_id, text=text, show_alert=show_alert)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"回应回调查询失败:{str(e)}")
|
||||
return False
|
||||
|
||||
def delete_msg(self, message_id: int, chat_id: Optional[int] = None) -> Optional[bool]:
|
||||
"""
|
||||
删除Telegram消息
|
||||
:param message_id: 消息ID
|
||||
:param chat_id: 聊天ID
|
||||
:return: 删除是否成功
|
||||
"""
|
||||
if not self._telegram_token or not self._telegram_chat_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
# 确定要删除消息的聊天ID
|
||||
if chat_id:
|
||||
target_chat_id = chat_id
|
||||
else:
|
||||
target_chat_id = self._telegram_chat_id
|
||||
|
||||
# 删除消息
|
||||
result = self._bot.delete_message(chat_id=target_chat_id, message_id=int(message_id))
|
||||
if result:
|
||||
logger.info(f"成功删除Telegram消息: chat_id={target_chat_id}, message_id={message_id}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"删除Telegram消息失败: chat_id={target_chat_id}, message_id={message_id}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"删除Telegram消息异常: {str(e)}")
|
||||
return False
|
||||
|
||||
def __edit_message(self, chat_id: str, message_id: int, text: str,
|
||||
buttons: Optional[List[List[dict]]] = None,
|
||||
image: Optional[str] = None) -> Optional[bool]:
|
||||
"""
|
||||
编辑已发送的消息
|
||||
:param chat_id: 聊天ID
|
||||
:param message_id: 消息ID
|
||||
:param text: 新的消息内容
|
||||
:param buttons: 按钮列表
|
||||
:param image: 图片URL或路径
|
||||
:return: 编辑是否成功
|
||||
"""
|
||||
if not self._bot:
|
||||
return None
|
||||
|
||||
try:
|
||||
|
||||
# 创建按钮键盘
|
||||
reply_markup = None
|
||||
if buttons:
|
||||
reply_markup = self._create_inline_keyboard(buttons)
|
||||
|
||||
if image:
|
||||
# 如果有图片,使用edit_message_media
|
||||
media = InputMediaPhoto(media=image, caption=text, parse_mode="Markdown")
|
||||
self._bot.edit_message_media(
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
media=media,
|
||||
reply_markup=reply_markup
|
||||
)
|
||||
else:
|
||||
# 如果没有图片,使用edit_message_text
|
||||
self._bot.edit_message_text(
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
text=text,
|
||||
parse_mode="Markdown",
|
||||
reply_markup=reply_markup
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"编辑消息失败:{str(e)}")
|
||||
return False
|
||||
|
||||
@retry(Exception, logger=logger)
|
||||
def __send_request(self, userid: Optional[str] = None, image="", caption="") -> bool:
|
||||
def __send_request(self, userid: Optional[str] = None, image="", caption="",
|
||||
reply_markup: Optional[InlineKeyboardMarkup] = None) -> bool:
|
||||
"""
|
||||
向Telegram发送报文
|
||||
:param reply_markup: 内联键盘
|
||||
"""
|
||||
if image:
|
||||
res = RequestUtils(proxies=settings.PROXY).get_res(image)
|
||||
res = RequestUtils(proxies=settings.PROXY, ua=settings.USER_AGENT).get_res(image)
|
||||
if res is None:
|
||||
raise Exception("获取图片失败")
|
||||
if res.content:
|
||||
@@ -221,7 +432,8 @@ class Telegram:
|
||||
ret = self._bot.send_photo(chat_id=userid or self._telegram_chat_id,
|
||||
photo=photo,
|
||||
caption=caption,
|
||||
parse_mode="Markdown")
|
||||
parse_mode="Markdown",
|
||||
reply_markup=reply_markup)
|
||||
if ret is None:
|
||||
raise Exception("发送图片消息失败")
|
||||
return True
|
||||
@@ -231,11 +443,13 @@ class Telegram:
|
||||
for i in range(0, len(caption), 4095):
|
||||
ret = self._bot.send_message(chat_id=userid or self._telegram_chat_id,
|
||||
text=caption[i:i + 4095],
|
||||
parse_mode="Markdown")
|
||||
parse_mode="Markdown",
|
||||
reply_markup=reply_markup if i == 0 else None)
|
||||
else:
|
||||
ret = self._bot.send_message(chat_id=userid or self._telegram_chat_id,
|
||||
text=caption,
|
||||
parse_mode="Markdown")
|
||||
parse_mode="Markdown",
|
||||
reply_markup=reply_markup)
|
||||
if ret is None:
|
||||
raise Exception("发送文本消息失败")
|
||||
return True if ret else False
|
||||
|
||||
@@ -15,7 +15,7 @@ from app.schemas.types import MediaType
|
||||
lock = RLock()
|
||||
|
||||
CACHE_EXPIRE_TIMESTAMP_STR = "cache_expire_timestamp"
|
||||
EXPIRE_TIMESTAMP = settings.CONF["meta"]
|
||||
EXPIRE_TIMESTAMP = settings.CONF.meta
|
||||
|
||||
|
||||
class TmdbCache(metaclass=Singleton):
|
||||
|
||||
@@ -500,7 +500,7 @@ class TmdbApi:
|
||||
|
||||
return ret_info
|
||||
|
||||
@cached(maxsize=settings.CONF["tmdb"], ttl=settings.CONF["meta"])
|
||||
@cached(maxsize=settings.CONF.tmdb, ttl=settings.CONF.meta)
|
||||
@rate_limit_exponential(source="match_tmdb_web", base_wait=5, max_wait=1800, enable_logging=True)
|
||||
def match_web(self, name: str, mtype: MediaType) -> Optional[dict]:
|
||||
"""
|
||||
@@ -563,6 +563,9 @@ class TmdbApi:
|
||||
except Exception as err:
|
||||
logger.error(f"从TheDbMovie网站查询出错:{str(err)}")
|
||||
return {}
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
return {}
|
||||
|
||||
def get_info(self,
|
||||
|
||||
@@ -59,7 +59,7 @@ class AsObj:
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
return setattr(self, key, value)
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return str(self._obj_list) if self._list_only else str(self._dict())
|
||||
|
||||
@@ -91,10 +91,10 @@ class AsObj:
|
||||
|
||||
def pop(self, key, value=None):
|
||||
return self.__dict__.pop(key, value)
|
||||
|
||||
|
||||
def popitem(self):
|
||||
return self.__dict__.popitem()
|
||||
|
||||
|
||||
def setdefault(self, key, value=None):
|
||||
return self.__dict__.setdefault(key, value)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user