mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-09 17:02:40 +08:00
Compare commits
123 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cd768b3d0 | ||
|
|
abc26b65ed | ||
|
|
dc1a41da90 | ||
|
|
a95dac1b32 | ||
|
|
18d9620687 | ||
|
|
8808dcee52 | ||
|
|
17adc4deab | ||
|
|
9351489166 | ||
|
|
e2148cb77f | ||
|
|
e322204094 | ||
|
|
0fa884157a | ||
|
|
96468213fe | ||
|
|
d044a9db00 | ||
|
|
d5f5e0d526 | ||
|
|
14a3bb8fc2 | ||
|
|
5921d43ae8 | ||
|
|
635061c054 | ||
|
|
3c8c6e5375 | ||
|
|
dd063bb16b | ||
|
|
750711611b | ||
|
|
d3983c51c2 | ||
|
|
b9dec73773 | ||
|
|
b310367d25 | ||
|
|
55beea87fd | ||
|
|
4510382f74 | ||
|
|
9b9ae9401e | ||
|
|
e10464c278 | ||
|
|
542531a1ca | ||
|
|
04c21232e3 | ||
|
|
48a19fd57c | ||
|
|
59cb69a96b | ||
|
|
e7d94f7f70 | ||
|
|
27d2d01a20 | ||
|
|
8b4495c857 | ||
|
|
15bdb694cc | ||
|
|
3ef9c5ea2c | ||
|
|
ab6577f752 | ||
|
|
49a82d7a48 | ||
|
|
bdcbb168a0 | ||
|
|
2e1cb0bd76 | ||
|
|
851864cd49 | ||
|
|
b5d7b6fb53 | ||
|
|
92bab2fc2f | ||
|
|
0dad6860c4 | ||
|
|
de4a7becc2 | ||
|
|
2eeb24e22d | ||
|
|
e4a67ea052 | ||
|
|
a4df2f5213 | ||
|
|
4f89780a0f | ||
|
|
26d6201b30 | ||
|
|
c9a9ff2692 | ||
|
|
0be49953b4 | ||
|
|
0de952f090 | ||
|
|
2b570bf48f | ||
|
|
9476017af5 | ||
|
|
54f808485e | ||
|
|
fa5c82899b | ||
|
|
4a57071809 | ||
|
|
4631db9a45 | ||
|
|
0f09da55b0 | ||
|
|
b14b41c2c1 | ||
|
|
cf05ae20c5 | ||
|
|
897758d829 | ||
|
|
85a77a66dd | ||
|
|
c450dfc0fa | ||
|
|
3d782a7475 | ||
|
|
4734851213 | ||
|
|
9c8635002d | ||
|
|
4cd3cb2b60 | ||
|
|
fa890ca29c | ||
|
|
bbf1ec4c50 | ||
|
|
523d458489 | ||
|
|
45ec668875 | ||
|
|
60122644b8 | ||
|
|
07a77e0001 | ||
|
|
d112f49a69 | ||
|
|
8cb061ff75 | ||
|
|
01e08c8e69 | ||
|
|
3549b38ee8 | ||
|
|
f5fb888c85 | ||
|
|
8bcb6a7cb6 | ||
|
|
ac81dd943c | ||
|
|
663d282b5e | ||
|
|
c7b389dd9b | ||
|
|
bad37a1846 | ||
|
|
9c09981583 | ||
|
|
2d8e66cbe2 | ||
|
|
db28986d22 | ||
|
|
727bed46b7 | ||
|
|
8e0df90177 | ||
|
|
34bbb86c16 | ||
|
|
0403f1f48c | ||
|
|
1db452e268 | ||
|
|
81ca11650d | ||
|
|
2e4671fdbc | ||
|
|
da80ad33d9 | ||
|
|
a6f28569ab | ||
|
|
5dd36e95e0 | ||
|
|
1eaeea62db | ||
|
|
4282c5dfc2 | ||
|
|
2e661f8759 | ||
|
|
31ca41828e | ||
|
|
c9ebe76eb1 | ||
|
|
71ac12ab7a | ||
|
|
81c0e15a1c | ||
|
|
2bde4923f9 | ||
|
|
22fb6305cf | ||
|
|
4bb5772e10 | ||
|
|
549658e871 | ||
|
|
80f47594f4 | ||
|
|
2614eeadb0 | ||
|
|
a0af827319 | ||
|
|
0233853794 | ||
|
|
6b24ccdc35 | ||
|
|
7d76ee2e65 | ||
|
|
1dd9228d01 | ||
|
|
a5b4221a00 | ||
|
|
37ba75b53c | ||
|
|
b8553e2b86 | ||
|
|
d28f3ed74b | ||
|
|
185c78b05c | ||
|
|
f23cab861a | ||
|
|
bbddec763a |
34
README.md
34
README.md
@@ -106,6 +106,7 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
|
||||
- **❗SUPERUSER:** 超级管理员用户名,默认`admin`,安装后使用该用户登录后台管理界面,**注意:启动一次后再次修改该值不会生效,除非删除数据库文件!**
|
||||
- **❗API_TOKEN:** API密钥,默认`moviepilot`,在媒体服务器Webhook、微信回调等地址配置中需要加上`?token=`该值,建议修改为复杂字符串
|
||||
- **BIG_MEMORY_MODE:** 大内存模式,默认为`false`,开启后会增加缓存数量,占用更多的内存,但响应速度会更快
|
||||
- **META_CACHE_EXPIRE:** 元数据识别缓存过期时间(小时),数字型,不配置或者配置为0时使用系统默认(大内存模式为7天,否则为3天),调大该值可减少themoviedb的访问次数
|
||||
- **GITHUB_TOKEN:** Github token,提高自动更新、插件安装等请求Github Api的限流阈值,格式:ghp_****
|
||||
- **DEV:** 开发者模式,`true`/`false`,默认`false`,开启后会暂停所有定时任务
|
||||
- **AUTO_UPDATE_RESOURCE**:启动时自动检测和更新资源包(站点索引及认证等),`true`/`false`,默认`true`,需要能正常连接Github,仅支持Docker镜像
|
||||
@@ -185,13 +186,25 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
|
||||
|
||||
## 使用
|
||||
|
||||
- 通过设置的超级管理员用户登录管理界面(默认用户:admin,默认端口:3000),**注意:初始密码为自动生成,需要在首次运行时的后台日志中查看!**
|
||||
- 通过CookieCloud同步快速添加站点,不需要使用的站点可在WEB管理界面中禁用或删除,无法同步的站点可手动新增。
|
||||
- 通过打开下载器监控实现下载完成后自动整理入库并刮削媒体信息。
|
||||
- 通过微信/Telegram/Slack/SynologyChat远程管理,其中微信/Telegram将会自动添加操作菜单(微信菜单条数有限制,部分菜单不显示);微信需要在官方页面设置回调地址,SynologyChat需要设置机器人传入地址,地址相对路径均为:`/api/v1/message/`。
|
||||
- 设置媒体服务器Webhook,通过MoviePilot发送播放通知等。Webhook回调相对路径为`/api/v1/webhook?token=moviepilot`,其中`moviepilot`为设置的`API_TOKEN`。
|
||||
- 将MoviePilot做为Radarr或Sonarr服务器添加到Overseerr或Jellyseerr,可使用Overseerr/Jellyseerr浏览订阅。
|
||||
- 映射宿主机docker.sock文件到容器`/var/run/docker.sock`,以支持内建重启操作。实例:`-v /var/run/docker.sock:/var/run/docker.sock:ro`。
|
||||
### 1. **WEB后台管理**
|
||||
- 通过设置的超级管理员用户登录后台管理界面(`SUPERUSER`配置项,默认用户:admin,默认端口:3000)
|
||||
> ❗**注意:超级管理员用户初始密码为自动生成,需要在首次运行时的后台日志中查看!** 如首次运行日志丢失,则需要删除配置文件目录下的`user.db`文件,然后重启服务。
|
||||
### 2. **站点维护**
|
||||
- 通过CookieCloud同步快速添加站点,不需要使用的站点可在WEB管理界面中禁用或删除,无法同步的站点也可手动新增。
|
||||
- 需要通过环境变量设置用户认证信息且认证成功后才能使用站点相关功能,未认证通过时站点相关的插件也会无法显示。
|
||||
### 3. **文件整理**
|
||||
- 默认通过监控下载器实现下载完成后自动整理入库并刮削媒体信息,需要后台打开`下载器监控`开关,且仅会处理通过MoviePilot添加下载的任务。
|
||||
- 使用`目录监控`等插件实现更灵活的自动整理。
|
||||
### 4. **通知交互**
|
||||
- 支持通过`微信`/`Telegram`/`Slack`/`SynologyChat`/`VoceChat`等渠道远程管理和订阅下载,其中 微信/Telegram 将会自动添加操作菜单(微信菜单条数有限制,部分菜单不显示)。
|
||||
- `微信`回调地址、`SynologyChat`传入地址地址相对路径均为:`/api/v1/message/`;`VoceChat`的Webhook地址相对路径为:`/api/v1/message/?token=moviepilot`,其中moviepilot为设置的`API_TOKEN`。
|
||||
### 5. **订阅与搜索**
|
||||
- 通过MoviePilot管理后台搜索和订阅。
|
||||
- 将MoviePilot做为`Radarr`或`Sonarr`服务器添加到`Overseerr`或`Jellyseerr`,可使用`Overseerr/Jellyseerr`浏览和添加订阅。
|
||||
- 安装`豆瓣榜单订阅`、`猫眼订阅`等插件,实现自动订阅豆瓣榜单、猫眼榜单等。
|
||||
### 6. **其他**
|
||||
- 通过设置媒体服务器Webhook指向MoviePilot(相对路径为`/api/v1/webhook?token=moviepilot`,其中`moviepilot`为设置的`API_TOKEN`),可实现通过MoviePilot发送播放通知,以及配合各类插件实现播放限速等功能。
|
||||
- 映射宿主机`docker.sock`文件到容器`/var/run/docker.sock`,可支持应用内建重启操作。实例:`-v /var/run/docker.sock:/var/run/docker.sock:ro`。
|
||||
- 将WEB页面添加到手机桌面图标可获得与App一样的使用体验。
|
||||
|
||||
### **注意**
|
||||
@@ -219,6 +232,13 @@ location /cgi-bin/menu/create {
|
||||
}
|
||||
```
|
||||
|
||||
- 部分插件功能基于文件系统监控实现(如`目录监控`等),需在宿主机上(不是docker容器内)执行以下命令并重启:
|
||||
```shell
|
||||
echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf
|
||||
echo fs.inotify.max_user_instances=524288 | sudo tee -a /etc/sysctl.conf
|
||||
sudo sysctl -p
|
||||
```
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
@@ -75,18 +75,16 @@ def downloader(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询下载器信息
|
||||
"""
|
||||
transfer_info = DashboardChain().downloader_info()
|
||||
free_space = SystemUtils.free_space(settings.SAVE_PATH)
|
||||
if transfer_info:
|
||||
return schemas.DownloaderInfo(
|
||||
download_speed=transfer_info.download_speed,
|
||||
upload_speed=transfer_info.upload_speed,
|
||||
download_size=transfer_info.download_size,
|
||||
upload_size=transfer_info.upload_size,
|
||||
free_space=free_space
|
||||
)
|
||||
else:
|
||||
return schemas.DownloaderInfo()
|
||||
downloader_info = schemas.DownloaderInfo()
|
||||
transfer_infos = DashboardChain().downloader_info()
|
||||
if transfer_infos:
|
||||
for transfer_info in transfer_infos:
|
||||
downloader_info.download_speed += transfer_info.download_speed
|
||||
downloader_info.upload_speed += transfer_info.upload_speed
|
||||
downloader_info.download_size += transfer_info.download_size
|
||||
downloader_info.upload_size += transfer_info.upload_size
|
||||
downloader_info.free_space = SystemUtils.free_space(settings.SAVE_PATH)
|
||||
return downloader_info
|
||||
|
||||
|
||||
@router.get("/downloader2", summary="下载器信息(API_TOKEN)", response_model=schemas.DownloaderInfo)
|
||||
|
||||
@@ -45,7 +45,7 @@ def add_downloading(
|
||||
media_info=mediainfo,
|
||||
torrent_info=torrentinfo
|
||||
)
|
||||
did = DownloadChain().download_single(context=context, username=current_user.name)
|
||||
did = DownloadChain().download_single(context=context, userid=current_user.name, username=current_user.name)
|
||||
return schemas.Response(success=True if did else False, data={
|
||||
"download_id": did
|
||||
})
|
||||
|
||||
@@ -49,6 +49,9 @@ async def login_access_token(
|
||||
is_superuser=False, hashed_password=get_password_hash(token))
|
||||
user.create(db)
|
||||
else:
|
||||
# 辅助验证用户若未启用,则禁止登录
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="用户未启用")
|
||||
# 普通用户权限
|
||||
user.is_superuser = False
|
||||
elif not user.is_active:
|
||||
|
||||
@@ -36,13 +36,11 @@ async def user_message(background_tasks: BackgroundTasks, request: Request):
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/", summary="微信验证")
|
||||
def wechat_verify(echostr: str, msg_signature: str,
|
||||
timestamp: Union[str, int], nonce: str) -> Any:
|
||||
"""
|
||||
用户消息响应
|
||||
微信验证响应
|
||||
"""
|
||||
logger.info(f"收到微信验证请求: {echostr}")
|
||||
try:
|
||||
wxcpt = WXBizMsgCrypt(sToken=settings.WECHAT_TOKEN,
|
||||
sEncodingAESKey=settings.WECHAT_ENCODING_AESKEY,
|
||||
@@ -60,6 +58,28 @@ def wechat_verify(echostr: str, msg_signature: str,
|
||||
return PlainTextResponse(sEchoStr)
|
||||
|
||||
|
||||
def vocechat_verify(token: str) -> Any:
|
||||
"""
|
||||
VoceChat验证响应
|
||||
"""
|
||||
if token == settings.API_TOKEN:
|
||||
return {"status": "OK"}
|
||||
return {"status": "ERROR"}
|
||||
|
||||
|
||||
@router.get("/", summary="回调请求验证")
|
||||
def incoming_verify(token: str = None, echostr: str = None, msg_signature: str = None,
|
||||
timestamp: Union[str, int] = None, nonce: str = None) -> Any:
|
||||
"""
|
||||
微信/VoceChat等验证响应
|
||||
"""
|
||||
logger.info(f"收到验证请求: token={token}, echostr={echostr}, "
|
||||
f"msg_signature={msg_signature}, timestamp={timestamp}, nonce={nonce}")
|
||||
if echostr and msg_signature and timestamp and nonce:
|
||||
return wechat_verify(echostr, msg_signature, timestamp, nonce)
|
||||
return vocechat_verify(token)
|
||||
|
||||
|
||||
@router.get("/switchs", summary="查询通知消息渠道开关", response_model=List[NotificationSwitch])
|
||||
def read_switchs(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
@@ -72,7 +92,7 @@ def read_switchs(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
for noti in NotificationType:
|
||||
return_list.append(NotificationSwitch(mtype=noti.value, wechat=True,
|
||||
telegram=True, slack=True,
|
||||
synologychat=True))
|
||||
synologychat=True, vocechat=True))
|
||||
else:
|
||||
for switch in switchs:
|
||||
return_list.append(NotificationSwitch(**switch))
|
||||
|
||||
@@ -7,6 +7,7 @@ from app.core.plugin import PluginManager
|
||||
from app.core.security import verify_token
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.plugin import PluginHelper
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
router = APIRouter()
|
||||
@@ -90,6 +91,8 @@ def install(plugin_id: str,
|
||||
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
||||
# 重载插件管理器
|
||||
PluginManager().init_config()
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@@ -115,7 +118,7 @@ def plugin_page(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token))
|
||||
|
||||
|
||||
@router.get("/reset/{plugin_id}", summary="重置插件配置", response_model=schemas.Response)
|
||||
def reset_plugin(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)) -> List[dict]:
|
||||
def reset_plugin(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据插件ID重置插件配置
|
||||
"""
|
||||
@@ -123,6 +126,8 @@ def reset_plugin(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)
|
||||
PluginManager().delete_plugin_config(plugin_id)
|
||||
# 重新生效插件
|
||||
PluginManager().reload_plugin(plugin_id, {})
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@@ -144,6 +149,8 @@ def set_plugin_config(plugin_id: str, conf: dict,
|
||||
PluginManager().save_plugin_config(plugin_id, conf)
|
||||
# 重新生效插件
|
||||
PluginManager().reload_plugin(plugin_id, conf)
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@@ -163,6 +170,8 @@ def uninstall_plugin(plugin_id: str,
|
||||
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
||||
# 重载插件管理器
|
||||
PluginManager().init_config()
|
||||
# 移除插件服务
|
||||
Scheduler().remove_plugin_job(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
|
||||
@@ -83,10 +83,11 @@ def create_subscribe(
|
||||
username=current_user.name,
|
||||
best_version=subscribe_in.best_version,
|
||||
save_path=subscribe_in.save_path,
|
||||
search_imdbid=subscribe_in.search_imdbid,
|
||||
exist_ok=True)
|
||||
return schemas.Response(success=True if sid else False, message=message, data={
|
||||
"id": sid
|
||||
})
|
||||
return schemas.Response(
|
||||
success=bool(sid), message=message, data={"id": sid}
|
||||
)
|
||||
|
||||
|
||||
@router.put("/", summary="更新订阅", response_model=schemas.Response)
|
||||
@@ -115,6 +116,9 @@ def update_subscribe(
|
||||
subscribe_dict["lack_episode"] = (subscribe.lack_episode
|
||||
+ (subscribe_in.total_episode
|
||||
- (subscribe.total_episode or 0)))
|
||||
# 是否手动修改过总集数
|
||||
if subscribe_in.total_episode != subscribe.total_episode:
|
||||
subscribe_dict["manual_total_episode"] = 1
|
||||
subscribe.update(db, subscribe_dict)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@@ -197,33 +197,6 @@ def get_logging(token: str, length: int = 50, logfile: str = "moviepilot.log"):
|
||||
return StreamingResponse(log_generator(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.get("/nettest", summary="测试网络连通性")
|
||||
def nettest(url: str,
|
||||
proxy: bool,
|
||||
_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
测试网络连通性
|
||||
"""
|
||||
# 记录开始的毫秒数
|
||||
start_time = datetime.now()
|
||||
url = url.replace("{TMDBAPIKEY}", settings.TMDB_API_KEY)
|
||||
result = RequestUtils(proxies=settings.PROXY if proxy else None,
|
||||
ua=settings.USER_AGENT).get_res(url)
|
||||
# 计时结束的毫秒数
|
||||
end_time = datetime.now()
|
||||
# 计算相关秒数
|
||||
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)
|
||||
})
|
||||
else:
|
||||
return schemas.Response(success=False, message="网络连接失败!")
|
||||
|
||||
|
||||
@router.get("/versions", summary="查询Github所有Release版本", response_model=schemas.Response)
|
||||
def latest_version(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
@@ -269,6 +242,53 @@ def ruletest(title: str,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/nettest", summary="测试网络连通性")
|
||||
def nettest(url: str,
|
||||
proxy: bool,
|
||||
_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
测试网络连通性
|
||||
"""
|
||||
# 记录开始的毫秒数
|
||||
start_time = datetime.now()
|
||||
url = url.replace("{TMDBAPIKEY}", settings.TMDB_API_KEY)
|
||||
result = RequestUtils(proxies=settings.PROXY if proxy else None,
|
||||
ua=settings.USER_AGENT).get_res(url)
|
||||
# 计时结束的毫秒数
|
||||
end_time = datetime.now()
|
||||
# 计算相关秒数
|
||||
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)
|
||||
})
|
||||
else:
|
||||
return schemas.Response(success=False, message="网络连接失败!")
|
||||
|
||||
|
||||
@router.get("/modulelist", summary="查询已加载的模块ID列表", response_model=schemas.Response)
|
||||
def modulelist(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
查询已加载的模块ID列表
|
||||
"""
|
||||
module_ids = [module.__name__ for module in ModuleManager().get_modules("test")]
|
||||
return schemas.Response(success=True, data={
|
||||
"ids": module_ids
|
||||
})
|
||||
|
||||
|
||||
@router.get("/moduletest/{moduleid}", summary="模块可用性测试", response_model=schemas.Response)
|
||||
def moduletest(moduleid: str, _: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
模块可用性测试接口
|
||||
"""
|
||||
state, errmsg = ModuleManager().test(moduleid)
|
||||
return schemas.Response(success=state, message=errmsg)
|
||||
|
||||
|
||||
@router.get("/restart", summary="重启系统", response_model=schemas.Response)
|
||||
def restart_system(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
@@ -286,8 +306,8 @@ def reload_module(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
重新加载模块
|
||||
"""
|
||||
ModuleManager().stop()
|
||||
ModuleManager().load_modules()
|
||||
ModuleManager().reload()
|
||||
Scheduler().init()
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ from fastapi import APIRouter, BackgroundTasks, Request, Depends
|
||||
|
||||
from app import schemas
|
||||
from app.chain.webhook import WebhookChain
|
||||
from app.core.config import settings
|
||||
from app.core.security import verify_uri_token
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -282,7 +282,8 @@ class ChainBase(metaclass=ABCMeta):
|
||||
mediainfo=mediainfo)
|
||||
|
||||
def download(self, content: Union[Path, str], download_dir: Path, cookie: str,
|
||||
episodes: Set[int] = None, category: str = None
|
||||
episodes: Set[int] = None, category: str = None,
|
||||
downloader: str = settings.DEFAULT_DOWNLOADER
|
||||
) -> Optional[Tuple[Optional[str], str]]:
|
||||
"""
|
||||
根据种子文件,选择并添加下载任务
|
||||
@@ -291,10 +292,12 @@ class ChainBase(metaclass=ABCMeta):
|
||||
:param cookie: cookie
|
||||
:param episodes: 需要下载的集数
|
||||
:param category: 种子分类
|
||||
:param downloader: 下载器
|
||||
:return: 种子Hash,错误信息
|
||||
"""
|
||||
return self.run_module("download", content=content, download_dir=download_dir,
|
||||
cookie=cookie, episodes=episodes, category=category)
|
||||
cookie=cookie, episodes=episodes, category=category,
|
||||
downloader=downloader)
|
||||
|
||||
def download_added(self, context: Context, download_dir: Path, torrent_path: Path = None) -> None:
|
||||
"""
|
||||
@@ -308,14 +311,17 @@ class ChainBase(metaclass=ABCMeta):
|
||||
download_dir=download_dir)
|
||||
|
||||
def list_torrents(self, status: TorrentStatus = None,
|
||||
hashs: Union[list, str] = None) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]:
|
||||
hashs: Union[list, str] = None,
|
||||
downloader: str = settings.DEFAULT_DOWNLOADER
|
||||
) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]:
|
||||
"""
|
||||
获取下载器种子列表
|
||||
:param status: 种子状态
|
||||
:param hashs: 种子Hash
|
||||
:param downloader: 下载器
|
||||
:return: 下载器中符合状态的种子列表
|
||||
"""
|
||||
return self.run_module("list_torrents", status=status, hashs=hashs)
|
||||
return self.run_module("list_torrents", status=status, hashs=hashs, downloader=downloader)
|
||||
|
||||
def transfer(self, path: Path, meta: MetaBase, mediainfo: MediaInfo,
|
||||
transfer_type: str, target: Path = None,
|
||||
@@ -331,49 +337,56 @@ class ChainBase(metaclass=ABCMeta):
|
||||
:return: {path, target_path, message}
|
||||
"""
|
||||
return self.run_module("transfer", path=path, meta=meta, mediainfo=mediainfo,
|
||||
transfer_type=transfer_type, target=target,
|
||||
episodes_info=episodes_info)
|
||||
transfer_type=transfer_type, target=target, episodes_info=episodes_info)
|
||||
|
||||
def transfer_completed(self, hashs: Union[str, list], path: Path = None) -> None:
|
||||
def transfer_completed(self, hashs: Union[str, list], path: Path = None,
|
||||
downloader: str = settings.DEFAULT_DOWNLOADER) -> None:
|
||||
"""
|
||||
转移完成后的处理
|
||||
:param hashs: 种子Hash
|
||||
:param path: 源目录
|
||||
:param downloader: 下载器
|
||||
"""
|
||||
return self.run_module("transfer_completed", hashs=hashs, path=path)
|
||||
return self.run_module("transfer_completed", hashs=hashs, path=path, downloader=downloader)
|
||||
|
||||
def remove_torrents(self, hashs: Union[str, list], delete_file: bool = True) -> bool:
|
||||
def remove_torrents(self, hashs: Union[str, list], delete_file: bool = True,
|
||||
downloader: str = settings.DEFAULT_DOWNLOADER) -> bool:
|
||||
"""
|
||||
删除下载器种子
|
||||
:param hashs: 种子Hash
|
||||
:param delete_file: 是否删除文件
|
||||
:param downloader: 下载器
|
||||
:return: bool
|
||||
"""
|
||||
return self.run_module("remove_torrents", hashs=hashs, delete_file=delete_file)
|
||||
return self.run_module("remove_torrents", hashs=hashs, delete_file=delete_file, downloader=downloader)
|
||||
|
||||
def start_torrents(self, hashs: Union[list, str]) -> bool:
|
||||
def start_torrents(self, hashs: Union[list, str], downloader: str = settings.DEFAULT_DOWNLOADER) -> bool:
|
||||
"""
|
||||
开始下载
|
||||
:param hashs: 种子Hash
|
||||
:param downloader: 下载器
|
||||
:return: bool
|
||||
"""
|
||||
return self.run_module("start_torrents", hashs=hashs)
|
||||
return self.run_module("start_torrents", hashs=hashs, downloader=downloader)
|
||||
|
||||
def stop_torrents(self, hashs: Union[list, str]) -> bool:
|
||||
def stop_torrents(self, hashs: Union[list, str], downloader: str = settings.DEFAULT_DOWNLOADER) -> bool:
|
||||
"""
|
||||
停止下载
|
||||
:param hashs: 种子Hash
|
||||
:param downloader: 下载器
|
||||
:return: bool
|
||||
"""
|
||||
return self.run_module("stop_torrents", hashs=hashs)
|
||||
return self.run_module("stop_torrents", hashs=hashs, downloader=downloader)
|
||||
|
||||
def torrent_files(self, tid: str) -> Optional[Union[TorrentFilesList, List[File]]]:
|
||||
def torrent_files(self, tid: str,
|
||||
downloader: str = settings.DEFAULT_DOWNLOADER) -> Optional[Union[TorrentFilesList, List[File]]]:
|
||||
"""
|
||||
获取种子文件
|
||||
:param tid: 种子Hash
|
||||
:param downloader: 下载器
|
||||
:return: 种子文件
|
||||
"""
|
||||
return self.run_module("torrent_files", tid=tid)
|
||||
return self.run_module("torrent_files", tid=tid, downloader=downloader)
|
||||
|
||||
def media_exists(self, mediainfo: MediaInfo, itemid: str = None) -> Optional[ExistMediaInfo]:
|
||||
"""
|
||||
|
||||
@@ -15,7 +15,7 @@ class DashboardChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
return self.run_module("media_statistic")
|
||||
|
||||
def downloader_info(self) -> schemas.DownloaderInfo:
|
||||
def downloader_info(self) -> Optional[List[schemas.DownloaderInfo]]:
|
||||
"""
|
||||
下载器信息
|
||||
"""
|
||||
|
||||
@@ -212,7 +212,7 @@ class DownloadChain(ChainBase):
|
||||
if _media.genre_ids \
|
||||
and set(_media.genre_ids).intersection(set(settings.ANIME_GENREIDS)):
|
||||
# 动漫
|
||||
download_dir = settings.SAVE_ANIME_PATH
|
||||
download_dir = settings.SAVE_ANIME_PATH / _media.category
|
||||
else:
|
||||
# 电视剧
|
||||
download_dir = settings.SAVE_TV_PATH / _media.category
|
||||
@@ -292,7 +292,7 @@ class DownloadChain(ChainBase):
|
||||
continue
|
||||
files_to_add.append({
|
||||
"download_hash": _hash,
|
||||
"downloader": settings.DOWNLOADER,
|
||||
"downloader": settings.DEFAULT_DOWNLOADER,
|
||||
"fullpath": str(download_dir / _folder_name / file),
|
||||
"savepath": str(download_dir / _folder_name),
|
||||
"filepath": file,
|
||||
@@ -301,8 +301,8 @@ class DownloadChain(ChainBase):
|
||||
if files_to_add:
|
||||
self.downloadhis.add_files(files_to_add)
|
||||
|
||||
# 发送消息
|
||||
self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent, channel=channel, userid=userid)
|
||||
# 发送消息(群发,不带channel和userid)
|
||||
self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent)
|
||||
# 下载成功后处理
|
||||
self.download_added(context=context, download_dir=download_dir, torrent_path=torrent_file)
|
||||
# 广播事件
|
||||
@@ -408,8 +408,8 @@ class DownloadChain(ChainBase):
|
||||
# 如果是电影,直接下载
|
||||
for context in contexts:
|
||||
if context.media_info.type == MediaType.MOVIE:
|
||||
if self.download_single(context, save_path=save_path,
|
||||
channel=channel, userid=userid, username=username):
|
||||
if self.download_single(context, save_path=save_path, channel=channel,
|
||||
userid=userid, username=username):
|
||||
# 下载成功
|
||||
downloaded_list.append(context)
|
||||
|
||||
@@ -482,8 +482,9 @@ class DownloadChain(ChainBase):
|
||||
)
|
||||
else:
|
||||
# 下载
|
||||
download_id = self.download_single(context, save_path=save_path,
|
||||
channel=channel, userid=userid, username=username)
|
||||
download_id = self.download_single(context,
|
||||
save_path=save_path, channel=channel,
|
||||
userid=userid, username=username)
|
||||
|
||||
if download_id:
|
||||
# 下载成功
|
||||
@@ -545,8 +546,9 @@ class DownloadChain(ChainBase):
|
||||
# 为需要集的子集则下载
|
||||
if torrent_episodes.issubset(set(need_episodes)):
|
||||
# 下载
|
||||
download_id = self.download_single(context, save_path=save_path,
|
||||
channel=channel, userid=userid, username=username)
|
||||
download_id = self.download_single(context,
|
||||
save_path=save_path, channel=channel,
|
||||
userid=userid, username=username)
|
||||
if download_id:
|
||||
# 下载成功
|
||||
downloaded_list.append(context)
|
||||
@@ -821,6 +823,7 @@ class DownloadChain(ChainBase):
|
||||
}
|
||||
# 下载用户
|
||||
torrent.userid = history.userid
|
||||
torrent.username = history.username
|
||||
ret_torrents.append(torrent)
|
||||
return ret_torrents
|
||||
|
||||
|
||||
@@ -275,7 +275,8 @@ class MessageChain(ChainBase):
|
||||
# 下载种子
|
||||
context: Context = cache_list[_choice]
|
||||
# 下载
|
||||
self.downloadchain.download_single(context, userid=userid, channel=channel, username=username)
|
||||
self.downloadchain.download_single(context, channel=channel,
|
||||
userid=userid, username=username)
|
||||
|
||||
elif text.lower() == "p":
|
||||
# 上一页
|
||||
|
||||
@@ -3,7 +3,7 @@ import re
|
||||
import traceback
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime
|
||||
from typing import Dict, Tuple
|
||||
from typing import Dict
|
||||
from typing import List, Optional
|
||||
|
||||
from app.chain import ChainBase
|
||||
@@ -124,7 +124,12 @@ class SearchChain(ChainBase):
|
||||
if keyword:
|
||||
keywords = [keyword]
|
||||
else:
|
||||
keywords = list({mediainfo.title, mediainfo.original_title, mediainfo.en_title} - {None})
|
||||
# 去重去空,但要保持顺序
|
||||
keywords = list(dict.fromkeys([k for k in [mediainfo.title,
|
||||
mediainfo.original_title,
|
||||
mediainfo.en_title,
|
||||
mediainfo.sg_title] if k]))
|
||||
|
||||
# 执行搜索
|
||||
torrents: List[TorrentInfo] = self.__search_all_sites(
|
||||
mediainfo=mediainfo,
|
||||
@@ -135,28 +140,8 @@ class SearchChain(ChainBase):
|
||||
if not torrents:
|
||||
logger.warn(f'{keyword or mediainfo.title} 未搜索到资源')
|
||||
return []
|
||||
# 过滤种子
|
||||
if priority_rule is None:
|
||||
# 取搜索优先级规则
|
||||
priority_rule = self.systemconfig.get(SystemConfigKey.SearchFilterRules)
|
||||
if priority_rule:
|
||||
logger.info(f'开始过滤资源,当前规则:{priority_rule} ...')
|
||||
result: List[TorrentInfo] = self.filter_torrents(rule_string=priority_rule,
|
||||
torrent_list=torrents,
|
||||
season_episodes=season_episodes,
|
||||
mediainfo=mediainfo)
|
||||
if result is not None:
|
||||
torrents = result
|
||||
if not torrents:
|
||||
logger.warn(f'{keyword or mediainfo.title} 没有符合优先级规则的资源')
|
||||
return []
|
||||
# 使用过滤规则再次过滤
|
||||
torrents = self.filter_torrents_by_rule(torrents=torrents,
|
||||
mediainfo=mediainfo,
|
||||
filter_rule=filter_rule)
|
||||
if not torrents:
|
||||
logger.warn(f'{keyword or mediainfo.title} 没有符合过滤规则的资源')
|
||||
return []
|
||||
# 开始新进度
|
||||
self.progress.start(ProgressKey.Search)
|
||||
# 匹配的资源
|
||||
_match_torrents = []
|
||||
# 总数
|
||||
@@ -164,29 +149,32 @@ class SearchChain(ChainBase):
|
||||
# 已处理数
|
||||
_count = 0
|
||||
if mediainfo:
|
||||
self.progress.start(ProgressKey.Search)
|
||||
logger.info(f'开始匹配,总 {_total} 个资源 ...')
|
||||
# 英文标题应该在别名/原标题中,不需要再匹配
|
||||
logger.info(f"标题:{mediainfo.title},原标题:{mediainfo.original_title},别名:{mediainfo.names}")
|
||||
self.progress.update(value=0, text=f'开始匹配,总 {_total} 个资源 ...', key=ProgressKey.Search)
|
||||
for torrent in torrents:
|
||||
_count += 1
|
||||
self.progress.update(value=(_count / _total) * 100,
|
||||
self.progress.update(value=(_count / _total) * 96,
|
||||
text=f'正在匹配 {torrent.site_name},已完成 {_count} / {_total} ...',
|
||||
key=ProgressKey.Search)
|
||||
# 比对IMDBID
|
||||
if torrent.imdbid \
|
||||
and mediainfo.imdb_id \
|
||||
and torrent.imdbid == mediainfo.imdb_id:
|
||||
logger.info(f'{mediainfo.title} 匹配到资源:{torrent.site_name} - {torrent.title}')
|
||||
logger.info(f'{mediainfo.title} 通过IMDBID匹配到资源:{torrent.site_name} - {torrent.title}')
|
||||
_match_torrents.append(torrent)
|
||||
continue
|
||||
# 识别
|
||||
torrent_meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
|
||||
# 比对类型
|
||||
if (torrent_meta.type == MediaType.TV and mediainfo.type != MediaType.TV) \
|
||||
or (torrent_meta.type != MediaType.TV and mediainfo.type == MediaType.TV):
|
||||
logger.warn(f'{torrent.site_name} - {torrent.title} 类型不匹配')
|
||||
# 比对种子识别类型
|
||||
if torrent_meta.type == MediaType.TV and mediainfo.type != MediaType.TV:
|
||||
logger.warn(f'{torrent.site_name} - {torrent.title} 种子标题类型为 {torrent_meta.type.value},'
|
||||
f'需要是 {mediainfo.type.value},不匹配')
|
||||
continue
|
||||
# 比对种子在站点中的类型
|
||||
if torrent.category == MediaType.TV.value and mediainfo.type != MediaType.TV:
|
||||
logger.warn(f'{torrent.site_name} - {torrent.title} 种子在站点中归类为 {torrent.category},'
|
||||
f'需要是 {mediainfo.type.value},不匹配')
|
||||
continue
|
||||
# 比对年份
|
||||
if mediainfo.year:
|
||||
@@ -231,21 +219,55 @@ class SearchChain(ChainBase):
|
||||
break
|
||||
else:
|
||||
logger.warn(f'{torrent.site_name} - {torrent.title} 标题不匹配')
|
||||
self.progress.update(value=100,
|
||||
logger.info(f"匹配完成,共匹配到 {len(_match_torrents)} 个资源")
|
||||
self.progress.update(value=97,
|
||||
text=f'匹配完成,共匹配到 {len(_match_torrents)} 个资源',
|
||||
key=ProgressKey.Search)
|
||||
self.progress.end(ProgressKey.Search)
|
||||
else:
|
||||
_match_torrents = torrents
|
||||
logger.info(f"匹配完成,共匹配到 {len(_match_torrents)} 个资源")
|
||||
# 开始过滤
|
||||
self.progress.update(value=98, text=f'开始过滤,总 {len(_match_torrents)} 个资源,请稍候...',
|
||||
key=ProgressKey.Search)
|
||||
# 过滤种子
|
||||
if priority_rule is None:
|
||||
# 取搜索优先级规则
|
||||
priority_rule = self.systemconfig.get(SystemConfigKey.SearchFilterRules)
|
||||
if priority_rule:
|
||||
logger.info(f'开始优先级规则过滤,当前规则:{priority_rule} ...')
|
||||
result: List[TorrentInfo] = self.filter_torrents(rule_string=priority_rule,
|
||||
torrent_list=_match_torrents,
|
||||
season_episodes=season_episodes,
|
||||
mediainfo=mediainfo)
|
||||
if result is not None:
|
||||
_match_torrents = result
|
||||
if not _match_torrents:
|
||||
logger.warn(f'{keyword or mediainfo.title} 没有符合优先级规则的资源')
|
||||
return []
|
||||
# 使用过滤规则再次过滤
|
||||
if filter_rule:
|
||||
logger.info(f'开始过滤规则过滤,当前规则:{filter_rule} ...')
|
||||
_match_torrents = self.filter_torrents_by_rule(torrents=_match_torrents,
|
||||
mediainfo=mediainfo,
|
||||
filter_rule=filter_rule)
|
||||
if not _match_torrents:
|
||||
logger.warn(f'{keyword or mediainfo.title} 没有符合过滤规则的资源')
|
||||
return []
|
||||
# 去掉mediainfo中多余的数据
|
||||
mediainfo.clear()
|
||||
# 组装上下文
|
||||
contexts = [Context(meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
|
||||
media_info=mediainfo,
|
||||
torrent_info=torrent) for torrent in _match_torrents]
|
||||
|
||||
logger.info(f"过滤完成,剩余 {_total} 个资源")
|
||||
self.progress.update(value=99, text=f'过滤完成,剩余 {_total} 个资源', key=ProgressKey.Search)
|
||||
# 排序
|
||||
self.progress.update(value=100,
|
||||
text=f'正在对 {len(contexts)} 个资源进行排序,请稍候...',
|
||||
key=ProgressKey.Search)
|
||||
contexts = self.torrenthelper.sort_torrents(contexts)
|
||||
# 结束进度
|
||||
self.progress.end(ProgressKey.Search)
|
||||
# 返回
|
||||
return contexts
|
||||
|
||||
@@ -352,102 +374,13 @@ class SearchChain(ChainBase):
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.DefaultSearchFilterRules)
|
||||
if not filter_rule:
|
||||
return torrents
|
||||
# 包含
|
||||
include = filter_rule.get("include")
|
||||
# 排除
|
||||
exclude = filter_rule.get("exclude")
|
||||
# 质量
|
||||
quality = filter_rule.get("quality")
|
||||
# 分辨率
|
||||
resolution = filter_rule.get("resolution")
|
||||
# 特效
|
||||
effect = filter_rule.get("effect")
|
||||
# 电影大小
|
||||
movie_size = filter_rule.get("movie_size")
|
||||
# 剧集单集大小
|
||||
tv_size = filter_rule.get("tv_size")
|
||||
|
||||
def __get_size_range(size_str: str) -> Tuple[float, float]:
|
||||
"""
|
||||
获取大小范围
|
||||
"""
|
||||
if not size_str:
|
||||
return 0, 0
|
||||
try:
|
||||
size_range = size_str.split("-")
|
||||
if len(size_range) == 1:
|
||||
return 0, float(size_range[0])
|
||||
elif len(size_range) == 2:
|
||||
return float(size_range[0]), float(size_range[1])
|
||||
except Exception as e:
|
||||
logger.error(f"解析大小范围失败:{str(e)} - {traceback.format_exc()}")
|
||||
return 0, 0
|
||||
|
||||
def __filter_torrent(t: TorrentInfo) -> bool:
|
||||
"""
|
||||
过滤种子
|
||||
"""
|
||||
# 包含
|
||||
if include:
|
||||
if not re.search(r"%s" % include,
|
||||
f"{t.title} {t.description}", re.I):
|
||||
logger.info(f"{t.title} 不匹配包含规则 {include}")
|
||||
return False
|
||||
# 排除
|
||||
if exclude:
|
||||
if re.search(r"%s" % exclude,
|
||||
f"{t.title} {t.description}", re.I):
|
||||
logger.info(f"{t.title} 匹配排除规则 {exclude}")
|
||||
return False
|
||||
# 质量
|
||||
if quality:
|
||||
if not re.search(r"%s" % quality, t.title, re.I):
|
||||
logger.info(f"{t.title} 不匹配质量规则 {quality}")
|
||||
return False
|
||||
|
||||
# 分辨率
|
||||
if resolution:
|
||||
if not re.search(r"%s" % resolution, t.title, re.I):
|
||||
logger.info(f"{t.title} 不匹配分辨率规则 {resolution}")
|
||||
return False
|
||||
|
||||
# 特效
|
||||
if effect:
|
||||
if not re.search(r"%s" % effect, t.title, re.I):
|
||||
logger.info(f"{t.title} 不匹配特效规则 {effect}")
|
||||
return False
|
||||
|
||||
# 大小
|
||||
if movie_size or tv_size:
|
||||
if mediainfo.type == MediaType.TV:
|
||||
size = tv_size
|
||||
else:
|
||||
size = movie_size
|
||||
# 大小范围
|
||||
begin_size, end_size = __get_size_range(size)
|
||||
if begin_size or end_size:
|
||||
meta = MetaInfo(title=t.title, subtitle=t.description)
|
||||
# 集数
|
||||
if mediainfo.type == MediaType.TV:
|
||||
# 电视剧
|
||||
season = meta.begin_season or 1
|
||||
if meta.total_episode:
|
||||
# 识别的总集数
|
||||
episodes_num = meta.total_episode
|
||||
else:
|
||||
# 整季集数
|
||||
episodes_num = len(mediainfo.seasons.get(season) or [1])
|
||||
# 比较大小
|
||||
if not (begin_size * 1024 ** 3 <= (t.size / episodes_num) <= end_size * 1024 ** 3):
|
||||
logger.info(f"{t.title} {StringUtils.str_filesize(t.size)} "
|
||||
f"共{episodes_num}集,不匹配大小规则 {size}")
|
||||
return False
|
||||
else:
|
||||
# 电影比较大小
|
||||
if not (begin_size * 1024 ** 3 <= t.size <= end_size * 1024 ** 3):
|
||||
logger.info(f"{t.title} {StringUtils.str_filesize(t.size)} 不匹配大小规则 {size}")
|
||||
return False
|
||||
return True
|
||||
|
||||
# 使用默认过滤规则再次过滤
|
||||
return list(filter(lambda t: __filter_torrent(t), torrents))
|
||||
return list(filter(
|
||||
lambda t: self.torrenthelper.filter_torrent(
|
||||
torrent_info=t,
|
||||
filter_rule=filter_rule,
|
||||
mediainfo=mediainfo
|
||||
),
|
||||
torrents
|
||||
))
|
||||
|
||||
@@ -372,9 +372,9 @@ class SiteChain(ChainBase):
|
||||
else:
|
||||
render_str = ""
|
||||
if site.is_active:
|
||||
messages.append(f"{site.id}. [{site.name}]({site.url}){render_str}")
|
||||
messages.append(f"{site.id}. {site.name} {render_str}")
|
||||
else:
|
||||
messages.append(f"{site.id}. {site.name}")
|
||||
messages.append(f"{site.id}. {site.name} ⚠️")
|
||||
# 发送列表
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
|
||||
@@ -18,9 +18,11 @@ from app.db.models.subscribe import Subscribe
|
||||
from app.db.subscribe_oper import SubscribeOper
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.torrent import TorrentHelper
|
||||
from app.log import logger
|
||||
from app.schemas import NotExistMediaInfo, Notification
|
||||
from app.schemas.types import MediaType, SystemConfigKey, MessageChannel, NotificationType
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
class SubscribeChain(ChainBase):
|
||||
@@ -37,6 +39,7 @@ class SubscribeChain(ChainBase):
|
||||
self.mediachain = MediaChain()
|
||||
self.message = MessageHelper()
|
||||
self.systemconfig = SystemConfigOper()
|
||||
self.torrenthelper = TorrentHelper()
|
||||
|
||||
def add(self, title: str, year: str,
|
||||
mtype: MediaType = None,
|
||||
@@ -140,9 +143,8 @@ class SubscribeChain(ChainBase):
|
||||
text = f"评分:{mediainfo.vote_average},来自用户:{username or userid}"
|
||||
else:
|
||||
text = f"评分:{mediainfo.vote_average}"
|
||||
# 广而告之
|
||||
self.post_message(Notification(channel=channel,
|
||||
mtype=NotificationType.Subscribe,
|
||||
# 群发
|
||||
self.post_message(Notification(mtype=NotificationType.Subscribe,
|
||||
title=f"{mediainfo.title_year} {metainfo.season} 已添加订阅",
|
||||
text=text,
|
||||
image=mediainfo.get_message_image()))
|
||||
@@ -259,10 +261,7 @@ class SubscribeChain(ChainBase):
|
||||
logger.info(f'订阅 {mediainfo.title_year} {meta.season} 缺失集:{no_exists_info.episodes}')
|
||||
|
||||
# 站点范围
|
||||
if subscribe.sites:
|
||||
sites = json.loads(subscribe.sites)
|
||||
else:
|
||||
sites = None
|
||||
sites = self.get_sub_sites(subscribe)
|
||||
|
||||
# 优先级过滤规则
|
||||
if subscribe.best_version:
|
||||
@@ -279,7 +278,8 @@ class SubscribeChain(ChainBase):
|
||||
no_exists=no_exists,
|
||||
sites=sites,
|
||||
priority_rule=priority_rule,
|
||||
filter_rule=filter_rule)
|
||||
filter_rule=filter_rule,
|
||||
area="imdbid" if subscribe.search_imdbid else "title")
|
||||
if not contexts:
|
||||
logger.warn(f'订阅 {subscribe.keyword or subscribe.name} 未搜索到资源')
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta,
|
||||
@@ -322,6 +322,7 @@ class SubscribeChain(ChainBase):
|
||||
downloads, lefts = self.downloadchain.batch_download(
|
||||
contexts=matched_contexts,
|
||||
no_exists=no_exists,
|
||||
userid=subscribe.username,
|
||||
username=subscribe.username,
|
||||
save_path=subscribe.save_path
|
||||
)
|
||||
@@ -418,6 +419,15 @@ class SubscribeChain(ChainBase):
|
||||
self.torrentschain.refresh(sites=sites)
|
||||
)
|
||||
|
||||
def get_sub_sites(self, subscribe: Subscribe) -> List[int]:
|
||||
"""
|
||||
获取订阅中涉及的站点清单
|
||||
"""
|
||||
if subscribe.sites:
|
||||
return json.loads(subscribe.sites)
|
||||
# 默认站点
|
||||
return self.systemconfig.get(SystemConfigKey.RssSites) or []
|
||||
|
||||
def get_subscribed_sites(self) -> Optional[List[int]]:
|
||||
"""
|
||||
获取订阅中涉及的所有站点清单(节约资源)
|
||||
@@ -430,13 +440,8 @@ class SubscribeChain(ChainBase):
|
||||
ret_sites = []
|
||||
# 刷新订阅选中的Rss站点
|
||||
for subscribe in subscribes:
|
||||
# 如果有一个订阅没有选择站点,则刷新所有订阅站点
|
||||
if not subscribe.sites:
|
||||
return []
|
||||
# 刷新选中的站点
|
||||
sub_sites = json.loads(subscribe.sites)
|
||||
if sub_sites:
|
||||
ret_sites.extend(sub_sites)
|
||||
ret_sites.extend(self.get_sub_sites(subscribe))
|
||||
# 去重
|
||||
if ret_sites:
|
||||
ret_sites = list(set(ret_sites))
|
||||
@@ -445,64 +450,20 @@ class SubscribeChain(ChainBase):
|
||||
|
||||
def get_filter_rule(self, subscribe: Subscribe):
|
||||
"""
|
||||
获取订阅过滤规则,没有则返回默认规则
|
||||
获取订阅过滤规则,同时组合默认规则
|
||||
"""
|
||||
# 默认过滤规则
|
||||
if (subscribe.include
|
||||
or subscribe.exclude
|
||||
or subscribe.quality
|
||||
or subscribe.resolution
|
||||
or subscribe.effect):
|
||||
return {
|
||||
"include": subscribe.include,
|
||||
"exclude": subscribe.exclude,
|
||||
"quality": subscribe.quality,
|
||||
"resolution": subscribe.resolution,
|
||||
"effect": subscribe.effect,
|
||||
}
|
||||
# 订阅默认过滤规则
|
||||
return self.systemconfig.get(SystemConfigKey.DefaultFilterRules) or {}
|
||||
|
||||
@staticmethod
|
||||
def check_filter_rule(torrent_info: TorrentInfo, filter_rule: Dict[str, str]) -> bool:
|
||||
"""
|
||||
检查种子是否匹配订阅过滤规则
|
||||
"""
|
||||
if not filter_rule:
|
||||
return True
|
||||
# 包含
|
||||
include = filter_rule.get("include")
|
||||
if include:
|
||||
if not re.search(r"%s" % include,
|
||||
f"{torrent_info.title} {torrent_info.description}", re.I):
|
||||
logger.info(f"{torrent_info.title} 不匹配包含规则 {include}")
|
||||
return False
|
||||
# 排除
|
||||
exclude = filter_rule.get("exclude")
|
||||
if exclude:
|
||||
if re.search(r"%s" % exclude,
|
||||
f"{torrent_info.title} {torrent_info.description}", re.I):
|
||||
logger.info(f"{torrent_info.title} 匹配排除规则 {exclude}")
|
||||
return False
|
||||
# 质量
|
||||
quality = filter_rule.get("quality")
|
||||
if quality:
|
||||
if not re.search(r"%s" % quality, torrent_info.title, re.I):
|
||||
logger.info(f"{torrent_info.title} 不匹配质量规则 {quality}")
|
||||
return False
|
||||
# 分辨率
|
||||
resolution = filter_rule.get("resolution")
|
||||
if resolution:
|
||||
if not re.search(r"%s" % resolution, torrent_info.title, re.I):
|
||||
logger.info(f"{torrent_info.title} 不匹配分辨率规则 {resolution}")
|
||||
return False
|
||||
# 特效
|
||||
effect = filter_rule.get("effect")
|
||||
if effect:
|
||||
if not re.search(r"%s" % effect, torrent_info.title, re.I):
|
||||
logger.info(f"{torrent_info.title} 不匹配特效规则 {effect}")
|
||||
return False
|
||||
return True
|
||||
default_rule = self.systemconfig.get(SystemConfigKey.DefaultFilterRules) or {}
|
||||
return {
|
||||
"include": subscribe.include or default_rule.get("include"),
|
||||
"exclude": subscribe.exclude or default_rule.get("exclude"),
|
||||
"quality": subscribe.quality or default_rule.get("quality"),
|
||||
"resolution": subscribe.resolution or default_rule.get("resolution"),
|
||||
"effect": subscribe.effect or default_rule.get("effect"),
|
||||
"tv_size": default_rule.get("tv_size"),
|
||||
"movie_size": default_rule.get("movie_size"),
|
||||
"min_seeders": default_rule.get("min_seeders"),
|
||||
}
|
||||
|
||||
def match(self, torrents: Dict[str, List[Context]]):
|
||||
"""
|
||||
@@ -590,15 +551,73 @@ class SubscribeChain(ChainBase):
|
||||
# 遍历缓存种子
|
||||
_match_context = []
|
||||
for domain, contexts in torrents.items():
|
||||
logger.info(f'开始匹配站点:{domain},共缓存了 {len(contexts)} 个种子...')
|
||||
for context in contexts:
|
||||
# 检查是否匹配
|
||||
torrent_meta = context.meta_info
|
||||
torrent_mediainfo = context.media_info
|
||||
torrent_info = context.torrent_info
|
||||
# 比对TMDBID和类型
|
||||
if torrent_mediainfo.tmdb_id != mediainfo.tmdb_id \
|
||||
or torrent_mediainfo.type != mediainfo.type:
|
||||
continue
|
||||
|
||||
# 如果识别了媒体信息,则比对TMDBID和类型
|
||||
if 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:
|
||||
# 按标题匹配
|
||||
# 比对种子识别类型
|
||||
if torrent_meta.type == MediaType.TV and mediainfo.type != MediaType.TV:
|
||||
continue
|
||||
# 比对种子在站点中的类型
|
||||
if torrent_info.category == MediaType.TV.value and mediainfo.type != MediaType.TV:
|
||||
continue
|
||||
# 比对年份
|
||||
if mediainfo.year:
|
||||
if mediainfo.type == MediaType.TV:
|
||||
# 剧集年份,每季的年份可能不同
|
||||
if torrent_meta.year and torrent_meta.year not in [year for year in
|
||||
mediainfo.season_years.values()]:
|
||||
continue
|
||||
else:
|
||||
# 电影年份,上下浮动1年
|
||||
if torrent_meta.year not in [str(int(mediainfo.year) - 1),
|
||||
mediainfo.year,
|
||||
str(int(mediainfo.year) + 1)]:
|
||||
continue
|
||||
# 标题匹配标志
|
||||
title_match = False
|
||||
# 比对标题和原语种标题
|
||||
meta_name = StringUtils.clear_upper(torrent_meta.name)
|
||||
if meta_name in [
|
||||
StringUtils.clear_upper(mediainfo.title),
|
||||
StringUtils.clear_upper(mediainfo.original_title)
|
||||
]:
|
||||
title_match = True
|
||||
# 在副标题中判断是否存在标题与原语种标题
|
||||
if not title_match and torrent_info.description:
|
||||
subtitle = re.split(r'[\s/|]+', torrent_info.description)
|
||||
if (StringUtils.is_chinese(mediainfo.title)
|
||||
and str(mediainfo.title) in subtitle) \
|
||||
or (StringUtils.is_chinese(mediainfo.original_title)
|
||||
and str(mediainfo.original_title) in subtitle):
|
||||
title_match = True
|
||||
# 比对别名和译名
|
||||
if not title_match:
|
||||
for name in mediainfo.names:
|
||||
if StringUtils.clear_upper(name) == meta_name:
|
||||
title_match = True
|
||||
break
|
||||
if not title_match:
|
||||
continue
|
||||
# 标题匹配成功
|
||||
logger.info(f'{mediainfo.title_year} 通过名称匹配到资源:{torrent_info.site_name} - {torrent_info.title}')
|
||||
|
||||
# 优先级过滤规则
|
||||
if subscribe.best_version:
|
||||
priority_rule = self.systemconfig.get(SystemConfigKey.BestVersionFilterRules)
|
||||
@@ -612,12 +631,13 @@ class SubscribeChain(ChainBase):
|
||||
# 不符合过滤规则
|
||||
logger.info(f"{torrent_info.title} 不匹配当前过滤规则")
|
||||
continue
|
||||
|
||||
# 不在订阅站点范围的不处理
|
||||
if subscribe.sites:
|
||||
sub_sites = json.loads(subscribe.sites)
|
||||
if sub_sites and torrent_info.site not in sub_sites:
|
||||
logger.info(f"{torrent_info.title} 不符合 {torrent_mediainfo.title_year} 订阅站点要求")
|
||||
continue
|
||||
sub_sites = self.get_sub_sites(subscribe)
|
||||
if sub_sites and torrent_info.site not in sub_sites:
|
||||
logger.info(f"{torrent_info.site_name} - {torrent_info.title} 不符合订阅站点要求")
|
||||
continue
|
||||
|
||||
# 如果是电视剧
|
||||
if torrent_mediainfo.type == MediaType.TV:
|
||||
# 有多季的不要
|
||||
@@ -661,8 +681,9 @@ class SubscribeChain(ChainBase):
|
||||
continue
|
||||
|
||||
# 过滤规则
|
||||
if not self.check_filter_rule(torrent_info=torrent_info,
|
||||
filter_rule=filter_rule):
|
||||
if not self.torrenthelper.filter_torrent(torrent_info=torrent_info,
|
||||
filter_rule=filter_rule,
|
||||
mediainfo=torrent_mediainfo):
|
||||
continue
|
||||
|
||||
# 洗版时,优先级小于已下载优先级的不要
|
||||
@@ -687,6 +708,7 @@ class SubscribeChain(ChainBase):
|
||||
logger.info(f'{mediainfo.title_year} 匹配完成,共匹配到{len(_match_context)}个资源')
|
||||
downloads, lefts = self.downloadchain.batch_download(contexts=_match_context,
|
||||
no_exists=no_exists,
|
||||
userid=subscribe.username,
|
||||
username=subscribe.username,
|
||||
save_path=subscribe.save_path)
|
||||
# 判断是否要完成订阅
|
||||
@@ -721,7 +743,7 @@ class SubscribeChain(ChainBase):
|
||||
continue
|
||||
# 对于电视剧,获取当前季的总集数
|
||||
episodes = mediainfo.seasons.get(subscribe.season) or []
|
||||
if len(episodes):
|
||||
if not subscribe.manual_total_episode and len(episodes):
|
||||
total_episode = len(episodes)
|
||||
lack_episode = subscribe.lack_episode + (total_episode - subscribe.total_episode)
|
||||
logger.info(
|
||||
@@ -837,20 +859,12 @@ class SubscribeChain(ChainBase):
|
||||
messages = []
|
||||
for subscribe in subscribes:
|
||||
if subscribe.type == MediaType.MOVIE.value:
|
||||
if subscribe.tmdbid:
|
||||
link = f"https://www.themoviedb.org/movie/{subscribe.tmdbid}"
|
||||
else:
|
||||
link = f"https://movie.douban.com/subject/{subscribe.doubanid}"
|
||||
messages.append(f"{subscribe.id}. [{subscribe.name}({subscribe.year})]({link})")
|
||||
messages.append(f"{subscribe.id}. {subscribe.name}({subscribe.year})")
|
||||
else:
|
||||
if subscribe.tmdbid:
|
||||
link = f"https://www.themoviedb.org/tv/{subscribe.tmdbid}"
|
||||
else:
|
||||
link = f"https://movie.douban.com/subject/{subscribe.doubanid}"
|
||||
messages.append(f"{subscribe.id}. [{subscribe.name}({subscribe.year})]({link}) "
|
||||
messages.append(f"{subscribe.id}. {subscribe.name}({subscribe.year})"
|
||||
f"第{subscribe.season}季 "
|
||||
f"_{subscribe.total_episode - (subscribe.lack_episode or subscribe.total_episode)}"
|
||||
f"/{subscribe.total_episode}_")
|
||||
f"[{subscribe.total_episode - (subscribe.lack_episode or subscribe.total_episode)}"
|
||||
f"/{subscribe.total_episode}]")
|
||||
# 发送列表
|
||||
self.post_message(Notification(channel=channel,
|
||||
title=title, text='\n'.join(messages), userid=userid))
|
||||
|
||||
@@ -16,7 +16,7 @@ from app.helper.sites import SitesHelper
|
||||
from app.helper.torrent import TorrentHelper
|
||||
from app.log import logger
|
||||
from app.schemas import Notification
|
||||
from app.schemas.types import SystemConfigKey, MessageChannel, NotificationType
|
||||
from app.schemas.types import SystemConfigKey, MessageChannel, NotificationType, MediaType
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
@@ -184,6 +184,10 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
logger.info(f'处理资源:{torrent.title} ...')
|
||||
# 识别
|
||||
meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
|
||||
# 使用站点种子分类,校正类型识别
|
||||
if meta.type != MediaType.TV \
|
||||
and torrent.category == MediaType.TV.value:
|
||||
meta.type = MediaType.TV
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.mediachain.recognize_by_meta(meta)
|
||||
if not mediainfo:
|
||||
|
||||
@@ -267,7 +267,10 @@ class Command(metaclass=Singleton):
|
||||
停止事件处理线程
|
||||
"""
|
||||
self._event.set()
|
||||
self._thread.join()
|
||||
try:
|
||||
self._thread.join()
|
||||
except Exception as e:
|
||||
logger.error(f"停止事件处理线程出错:{str(e)} - {traceback.format_exc()}")
|
||||
|
||||
def get_commands(self):
|
||||
"""
|
||||
|
||||
@@ -69,6 +69,8 @@ class Settings(BaseSettings):
|
||||
'.tp']
|
||||
# 支持的字幕文件后缀格式
|
||||
RMT_SUBEXT: list = ['.srt', '.ass', '.ssa', '.sup']
|
||||
# 下载器临时文件后缀
|
||||
DOWNLOAD_TMPEXT: list = ['.!qB', '.part']
|
||||
# 支持的音轨文件后缀格式
|
||||
RMT_AUDIO_TRACK_EXT: list = ['.mka']
|
||||
# 索引器
|
||||
@@ -83,7 +85,7 @@ class Settings(BaseSettings):
|
||||
AUTH_SITE: str = ""
|
||||
# 交互搜索自动下载用户ID,使用,分割
|
||||
AUTO_DOWNLOAD_USER: Optional[str] = None
|
||||
# 消息通知渠道 telegram/wechat/slack,多个通知渠道用,分隔
|
||||
# 消息通知渠道 telegram/wechat/slack/synologychat/vocechat,多个通知渠道用,分隔
|
||||
MESSAGER: str = "telegram"
|
||||
# WeChat企业ID
|
||||
WECHAT_CORPID: Optional[str] = None
|
||||
@@ -117,7 +119,13 @@ class Settings(BaseSettings):
|
||||
SYNOLOGYCHAT_WEBHOOK: str = ""
|
||||
# SynologyChat Token
|
||||
SYNOLOGYCHAT_TOKEN: str = ""
|
||||
# 下载器 qbittorrent/transmission
|
||||
# VoceChat地址
|
||||
VOCECHAT_HOST: str = ""
|
||||
# VoceChat ApiKey
|
||||
VOCECHAT_API_KEY: str = ""
|
||||
# VoceChat 频道ID
|
||||
VOCECHAT_CHANNEL_ID: str = ""
|
||||
# 下载器 qbittorrent/transmission,启用多个下载器时使用,分隔,只有第一个会被默认使用
|
||||
DOWNLOADER: str = "qbittorrent"
|
||||
# 下载器监控开关
|
||||
DOWNLOADER_MONITOR: bool = True
|
||||
@@ -222,10 +230,13 @@ class Settings(BaseSettings):
|
||||
GITHUB_TOKEN: Optional[str] = None
|
||||
# 自动检查和更新站点资源包(站点索引、认证等)
|
||||
AUTO_UPDATE_RESOURCE: bool = True
|
||||
# 元数据识别缓存过期时间(小时)
|
||||
META_CACHE_EXPIRE: int = 0
|
||||
|
||||
@validator("SUBSCRIBE_RSS_INTERVAL",
|
||||
"COOKIECLOUD_INTERVAL",
|
||||
"MEDIASERVER_SYNC_INTERVAL",
|
||||
"META_CACHE_EXPIRE",
|
||||
pre=True, always=True)
|
||||
def convert_int(cls, value):
|
||||
if not value:
|
||||
@@ -274,7 +285,7 @@ class Settings(BaseSettings):
|
||||
"torrents": 100,
|
||||
"douban": 512,
|
||||
"fanart": 512,
|
||||
"meta": 15 * 24 * 3600
|
||||
"meta": (self.META_CACHE_EXPIRE or 168) * 3600
|
||||
}
|
||||
return {
|
||||
"tmdb": 256,
|
||||
@@ -282,7 +293,7 @@ class Settings(BaseSettings):
|
||||
"torrents": 50,
|
||||
"douban": 256,
|
||||
"fanart": 128,
|
||||
"meta": 7 * 24 * 3600
|
||||
"meta": (self.META_CACHE_EXPIRE or 72) * 3600
|
||||
}
|
||||
|
||||
@property
|
||||
@@ -354,6 +365,24 @@ class Settings(BaseSettings):
|
||||
}
|
||||
return {}
|
||||
|
||||
@property
|
||||
def DEFAULT_DOWNLOADER(self):
|
||||
"""
|
||||
默认下载器
|
||||
"""
|
||||
if not self.DOWNLOADER:
|
||||
return None
|
||||
return self.DOWNLOADER.split(",")[0]
|
||||
|
||||
@property
|
||||
def DOWNLOADERS(self):
|
||||
"""
|
||||
下载器列表
|
||||
"""
|
||||
if not self.DOWNLOADER:
|
||||
return []
|
||||
return self.DOWNLOADER.split(",")
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
with self.CONFIG_PATH as p:
|
||||
|
||||
@@ -57,6 +57,8 @@ class TorrentInfo:
|
||||
labels: list = field(default_factory=list)
|
||||
# 种子优先级
|
||||
pri_order: int = 0
|
||||
# 种子分类 电影/电视剧
|
||||
category: str = None
|
||||
|
||||
def __setattr__(self, name: str, value: Any):
|
||||
self.__dict__[name] = value
|
||||
@@ -137,6 +139,8 @@ class MediaInfo:
|
||||
title: str = None
|
||||
# 英文标题
|
||||
en_title: str = None
|
||||
# 新加坡标题
|
||||
sg_title: str = None
|
||||
# 年份
|
||||
year: str = None
|
||||
# 季
|
||||
@@ -162,7 +166,7 @@ class MediaInfo:
|
||||
# LOGO
|
||||
logo_path: str = None
|
||||
# 评分
|
||||
vote_average: int = 0
|
||||
vote_average: float = 0
|
||||
# 描述
|
||||
overview: str = None
|
||||
# 风格ID
|
||||
@@ -372,6 +376,8 @@ class MediaInfo:
|
||||
self.original_language = info.get('original_language')
|
||||
# 英文标题
|
||||
self.en_title = info.get('en_title')
|
||||
# 新加坡标题
|
||||
self.sg_title = info.get('sg_title')
|
||||
if self.type == MediaType.MOVIE:
|
||||
# 标题
|
||||
self.title = info.get('title')
|
||||
|
||||
@@ -131,6 +131,10 @@ class MetaBase(object):
|
||||
except Exception as err:
|
||||
logger.debug(f'识别季失败:{str(err)} - {traceback.format_exc()}')
|
||||
return
|
||||
if begin_season and begin_season > 100:
|
||||
return
|
||||
if end_season and end_season > 100:
|
||||
return
|
||||
if self.begin_season is None and isinstance(begin_season, int):
|
||||
self.begin_season = begin_season
|
||||
self.total_season = 1
|
||||
@@ -162,6 +166,10 @@ class MetaBase(object):
|
||||
except Exception as err:
|
||||
logger.debug(f'识别集失败:{str(err)} - {traceback.format_exc()}')
|
||||
return
|
||||
if begin_episode and begin_episode >= 10000:
|
||||
return
|
||||
if end_episode and end_episode >= 10000:
|
||||
return
|
||||
if self.begin_episode is None and isinstance(begin_episode, int):
|
||||
self.begin_episode = begin_episode
|
||||
self.total_episode = 1
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Generator, Optional
|
||||
from typing import Generator, Optional, Tuple
|
||||
|
||||
from app.core.config import settings
|
||||
from app.helper.module import ModuleHelper
|
||||
@@ -51,6 +51,25 @@ class ModuleManager(metaclass=Singleton):
|
||||
if hasattr(module, "stop"):
|
||||
module.stop()
|
||||
|
||||
def reload(self):
|
||||
"""
|
||||
重新加载所有模块
|
||||
"""
|
||||
self.stop()
|
||||
self.load_modules()
|
||||
|
||||
def test(self, modleid: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块
|
||||
"""
|
||||
if modleid not in self._running_modules:
|
||||
return False, "模块未加载,请检查参数设置"
|
||||
module = self._running_modules[modleid]
|
||||
if hasattr(module, "test") \
|
||||
and ObjectUtils.check_method(getattr(module, "test")):
|
||||
return module.test()
|
||||
return True, "模块不支持测试"
|
||||
|
||||
@staticmethod
|
||||
def check_setting(setting: Optional[tuple]) -> bool:
|
||||
"""
|
||||
|
||||
@@ -246,6 +246,16 @@ class PluginManager(metaclass=Singleton):
|
||||
ret_services.extend(services)
|
||||
return ret_services
|
||||
|
||||
def get_plugin_attr(self, pid: str, attr: str) -> Any:
|
||||
"""
|
||||
获取插件属性
|
||||
"""
|
||||
if not self._running_plugins.get(pid):
|
||||
return None
|
||||
if not hasattr(self._running_plugins[pid], attr):
|
||||
return None
|
||||
return getattr(self._running_plugins[pid], attr)
|
||||
|
||||
def run_plugin_method(self, pid: str, method: str, *args, **kwargs) -> Any:
|
||||
"""
|
||||
运行插件方法
|
||||
@@ -262,6 +272,12 @@ class PluginManager(metaclass=Singleton):
|
||||
"""
|
||||
return list(self._plugins.keys())
|
||||
|
||||
def get_running_plugin_ids(self) -> List[str]:
|
||||
"""
|
||||
获取所有运行态插件ID
|
||||
"""
|
||||
return list(self._running_plugins.keys())
|
||||
|
||||
def get_online_plugins(self) -> List[dict]:
|
||||
"""
|
||||
获取所有在线插件信息
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from app.db import DbOper
|
||||
@@ -132,3 +131,11 @@ class DownloadHistoryOper(DbOper):
|
||||
type=type,
|
||||
tmdbid=tmdbid,
|
||||
seasons=seasons)
|
||||
|
||||
def list_by_type(self, mtype: str, days: int = 7) -> List[DownloadHistory]:
|
||||
"""
|
||||
获取指定类型的下载历史
|
||||
"""
|
||||
return DownloadHistory.list_by_type(db=self._db,
|
||||
mtype=mtype,
|
||||
days=days)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import time
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Sequence
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -140,6 +142,16 @@ class DownloadHistory(Base):
|
||||
DownloadHistory.tmdbid == tmdbid).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def list_by_type(db: Session, mtype: str, days: int):
|
||||
result = 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):
|
||||
"""
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import time
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Sequence
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -67,6 +69,10 @@ class Subscribe(Base):
|
||||
current_priority = Column(Integer)
|
||||
# 保存路径
|
||||
save_path = Column(String)
|
||||
# 是否使用 imdbid 搜索
|
||||
search_imdbid = Column(Integer, default=0)
|
||||
# 是否手动修改过总集数 0否 1是
|
||||
manual_total_episode = Column(Integer, default=0)
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
@@ -122,3 +128,13 @@ class Subscribe(Base):
|
||||
if subscribe:
|
||||
subscribe.delete(db, subscribe.id)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def list_by_type(db: Session, mtype: str, days: int):
|
||||
result = 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)
|
||||
|
||||
@@ -83,3 +83,9 @@ class SubscribeOper(DbOper):
|
||||
subscribe = self.get(sid)
|
||||
subscribe.update(self._db, payload)
|
||||
return subscribe
|
||||
|
||||
def list_by_type(self, mtype: str, days: int = 7) -> Subscribe:
|
||||
"""
|
||||
获取指定类型的订阅
|
||||
"""
|
||||
return Subscribe.list_by_type(self._db, mtype=mtype, days=days)
|
||||
|
||||
@@ -8,7 +8,7 @@ class CookieCloudHelper:
|
||||
|
||||
_ignore_cookies: list = ["CookieAutoDeleteBrowsingDataCleanup", "CookieAutoDeleteCleaningDiscarded"]
|
||||
|
||||
def __init__(self, server, key, password):
|
||||
def __init__(self, server: str, key: str, password: str):
|
||||
self._server = server
|
||||
self._key = key
|
||||
self._password = password
|
||||
@@ -21,8 +21,8 @@ class CookieCloudHelper:
|
||||
"""
|
||||
if not self._server or not self._key or not self._password:
|
||||
return None, "CookieCloud参数不正确"
|
||||
req_url = "%s/get/%s" % (self._server, self._key)
|
||||
ret = self._req.post_res(url=req_url, json={"password": self._password})
|
||||
req_url = "%s/get/%s" % (self._server, str(self._key).strip())
|
||||
ret = self._req.post_res(url=req_url, json={"password": str(self._password).strip()})
|
||||
if ret and ret.status_code == 200:
|
||||
result = ret.json()
|
||||
if not result:
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import datetime
|
||||
import re
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Tuple, Optional, List, Union
|
||||
from typing import Tuple, Optional, List, Union, Dict
|
||||
from urllib.parse import unquote
|
||||
|
||||
from requests import Response
|
||||
from torrentool.api import Torrent
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.context import Context
|
||||
from app.core.context import Context, TorrentInfo, MediaInfo
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.log import logger
|
||||
from app.utils.http import RequestUtils
|
||||
from app.schemas.types import MediaType, SystemConfigKey
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
class TorrentHelper(metaclass=Singleton):
|
||||
@@ -295,3 +297,105 @@ class TorrentHelper(metaclass=Singleton):
|
||||
"""
|
||||
if url not in self._invalid_torrents:
|
||||
self._invalid_torrents.append(url)
|
||||
|
||||
@staticmethod
|
||||
def filter_torrent(torrent_info: TorrentInfo,
|
||||
filter_rule: Dict[str, str],
|
||||
mediainfo: MediaInfo) -> bool:
|
||||
"""
|
||||
检查种子是否匹配订阅过滤规则
|
||||
"""
|
||||
|
||||
def __get_size_range(size_str: str) -> Tuple[float, float]:
|
||||
"""
|
||||
获取大小范围
|
||||
"""
|
||||
if not size_str:
|
||||
return 0, 0
|
||||
try:
|
||||
size_range = size_str.split("-")
|
||||
if len(size_range) == 1:
|
||||
return 0, float(size_range[0])
|
||||
elif len(size_range) == 2:
|
||||
return float(size_range[0]), float(size_range[1])
|
||||
except Exception as e:
|
||||
logger.error(f"解析大小范围失败:{str(e)} - {traceback.format_exc()}")
|
||||
return 0, 0
|
||||
|
||||
if not filter_rule:
|
||||
return True
|
||||
|
||||
# 匹配内容
|
||||
content = f"{torrent_info.title} {torrent_info.description} {' '.join(torrent_info.labels or [])}"
|
||||
|
||||
# 最少做种人数
|
||||
min_seeders = filter_rule.get("min_seeders")
|
||||
if min_seeders and torrent_info.seeders < int(min_seeders):
|
||||
logger.info(f"{torrent_info.title} 做种人数不足 {min_seeders}")
|
||||
return False
|
||||
|
||||
# 包含
|
||||
include = filter_rule.get("include")
|
||||
if include:
|
||||
if not re.search(r"%s" % include, content, re.I):
|
||||
logger.info(f"{torrent_info.title} 不匹配包含规则 {include}")
|
||||
return False
|
||||
# 排除
|
||||
exclude = filter_rule.get("exclude")
|
||||
if exclude:
|
||||
if re.search(r"%s" % exclude, content, re.I):
|
||||
logger.info(f"{torrent_info.title} 匹配排除规则 {exclude}")
|
||||
return False
|
||||
# 质量
|
||||
quality = filter_rule.get("quality")
|
||||
if quality:
|
||||
if not re.search(r"%s" % quality, torrent_info.title, re.I):
|
||||
logger.info(f"{torrent_info.title} 不匹配质量规则 {quality}")
|
||||
return False
|
||||
# 分辨率
|
||||
resolution = filter_rule.get("resolution")
|
||||
if resolution:
|
||||
if not re.search(r"%s" % resolution, torrent_info.title, re.I):
|
||||
logger.info(f"{torrent_info.title} 不匹配分辨率规则 {resolution}")
|
||||
return False
|
||||
# 特效
|
||||
effect = filter_rule.get("effect")
|
||||
if effect:
|
||||
if not re.search(r"%s" % effect, torrent_info.title, re.I):
|
||||
logger.info(f"{torrent_info.title} 不匹配特效规则 {effect}")
|
||||
return False
|
||||
|
||||
# 大小
|
||||
tv_size = filter_rule.get("tv_size")
|
||||
movie_size = filter_rule.get("movie_size")
|
||||
if movie_size or tv_size:
|
||||
if mediainfo.type == MediaType.TV:
|
||||
size = tv_size
|
||||
else:
|
||||
size = movie_size
|
||||
# 大小范围
|
||||
begin_size, end_size = __get_size_range(size)
|
||||
if begin_size or end_size:
|
||||
meta = MetaInfo(title=torrent_info.title, subtitle=torrent_info.description)
|
||||
# 集数
|
||||
if mediainfo.type == MediaType.TV:
|
||||
# 电视剧
|
||||
season = meta.begin_season or 1
|
||||
if meta.total_episode:
|
||||
# 识别的总集数
|
||||
episodes_num = meta.total_episode
|
||||
else:
|
||||
# 整季集数
|
||||
episodes_num = len(mediainfo.seasons.get(season) or [1])
|
||||
# 比较大小
|
||||
if not (begin_size * 1024 ** 3 <= (torrent_info.size / episodes_num) <= end_size * 1024 ** 3):
|
||||
logger.info(f"{torrent_info.title} {StringUtils.str_filesize(torrent_info.size)} "
|
||||
f"共{episodes_num}集,不匹配大小规则 {size}")
|
||||
return False
|
||||
else:
|
||||
# 电影比较大小
|
||||
if not (begin_size * 1024 ** 3 <= torrent_info.size <= end_size * 1024 ** 3):
|
||||
logger.info(
|
||||
f"{torrent_info.title} {StringUtils.str_filesize(torrent_info.size)} 不匹配大小规则 {size}")
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -62,6 +62,8 @@ class LoggerManager:
|
||||
if not plugin_name and "plugins" in parts:
|
||||
# 设定调用者插件名称
|
||||
plugin_name = parts[parts.index("plugins") + 1]
|
||||
if plugin_name == "__init__.py":
|
||||
plugin_name = "plugin"
|
||||
break
|
||||
if "main.py" in parts:
|
||||
# 已经到达程序的入口
|
||||
|
||||
@@ -35,6 +35,13 @@ class _ModuleBase(metaclass=ABCMeta):
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
模块测试, 返回测试结果和错误信息
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
def checkMessage(channel_type: MessageChannel):
|
||||
"""
|
||||
@@ -60,6 +67,8 @@ def checkMessage(channel_type: MessageChannel):
|
||||
return None
|
||||
if channel_type == MessageChannel.SynologyChat and not switch.get("synologychat"):
|
||||
return None
|
||||
if channel_type == MessageChannel.VoceChat and not switch.get("vocechat"):
|
||||
return None
|
||||
return func(self, message, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.modules.douban.douban_cache import DoubanCache
|
||||
from app.modules.douban.scraper import DoubanScraper
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.common import retry
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
@@ -29,6 +30,17 @@ class DoubanModule(_ModuleBase):
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
ret = RequestUtils().get_res("https://movie.douban.com/")
|
||||
if ret and ret.status_code == 200:
|
||||
return True, ""
|
||||
elif ret:
|
||||
return False, f"无法连接豆瓣,错误码:{ret.status_code}"
|
||||
return False, "豆瓣网络连接失败"
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
pass
|
||||
|
||||
|
||||
@@ -17,6 +17,16 @@ class EmbyModule(_ModuleBase):
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
if self.emby.is_inactive():
|
||||
self.emby.reconnect()
|
||||
if not self.emby.get_user():
|
||||
return False, "无法连接Emby,请检查参数配置"
|
||||
return True, ""
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
return "MEDIASERVER", "emby"
|
||||
|
||||
|
||||
@@ -436,20 +436,45 @@ class Emby:
|
||||
return None
|
||||
req_url = "%semby/Items/%s/RemoteImages?api_key=%s" % (self._host, item_id, self._apikey)
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
res = RequestUtils(timeout=10).get_res(req_url)
|
||||
if res:
|
||||
images = res.json().get("Images")
|
||||
for image in images:
|
||||
if image.get("ProviderName") == "TheMovieDb" and image.get("Type") == image_type:
|
||||
return image.get("Url")
|
||||
else:
|
||||
logger.error(f"Items/RemoteImages 未获取到返回数据")
|
||||
return None
|
||||
if images:
|
||||
for image in images:
|
||||
logger.info(image)
|
||||
if image.get("ProviderName") == "TheMovieDb" and image.get("Type") == image_type:
|
||||
return image.get("Url")
|
||||
# 数据为空
|
||||
logger.info(f"Items/RemoteImages 未获取到返回数据,采用本地图片")
|
||||
return self.generate_external_image_link(item_id, image_type)
|
||||
except Exception as e:
|
||||
logger.error(f"连接Items/Id/RemoteImages出错:" + str(e))
|
||||
return None
|
||||
return None
|
||||
|
||||
def generate_external_image_link(self, item_id: str, image_type: str) -> Optional[str]:
|
||||
"""
|
||||
根据ItemId和imageType查询本地对应图片
|
||||
:param item_id: 在Emby中的ID
|
||||
:param image_type: 图片类型,如Backdrop、Primary
|
||||
:return: 图片对应在外网播放器中的URL
|
||||
"""
|
||||
if not self._playhost:
|
||||
logger.error("Emby外网播放地址未能获取或为空")
|
||||
return None
|
||||
|
||||
req_url = "%sItems/%s/Images/%s" % (self._playhost, item_id, image_type)
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res and res.status_code != 404:
|
||||
logger.info("影片图片链接:{}".format(res.url))
|
||||
return res.url
|
||||
else:
|
||||
logger.error("Items/Id/Images 未获取到返回数据或无该影片{}图片".format(image_type))
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"连接Items/Id/Images出错:" + str(e))
|
||||
return None
|
||||
|
||||
def __refresh_emby_library_by_id(self, item_id: str) -> bool:
|
||||
"""
|
||||
通知Emby刷新一个项目的媒体库
|
||||
|
||||
@@ -317,6 +317,17 @@ class FanartModule(_ModuleBase):
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
ret = RequestUtils().get_res("https://webservice.fanart.tv")
|
||||
if ret and ret.status_code == 200:
|
||||
return True, ""
|
||||
elif ret:
|
||||
return False, f"无法连接fanart,错误码:{ret.status_code}"
|
||||
return False, "fanart网络连接失败"
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
return "FANART_API_KEY", True
|
||||
|
||||
|
||||
@@ -27,6 +27,40 @@ class FileTransferModule(_ModuleBase):
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
if not settings.DOWNLOAD_PATH:
|
||||
return False, "下载目录未设置"
|
||||
# 检查下载目录
|
||||
download_paths: List[str] = []
|
||||
for path in [settings.DOWNLOAD_PATH,
|
||||
settings.DOWNLOAD_MOVIE_PATH,
|
||||
settings.DOWNLOAD_TV_PATH,
|
||||
settings.DOWNLOAD_ANIME_PATH]:
|
||||
if not path:
|
||||
continue
|
||||
download_path = Path(path)
|
||||
if not download_path.exists():
|
||||
return False, f"目录 {download_path} 不存在"
|
||||
download_paths.append(path)
|
||||
# 下载目录的设备ID
|
||||
download_devids = [Path(path).stat().st_dev for path in download_paths]
|
||||
# 检查媒体库目录
|
||||
if not settings.LIBRARY_PATH:
|
||||
return False, "媒体库目录未设置"
|
||||
# 比较媒体库目录的设备ID
|
||||
for path in settings.LIBRARY_PATHS:
|
||||
library_path = Path(path)
|
||||
if not library_path.exists():
|
||||
return False, f"目录不存在:{library_path}"
|
||||
if settings.DOWNLOADER_MONITOR and settings.TRANSFER_TYPE == "link":
|
||||
if library_path.stat().st_dev not in download_devids:
|
||||
return False, f"媒体库目录 {library_path} " \
|
||||
f"与下载目录 {','.join(download_paths)} 不在同一设备,将无法硬链接"
|
||||
return True, ""
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
pass
|
||||
|
||||
@@ -171,6 +205,8 @@ class FileTransferModule(_ModuleBase):
|
||||
if (org_path.stem == Path(sub_file_name).stem) or \
|
||||
(sub_metainfo.cn_name and sub_metainfo.cn_name == metainfo.cn_name) \
|
||||
or (sub_metainfo.en_name and sub_metainfo.en_name == metainfo.en_name):
|
||||
if metainfo.part and metainfo.part != sub_metainfo.part:
|
||||
continue
|
||||
if metainfo.season \
|
||||
and metainfo.season != sub_metainfo.season:
|
||||
continue
|
||||
@@ -319,7 +355,7 @@ class FileTransferModule(_ModuleBase):
|
||||
:param transfer_type: RmtMode转移方式
|
||||
:param over_flag: 是否覆盖,为True时会先删除再转移
|
||||
"""
|
||||
if new_file.exists():
|
||||
if new_file.exists() or new_file.is_symlink():
|
||||
if not over_flag:
|
||||
logger.warn(f"文件已存在:{new_file}")
|
||||
return 0
|
||||
@@ -486,35 +522,41 @@ class FileTransferModule(_ModuleBase):
|
||||
|
||||
# 判断是否要覆盖
|
||||
overflag = False
|
||||
if new_file.exists():
|
||||
# 目标文件已存在
|
||||
logger.info(f"目标文件已存在,转移覆盖模式:{settings.OVERWRITE_MODE}")
|
||||
match settings.OVERWRITE_MODE:
|
||||
case 'always':
|
||||
# 总是覆盖同名文件
|
||||
target_file = new_file
|
||||
if new_file.exists() or new_file.is_symlink():
|
||||
if new_file.is_symlink():
|
||||
target_file = new_file.readlink()
|
||||
if not target_file.exists():
|
||||
overflag = True
|
||||
case 'size':
|
||||
# 存在时大覆盖小
|
||||
if new_file.stat().st_size < in_path.stat().st_size:
|
||||
logger.info(f"目标文件文件大小更小,将覆盖:{new_file}")
|
||||
if not overflag:
|
||||
# 目标文件已存在
|
||||
logger.info(f"目标文件已存在,转移覆盖模式:{settings.OVERWRITE_MODE}")
|
||||
match settings.OVERWRITE_MODE:
|
||||
case 'always':
|
||||
# 总是覆盖同名文件
|
||||
overflag = True
|
||||
else:
|
||||
case 'size':
|
||||
# 存在时大覆盖小
|
||||
if target_file.stat().st_size < in_path.stat().st_size:
|
||||
logger.info(f"目标文件文件大小更小,将覆盖:{new_file}")
|
||||
overflag = True
|
||||
else:
|
||||
return TransferInfo(success=False,
|
||||
message=f"媒体库中已存在,且质量更好",
|
||||
path=in_path,
|
||||
target_path=new_file,
|
||||
fail_list=[str(in_path)])
|
||||
case 'never':
|
||||
# 存在不覆盖
|
||||
return TransferInfo(success=False,
|
||||
message=f"媒体库中已存在,且质量更好",
|
||||
message=f"媒体库中已存在,当前设置为不覆盖",
|
||||
path=in_path,
|
||||
target_path=new_file,
|
||||
fail_list=[str(in_path)])
|
||||
case 'never':
|
||||
# 存在不覆盖
|
||||
return TransferInfo(success=False,
|
||||
message=f"媒体库中已存在,当前设置为不覆盖",
|
||||
path=in_path,
|
||||
target_path=new_file,
|
||||
fail_list=[str(in_path)])
|
||||
case 'latest':
|
||||
# 仅保留最新版本
|
||||
logger.info(f"仅保留最新版本,将覆盖:{new_file}")
|
||||
overflag = True
|
||||
case 'latest':
|
||||
# 仅保留最新版本
|
||||
logger.info(f"仅保留最新版本,将覆盖:{new_file}")
|
||||
overflag = True
|
||||
else:
|
||||
if settings.OVERWRITE_MODE == 'latest':
|
||||
# 文件不存在,但仅保留最新版本
|
||||
|
||||
@@ -39,12 +39,19 @@ class FilterModule(_ModuleBase):
|
||||
# 中字
|
||||
"CNSUB": {
|
||||
"include": [
|
||||
r'[中国國繁简](/|\s|\\|\|)?[繁简英粤]|[英简繁](/|\s|\\|\|)?[中繁简]|繁體|简体|[中国國][字配]|国语|國語|中文|中字'],
|
||||
r'[中国國繁简](/|\s|\\|\|)?[繁简英粤]|[英简繁](/|\s|\\|\|)?[中繁简]'
|
||||
r'|繁體|简体|[中国國][字配]|国语|國語|中文|中字|简日|繁日|简繁|繁体'
|
||||
r'|([\s,.-\[])(CHT|CHS|cht|chs)(|[\s,.-\]])'],
|
||||
"exclude": [],
|
||||
"tmdb": {
|
||||
"original_language": "zh,cn"
|
||||
}
|
||||
},
|
||||
# 官种
|
||||
"GZ": {
|
||||
"include": [r'官方', r'官种'],
|
||||
"match": ["labels"]
|
||||
},
|
||||
# 特效字幕
|
||||
"SPECSUB": {
|
||||
"include": [r'特效'],
|
||||
@@ -132,6 +139,9 @@ class FilterModule(_ModuleBase):
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def test(self):
|
||||
pass
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
pass
|
||||
|
||||
@@ -253,14 +263,30 @@ class FilterModule(_ModuleBase):
|
||||
# 符合TMDB规则的直接返回True,即不过滤
|
||||
if tmdb and self.__match_tmdb(tmdb):
|
||||
return True
|
||||
# 匹配项:标题、副标题、标签
|
||||
content = f"{torrent.title} {torrent.description} {' '.join(torrent.labels or [])}"
|
||||
# 只匹配指定关键字
|
||||
match_content = []
|
||||
matchs = self.rule_set[rule_name].get("match") or []
|
||||
if matchs:
|
||||
for match in matchs:
|
||||
if not hasattr(torrent, match):
|
||||
continue
|
||||
match_value = getattr(torrent, match)
|
||||
if not match_value:
|
||||
continue
|
||||
if isinstance(match_value, list):
|
||||
match_content.extend(match_value)
|
||||
else:
|
||||
match_content.append(match_value)
|
||||
if match_content:
|
||||
content = " ".join(match_content)
|
||||
# 包含规则项
|
||||
includes = self.rule_set[rule_name].get("include") or []
|
||||
# 排除规则项
|
||||
excludes = self.rule_set[rule_name].get("exclude") or []
|
||||
# FREE规则
|
||||
downloadvolumefactor = self.rule_set[rule_name].get("downloadvolumefactor")
|
||||
# 匹配项
|
||||
content = f"{torrent.title} {torrent.description} {' '.join(torrent.labels or [])}"
|
||||
for include in includes:
|
||||
if not re.search(r"%s" % include, content, re.IGNORECASE):
|
||||
# 未发现包含项
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import List, Optional, Tuple, Union
|
||||
from ruamel.yaml import CommentedMap
|
||||
|
||||
from app.core.context import TorrentInfo
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.modules.indexer.mtorrent import MTorrentSpider
|
||||
@@ -25,6 +26,15 @@ class IndexerModule(_ModuleBase):
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
sites = SitesHelper().get_indexers()
|
||||
if not sites:
|
||||
return False, "未配置站点或未通过用户认证"
|
||||
return True, ""
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
return "INDEXER", "builtin"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import datetime
|
||||
import re
|
||||
import traceback
|
||||
from typing import List
|
||||
from urllib.parse import quote, urlencode
|
||||
from urllib.parse import quote, urlencode, urlparse, parse_qs
|
||||
|
||||
import chardet
|
||||
from jinja2 import Template
|
||||
@@ -276,7 +276,7 @@ class TorrentSpider:
|
||||
return self.parse(page_source)
|
||||
|
||||
def __get_title(self, torrent):
|
||||
# title default
|
||||
# title default text
|
||||
if 'title' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('title', {})
|
||||
@@ -306,7 +306,7 @@ class TorrentSpider:
|
||||
selector.get('filters'))
|
||||
|
||||
def __get_description(self, torrent):
|
||||
# title optional
|
||||
# title optional text
|
||||
if 'description' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('description', {})
|
||||
@@ -352,7 +352,7 @@ class TorrentSpider:
|
||||
selector.get('filters'))
|
||||
|
||||
def __get_detail(self, torrent):
|
||||
# details
|
||||
# details page text
|
||||
if 'details' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('details', {})
|
||||
@@ -373,7 +373,7 @@ class TorrentSpider:
|
||||
self.torrents_info['page_url'] = detail_link
|
||||
|
||||
def __get_download(self, torrent):
|
||||
# download link
|
||||
# download link text
|
||||
if 'download' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('download', {})
|
||||
@@ -383,9 +383,19 @@ class TorrentSpider:
|
||||
item = self.__index(items, 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"):
|
||||
self.torrents_info['enclosure'] = self.domain + download_link[1:] if download_link.startswith(
|
||||
"/") else self.domain + 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
|
||||
|
||||
@@ -403,7 +413,7 @@ class TorrentSpider:
|
||||
selector.get('filters'))
|
||||
|
||||
def __get_size(self, torrent):
|
||||
# torrent size
|
||||
# torrent size int
|
||||
if 'size' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('size', {})
|
||||
@@ -420,7 +430,7 @@ class TorrentSpider:
|
||||
self.torrents_info['size'] = 0
|
||||
|
||||
def __get_leechers(self, torrent):
|
||||
# torrent leechers
|
||||
# torrent leechers int
|
||||
if 'leechers' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('leechers', {})
|
||||
@@ -430,6 +440,7 @@ class TorrentSpider:
|
||||
item = self.__index(items, 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
|
||||
@@ -437,7 +448,7 @@ class TorrentSpider:
|
||||
self.torrents_info['peers'] = 0
|
||||
|
||||
def __get_seeders(self, torrent):
|
||||
# torrent leechers
|
||||
# torrent leechers int
|
||||
if 'seeders' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('seeders', {})
|
||||
@@ -447,6 +458,7 @@ class TorrentSpider:
|
||||
item = self.__index(items, 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
|
||||
@@ -454,7 +466,7 @@ class TorrentSpider:
|
||||
self.torrents_info['seeders'] = 0
|
||||
|
||||
def __get_grabs(self, torrent):
|
||||
# torrent grabs
|
||||
# torrent grabs int
|
||||
if 'grabs' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('grabs', {})
|
||||
@@ -464,6 +476,7 @@ class TorrentSpider:
|
||||
item = self.__index(items, 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
|
||||
@@ -471,7 +484,7 @@ class TorrentSpider:
|
||||
self.torrents_info['grabs'] = 0
|
||||
|
||||
def __get_pubdate(self, torrent):
|
||||
# torrent pubdate
|
||||
# torrent pubdate yyyy-mm-dd hh:mm:ss
|
||||
if 'date_added' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('date_added', {})
|
||||
@@ -483,7 +496,7 @@ class TorrentSpider:
|
||||
selector.get('filters'))
|
||||
|
||||
def __get_date_elapsed(self, torrent):
|
||||
# torrent pubdate
|
||||
# torrent data elaspsed text
|
||||
if 'date_elapsed' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('date_elapsed', {})
|
||||
@@ -495,7 +508,7 @@ class TorrentSpider:
|
||||
selector.get('filters'))
|
||||
|
||||
def __get_downloadvolumefactor(self, torrent):
|
||||
# downloadvolumefactor
|
||||
# downloadvolumefactor int
|
||||
selector = self.fields.get('downloadvolumefactor', {})
|
||||
if not selector:
|
||||
return
|
||||
@@ -518,7 +531,7 @@ class TorrentSpider:
|
||||
self.torrents_info['downloadvolumefactor'] = int(downloadvolumefactor.group(1))
|
||||
|
||||
def __get_uploadvolumefactor(self, torrent):
|
||||
# uploadvolumefactor
|
||||
# uploadvolumefactor int
|
||||
selector = self.fields.get('uploadvolumefactor', {})
|
||||
if not selector:
|
||||
return
|
||||
@@ -541,7 +554,7 @@ class TorrentSpider:
|
||||
self.torrents_info['uploadvolumefactor'] = int(uploadvolumefactor.group(1))
|
||||
|
||||
def __get_labels(self, torrent):
|
||||
# labels
|
||||
# labels ['label1', 'label2']
|
||||
if 'labels' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('labels', {})
|
||||
@@ -554,7 +567,7 @@ class TorrentSpider:
|
||||
self.torrents_info['labels'] = []
|
||||
|
||||
def __get_free_date(self, torrent):
|
||||
# free date
|
||||
# free date yyyy-mm-dd hh:mm:ss
|
||||
if 'freedate' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('freedate', {})
|
||||
@@ -566,7 +579,7 @@ class TorrentSpider:
|
||||
selector.get('filters'))
|
||||
|
||||
def __get_hit_and_run(self, torrent):
|
||||
# hitandrun
|
||||
# hitandrun True/False
|
||||
if 'hr' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('hr', {})
|
||||
@@ -576,28 +589,71 @@ class TorrentSpider:
|
||||
else:
|
||||
self.torrents_info['hit_and_run'] = False
|
||||
|
||||
def __get_category(self, torrent):
|
||||
# category 电影/电视剧
|
||||
if 'category' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('category', {})
|
||||
category = torrent(selector.get('selector', '')).clone()
|
||||
self.__remove(category, selector)
|
||||
items = self.__attribute_or_text(category, selector)
|
||||
category_value = self.__index(items, 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 get_info(self, torrent) -> 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)
|
||||
|
||||
except Exception as err:
|
||||
logger.error("%s 搜索出现错误:%s" % (self.indexername, str(err)))
|
||||
return self.torrents_info
|
||||
@@ -629,6 +685,11 @@ class TorrentSpider:
|
||||
text = text.strip()
|
||||
elif method_name == "appendleft":
|
||||
text = f"{args}{text}"
|
||||
elif method_name == "querystring":
|
||||
parsed_url = urlparse(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()
|
||||
|
||||
@@ -28,6 +28,16 @@ class JellyfinModule(_ModuleBase):
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
if self.jellyfin.is_inactive():
|
||||
self.jellyfin.reconnect()
|
||||
if not self.jellyfin.get_user():
|
||||
return False, "无法连接Jellyfin,请检查参数配置"
|
||||
return True, ""
|
||||
|
||||
def user_authenticate(self, name: str, password: str) -> Optional[str]:
|
||||
"""
|
||||
使用Emby用户辅助完成用户认证
|
||||
|
||||
@@ -68,11 +68,11 @@ class Jellyfin:
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return []
|
||||
req_url = "%sLibrary/VirtualFolders/Query?api_key=%s" % (self._host, self._apikey)
|
||||
req_url = "%sLibrary/VirtualFolders?api_key=%s" % (self._host, self._apikey)
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
library_items = res.json().get("Items")
|
||||
library_items = res.json()
|
||||
librarys = []
|
||||
for library_item in library_items:
|
||||
library_name = library_item.get('Name')
|
||||
@@ -91,10 +91,10 @@ class Jellyfin:
|
||||
})
|
||||
return librarys
|
||||
else:
|
||||
logger.error(f"Library/VirtualFolders/Query 未获取到返回数据")
|
||||
logger.error(f"Library/VirtualFolders 未获取到返回数据")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"连接Library/VirtualFolders/Query 出错:" + str(e))
|
||||
logger.error(f"连接Library/VirtualFolders 出错:" + str(e))
|
||||
return []
|
||||
|
||||
def __get_jellyfin_librarys(self, username: str = None) -> List[dict]:
|
||||
@@ -422,20 +422,73 @@ class Jellyfin:
|
||||
return None
|
||||
req_url = "%sItems/%s/RemoteImages?api_key=%s" % (self._host, item_id, self._apikey)
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
res = RequestUtils(timeout=10).get_res(req_url)
|
||||
if res:
|
||||
images = res.json().get("Images")
|
||||
for image in images:
|
||||
if image.get("ProviderName") == "TheMovieDb" and image.get("Type") == image_type:
|
||||
return image.get("Url")
|
||||
# return images[0].get("Url") # 首选无则返回第一张
|
||||
else:
|
||||
logger.error(f"Items/RemoteImages 未获取到返回数据")
|
||||
return None
|
||||
logger.info(f"Items/RemoteImages 未获取到返回数据,采用本地图片")
|
||||
return self.generate_image_link(item_id, image_type, True)
|
||||
except Exception as e:
|
||||
logger.error(f"连接Items/Id/RemoteImages出错:" + str(e))
|
||||
return None
|
||||
return None
|
||||
|
||||
def generate_image_link(self, item_id: str, image_type: str, host_type: bool) -> Optional[str]:
|
||||
"""
|
||||
根据ItemId和imageType查询本地对应图片
|
||||
:param item_id: 在Jellyfin中的ID
|
||||
:param image_type: 图片类型,如Backdrop、Primary
|
||||
:param host_type: True为外网链接, False为内网链接
|
||||
:return: 图片对应在host_type的播放器中的URL
|
||||
"""
|
||||
if not self._playhost:
|
||||
logger.error("Jellyfin外网播放地址未能获取或为空")
|
||||
return None
|
||||
# 检测是否为TV
|
||||
_parent_id = self.get_itemId_ancestors(item_id, 0, "ParentBackdropItemId")
|
||||
if _parent_id:
|
||||
item_id = _parent_id
|
||||
|
||||
_host = self._host
|
||||
if host_type:
|
||||
_host = self._playhost
|
||||
req_url = "%sItems/%s/Images/%s" % (_host, item_id, image_type)
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res and res.status_code != 404:
|
||||
logger.info("影片图片链接:{}".format(res.url))
|
||||
return res.url
|
||||
else:
|
||||
logger.error("Items/Id/Images 未获取到返回数据或无该影片{}图片".format(image_type))
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"连接Items/Id/Images出错:" + str(e))
|
||||
return None
|
||||
|
||||
def get_itemId_ancestors(self, item_id: str, index: int, key: str) -> Optional[Union[str, list, int, dict, bool]]:
|
||||
"""
|
||||
获得itemId的父item
|
||||
:param item_id: 在Jellyfin中剧集的ID (S01E02的E02的item_id)
|
||||
:param index: 第几个json对象
|
||||
:param key: 需要得到父item中的键值对
|
||||
:return key对应类型的值
|
||||
"""
|
||||
req_url = "%sItems/%s/Ancestors?api_key=%s" % (self._host, item_id, self._apikey)
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
return res.json()[index].get(key)
|
||||
else:
|
||||
logger.error(f"Items/Id/Ancestors 未获取到返回数据")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"连接Items/Id/Ancestors出错:" + str(e))
|
||||
return None
|
||||
|
||||
def refresh_root_library(self) -> bool:
|
||||
"""
|
||||
通知Jellyfin刷新整个媒体库
|
||||
@@ -730,6 +783,10 @@ class Jellyfin:
|
||||
image_tag=item.get("BackdropImageTags")[0])
|
||||
else:
|
||||
image = self.__get_local_image_by_id(item.get("Id"))
|
||||
# 小部分剧集无[xxx-S01E01-thumb.jpg]图片
|
||||
image_res = RequestUtils().get_res(image)
|
||||
if not image_res or image_res.status_code == 404:
|
||||
image = self.generate_image_link(item.get("Id"), "Backdrop", False)
|
||||
if item_type == MediaType.MOVIE.value:
|
||||
title = item.get("Name")
|
||||
subtitle = item.get("ProductionYear")
|
||||
|
||||
@@ -17,6 +17,16 @@ class PlexModule(_ModuleBase):
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
if self.plex.is_inactive():
|
||||
self.plex.reconnect()
|
||||
if not self.plex.get_librarys():
|
||||
return False, "无法连接Plex,请检查参数配置"
|
||||
return True, ""
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
return "MEDIASERVER", "plex"
|
||||
|
||||
|
||||
@@ -26,6 +26,16 @@ class QbittorrentModule(_ModuleBase):
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
if self.qbittorrent.is_inactive():
|
||||
self.qbittorrent.reconnect()
|
||||
if not self.qbittorrent.transfer_info():
|
||||
return False, "无法获取Qbittorrent状态,请检查参数配置"
|
||||
return True, ""
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
return "DOWNLOADER", "qbittorrent"
|
||||
|
||||
@@ -38,7 +48,8 @@ class QbittorrentModule(_ModuleBase):
|
||||
self.qbittorrent.reconnect()
|
||||
|
||||
def download(self, content: Union[Path, str], download_dir: Path, cookie: str,
|
||||
episodes: Set[int] = None, category: str = None) -> Optional[Tuple[Optional[str], str]]:
|
||||
episodes: Set[int] = None, category: str = None,
|
||||
downloader: str = settings.DEFAULT_DOWNLOADER) -> Optional[Tuple[Optional[str], str]]:
|
||||
"""
|
||||
根据种子文件,选择并添加下载任务
|
||||
:param content: 种子文件地址或者磁力链接
|
||||
@@ -46,6 +57,7 @@ class QbittorrentModule(_ModuleBase):
|
||||
:param cookie: cookie
|
||||
:param episodes: 需要下载的集数
|
||||
:param category: 分类
|
||||
:param downloader: 下载器
|
||||
:return: 种子Hash,错误信息
|
||||
"""
|
||||
|
||||
@@ -63,8 +75,12 @@ class QbittorrentModule(_ModuleBase):
|
||||
logger.error(f"获取种子名称失败:{e}")
|
||||
return "", 0
|
||||
|
||||
# 不是默认下载器不处理
|
||||
if downloader != "qbittorrent":
|
||||
return None
|
||||
|
||||
if not content:
|
||||
return
|
||||
return None
|
||||
if isinstance(content, Path) and not content.exists():
|
||||
return None, f"种子文件不存在:{content}"
|
||||
|
||||
@@ -113,7 +129,7 @@ class QbittorrentModule(_ModuleBase):
|
||||
# 获取种子Hash
|
||||
torrent_hash = self.qbittorrent.get_torrent_id_by_tag(tags=tag)
|
||||
if not torrent_hash:
|
||||
return None, f"获取种子Hash失败:{content}"
|
||||
return None, f"下载任务添加成功,但获取Qbittorrent任务信息失败:{content}"
|
||||
else:
|
||||
if is_paused:
|
||||
# 种子文件
|
||||
@@ -151,13 +167,18 @@ class QbittorrentModule(_ModuleBase):
|
||||
return torrent_hash, "添加下载成功"
|
||||
|
||||
def list_torrents(self, status: TorrentStatus = None,
|
||||
hashs: Union[list, str] = None) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]:
|
||||
hashs: Union[list, str] = None,
|
||||
downloader: str = settings.DEFAULT_DOWNLOADER
|
||||
) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]:
|
||||
"""
|
||||
获取下载器种子列表
|
||||
:param status: 种子状态
|
||||
:param hashs: 种子Hash
|
||||
:param downloader: 下载器
|
||||
:return: 下载器中符合状态的种子列表
|
||||
"""
|
||||
if downloader != "qbittorrent":
|
||||
return None
|
||||
ret_torrents = []
|
||||
if hashs:
|
||||
# 按Hash获取
|
||||
@@ -217,13 +238,16 @@ class QbittorrentModule(_ModuleBase):
|
||||
return None
|
||||
return ret_torrents
|
||||
|
||||
def transfer_completed(self, hashs: Union[str, list],
|
||||
path: Path = None) -> None:
|
||||
def transfer_completed(self, hashs: Union[str, list], path: Path = None,
|
||||
downloader: str = settings.DEFAULT_DOWNLOADER) -> None:
|
||||
"""
|
||||
转移完成后的处理
|
||||
:param hashs: 种子Hash
|
||||
:param path: 源目录
|
||||
:param downloader: 下载器
|
||||
"""
|
||||
if downloader != "qbittorrent":
|
||||
return
|
||||
self.qbittorrent.set_torrents_tag(ids=hashs, tags=['已整理'])
|
||||
# 移动模式删除种子
|
||||
if settings.TRANSFER_TYPE == "move":
|
||||
@@ -236,48 +260,61 @@ class QbittorrentModule(_ModuleBase):
|
||||
logger.warn(f"删除残留文件夹:{path}")
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
|
||||
def remove_torrents(self, hashs: Union[str, list], delete_file: bool = True) -> bool:
|
||||
def remove_torrents(self, hashs: Union[str, list], delete_file: bool = True,
|
||||
downloader: str = settings.DEFAULT_DOWNLOADER) -> Optional[bool]:
|
||||
"""
|
||||
删除下载器种子
|
||||
:param hashs: 种子Hash
|
||||
:param delete_file: 是否删除文件
|
||||
:param downloader: 下载器
|
||||
:return: bool
|
||||
"""
|
||||
if downloader != "qbittorrent":
|
||||
return None
|
||||
return self.qbittorrent.delete_torrents(delete_file=delete_file, ids=hashs)
|
||||
|
||||
def start_torrents(self, hashs: Union[list, str]) -> bool:
|
||||
def start_torrents(self, hashs: Union[list, str],
|
||||
downloader: str = settings.DEFAULT_DOWNLOADER) -> Optional[bool]:
|
||||
"""
|
||||
开始下载
|
||||
:param hashs: 种子Hash
|
||||
:param downloader: 下载器
|
||||
:return: bool
|
||||
"""
|
||||
if downloader != "qbittorrent":
|
||||
return None
|
||||
return self.qbittorrent.start_torrents(ids=hashs)
|
||||
|
||||
def stop_torrents(self, hashs: Union[list, str]) -> bool:
|
||||
def stop_torrents(self, hashs: Union[list, str], downloader: str = settings.DEFAULT_DOWNLOADER) -> Optional[bool]:
|
||||
"""
|
||||
停止下载
|
||||
:param hashs: 种子Hash
|
||||
:param downloader: 下载器
|
||||
:return: bool
|
||||
"""
|
||||
if downloader != "qbittorrent":
|
||||
return None
|
||||
return self.qbittorrent.stop_torrents(ids=hashs)
|
||||
|
||||
def torrent_files(self, tid: str) -> Optional[TorrentFilesList]:
|
||||
def torrent_files(self, tid: str, downloader: str = settings.DEFAULT_DOWNLOADER) -> Optional[TorrentFilesList]:
|
||||
"""
|
||||
获取种子文件列表
|
||||
"""
|
||||
if downloader != "qbittorrent":
|
||||
return None
|
||||
return self.qbittorrent.get_files(tid=tid)
|
||||
|
||||
def downloader_info(self) -> schemas.DownloaderInfo:
|
||||
def downloader_info(self) -> [schemas.DownloaderInfo]:
|
||||
"""
|
||||
下载器信息
|
||||
"""
|
||||
# 调用Qbittorrent API查询实时信息
|
||||
info = self.qbittorrent.transfer_info()
|
||||
if not info:
|
||||
return schemas.DownloaderInfo()
|
||||
return schemas.DownloaderInfo(
|
||||
return [schemas.DownloaderInfo()]
|
||||
return [schemas.DownloaderInfo(
|
||||
download_speed=info.get("dl_info_speed"),
|
||||
upload_speed=info.get("up_info_speed"),
|
||||
download_size=info.get("dl_info_data"),
|
||||
upload_size=info.get("up_info_data")
|
||||
)
|
||||
)]
|
||||
|
||||
@@ -119,9 +119,9 @@ class Qbittorrent:
|
||||
tags=tags)
|
||||
return None if error else torrents or []
|
||||
|
||||
def remove_torrents_tag(self, ids: Union[str, list], tag: Union[str, list]) -> bool:
|
||||
def delete_torrents_tag(self, ids: Union[str, list], tag: Union[str, list]) -> bool:
|
||||
"""
|
||||
移除种子Tag
|
||||
删除Tag
|
||||
:param ids: 种子Hash列表
|
||||
:param tag: 标签内容
|
||||
"""
|
||||
@@ -130,10 +130,25 @@ class Qbittorrent:
|
||||
try:
|
||||
self.qbc.torrents_delete_tags(torrent_hashes=ids, tags=tag)
|
||||
return True
|
||||
except Exception as err:
|
||||
logger.error(f"删除种子Tag出错:{str(err)}")
|
||||
return False
|
||||
|
||||
def remove_torrents_tag(self, ids: Union[str, list], tag: Union[str, list]) -> bool:
|
||||
"""
|
||||
移除种子Tag
|
||||
:param ids: 种子Hash列表
|
||||
:param tag: 标签内容
|
||||
"""
|
||||
if not self.qbc:
|
||||
return False
|
||||
try:
|
||||
self.qbc.torrents_remove_tags(torrent_hashes=ids, tags=tag)
|
||||
return True
|
||||
except Exception as err:
|
||||
logger.error(f"移除种子Tag出错:{str(err)}")
|
||||
return False
|
||||
|
||||
|
||||
def set_torrents_tag(self, ids: Union[str, list], tags: list):
|
||||
"""
|
||||
设置种子状态为已整理,以及是否强制做种
|
||||
@@ -179,7 +194,7 @@ class Qbittorrent:
|
||||
通过标签多次尝试获取刚添加的种子ID,并移除标签
|
||||
"""
|
||||
torrent_id = None
|
||||
# QB添加下载后需要时间,重试5次每次等待5秒
|
||||
# QB添加下载后需要时间,重试10次每次等待3秒
|
||||
for i in range(1, 10):
|
||||
time.sleep(3)
|
||||
torrent_id = self.__get_last_add_torrentid_by_tag(tags=tags,
|
||||
@@ -187,7 +202,7 @@ class Qbittorrent:
|
||||
if torrent_id is None:
|
||||
continue
|
||||
else:
|
||||
self.remove_torrents_tag(torrent_id, tags)
|
||||
self.delete_torrents_tag(torrent_id, tags)
|
||||
break
|
||||
return torrent_id
|
||||
|
||||
|
||||
@@ -19,6 +19,15 @@ class SlackModule(_ModuleBase):
|
||||
def stop(self):
|
||||
self.slack.stop()
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
state = self.slack.get_state()
|
||||
if state:
|
||||
return True, ""
|
||||
return False, "Slack未就续,请检查参数设置和网络连接"
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
return "MESSAGER", "slack"
|
||||
|
||||
|
||||
@@ -87,6 +87,12 @@ class Slack:
|
||||
except Exception as err:
|
||||
logger.error("Slack消息接收服务停止失败: %s" % str(err))
|
||||
|
||||
def get_state(self) -> bool:
|
||||
"""
|
||||
获取状态
|
||||
"""
|
||||
return True if self._client else False
|
||||
|
||||
def send_msg(self, title: str, text: str = "", image: str = "", url: str = "", userid: str = ""):
|
||||
"""
|
||||
发送Telegram消息
|
||||
|
||||
@@ -34,6 +34,9 @@ class SubtitleModule(_ModuleBase):
|
||||
def stop(self) -> None:
|
||||
pass
|
||||
|
||||
def test(self):
|
||||
pass
|
||||
|
||||
def download_added(self, context: Context, download_dir: Path, torrent_path: Path = None) -> None:
|
||||
"""
|
||||
添加下载任务成功后,从站点下载字幕,保存到下载目录
|
||||
|
||||
@@ -16,6 +16,15 @@ class SynologyChatModule(_ModuleBase):
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
state = self.synologychat.get_state()
|
||||
if state:
|
||||
return True, ""
|
||||
return False, "SynologyChat未就续,请检查参数设置、网络连接以及机器人是否可见"
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
return "MESSAGER", "synologychat"
|
||||
|
||||
|
||||
@@ -25,6 +25,17 @@ class SynologyChat:
|
||||
def check_token(self, token: str) -> bool:
|
||||
return True if token == self._token else False
|
||||
|
||||
def get_state(self) -> bool:
|
||||
"""
|
||||
获取状态
|
||||
"""
|
||||
if not self._webhook_url or not self._token:
|
||||
return False
|
||||
ret = self.__get_bot_users()
|
||||
if ret:
|
||||
return True
|
||||
return False
|
||||
|
||||
def send_msg(self, title: str, text: str = "", image: str = "", userid: str = "") -> Optional[bool]:
|
||||
"""
|
||||
发送Telegram消息
|
||||
|
||||
@@ -18,6 +18,15 @@ class TelegramModule(_ModuleBase):
|
||||
def stop(self):
|
||||
self.telegram.stop()
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
state = self.telegram.get_state()
|
||||
if state:
|
||||
return True, ""
|
||||
return False, "Telegram未就续,请检查参数设置和网络连接"
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
return "MESSAGER", "telegram"
|
||||
|
||||
|
||||
@@ -61,6 +61,12 @@ class Telegram:
|
||||
self._polling_thread.start()
|
||||
logger.info("Telegram消息接收服务启动")
|
||||
|
||||
def get_state(self) -> bool:
|
||||
"""
|
||||
获取状态
|
||||
"""
|
||||
return self._bot is not None
|
||||
|
||||
def send_msg(self, title: str, text: str = "", image: str = "", userid: str = "") -> Optional[bool]:
|
||||
"""
|
||||
发送Telegram消息
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.modules.themoviedb.scraper import TmdbScraper
|
||||
from app.modules.themoviedb.tmdb_cache import TmdbCache
|
||||
from app.modules.themoviedb.tmdbapi import TmdbHelper
|
||||
from app.schemas.types import MediaType, MediaImageType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
@@ -38,6 +39,18 @@ class TheMovieDbModule(_ModuleBase):
|
||||
def stop(self):
|
||||
self.cache.save()
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
ret = RequestUtils(proxies=settings.PROXY).get_res(
|
||||
f"https://{settings.TMDB_API_DOMAIN}/3/movie/550?api_key={settings.TMDB_API_KEY}")
|
||||
if ret and ret.status_code == 200:
|
||||
return True, ""
|
||||
elif ret:
|
||||
return False, f"无法连接 {settings.TMDB_API_DOMAIN},错误码:{ret.status_code}"
|
||||
return False, f"{settings.TMDB_API_DOMAIN} 网络连接失败"
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
pass
|
||||
|
||||
@@ -119,7 +132,7 @@ class TheMovieDbModule(_ModuleBase):
|
||||
logger.error("识别媒体信息时未提供元数据或tmdbid")
|
||||
return None
|
||||
# 保存到缓存
|
||||
if meta and cache:
|
||||
if meta:
|
||||
self.cache.update(meta, info)
|
||||
else:
|
||||
# 使用缓存信息
|
||||
|
||||
@@ -15,6 +15,7 @@ class CategoryHelper(metaclass=Singleton):
|
||||
_categorys = {}
|
||||
_movie_categorys = {}
|
||||
_tv_categorys = {}
|
||||
_anime_categorys = {}
|
||||
|
||||
def __init__(self):
|
||||
self._category_path: Path = settings.CONFIG_PATH / "category.yaml"
|
||||
@@ -43,6 +44,7 @@ class CategoryHelper(metaclass=Singleton):
|
||||
if self._categorys:
|
||||
self._movie_categorys = self._categorys.get('movie')
|
||||
self._tv_categorys = self._categorys.get('tv')
|
||||
self._anime_categorys = self._categorys.get('anime')
|
||||
logger.info(f"已加载二级分类策略 category.yaml")
|
||||
|
||||
@property
|
||||
@@ -81,6 +83,15 @@ class CategoryHelper(metaclass=Singleton):
|
||||
return []
|
||||
return self._tv_categorys.keys()
|
||||
|
||||
@property
|
||||
def anime_categorys(self) -> list:
|
||||
"""
|
||||
获取动漫分类清单
|
||||
"""
|
||||
if not self._anime_categorys:
|
||||
return []
|
||||
return self._anime_categorys.keys()
|
||||
|
||||
def get_movie_category(self, tmdb_info) -> str:
|
||||
"""
|
||||
判断电影的分类
|
||||
@@ -91,10 +102,14 @@ class CategoryHelper(metaclass=Singleton):
|
||||
|
||||
def get_tv_category(self, tmdb_info) -> str:
|
||||
"""
|
||||
判断电视剧的分类
|
||||
判断电视剧的分类,包括动漫
|
||||
:param tmdb_info: 识别的TMDB中的信息
|
||||
:return: 二级分类的名称
|
||||
"""
|
||||
genre_ids = tmdb_info.get("genre_ids") or []
|
||||
if self._anime_categorys and genre_ids \
|
||||
and set(genre_ids).intersection(set(settings.ANIME_GENREIDS)):
|
||||
return self.get_category(self._anime_categorys, tmdb_info)
|
||||
return self.get_category(self._tv_categorys, tmdb_info)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import time
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
from xml.dom import minidom
|
||||
@@ -135,14 +136,15 @@ class TmdbScraper:
|
||||
file_path=file_path)
|
||||
# 集的图片
|
||||
episode_image = episodeinfo.get("still_path")
|
||||
image_path = file_path.with_name(file_path.stem + "-thumb.jpg").with_suffix(
|
||||
Path(episode_image).suffix)
|
||||
if episode_image and (self._force_img or not image_path.exists()):
|
||||
self.__save_image(
|
||||
f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{episode_image}",
|
||||
image_path)
|
||||
if episode_image:
|
||||
image_path = file_path.with_name(file_path.stem + "-thumb.jpg").with_suffix(
|
||||
Path(episode_image).suffix)
|
||||
if self._force_img or not image_path.exists():
|
||||
self.__save_image(
|
||||
f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{episode_image}",
|
||||
image_path)
|
||||
except Exception as e:
|
||||
logger.error(f"{file_path} 刮削失败:{str(e)}")
|
||||
logger.error(f"{file_path} 刮削失败:{str(e)} - {traceback.format_exc()}")
|
||||
|
||||
@staticmethod
|
||||
def __gen_common_nfo(mediainfo: MediaInfo, doc, root):
|
||||
@@ -307,7 +309,7 @@ class TmdbScraper:
|
||||
# 添加时间
|
||||
DomUtils.add_node(doc, root, "dateadded", time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time())))
|
||||
# TMDBID
|
||||
uniqueid = DomUtils.add_node(doc, root, "uniqueid", str(tmdbid))
|
||||
uniqueid = DomUtils.add_node(doc, root, "uniqueid", str(episodeinfo.get("id")))
|
||||
uniqueid.setAttribute("type", "tmdb")
|
||||
uniqueid.setAttribute("default", "true")
|
||||
# tmdbid
|
||||
|
||||
@@ -553,10 +553,10 @@ class TmdbHelper:
|
||||
tmdb_info['genre_ids'] = __get_genre_ids(tmdb_info.get('genres'))
|
||||
# 别名和译名
|
||||
tmdb_info['names'] = self.__get_names(tmdb_info)
|
||||
# 转换多语种标题
|
||||
self.__update_tmdbinfo_extra_title(tmdb_info)
|
||||
# 转换中文标题
|
||||
self.__update_tmdbinfo_cn_title(tmdb_info)
|
||||
# 转换英文标题
|
||||
self.__update_tmdbinfo_en_title(tmdb_info)
|
||||
|
||||
return tmdb_info
|
||||
|
||||
@@ -585,49 +585,61 @@ class TmdbHelper:
|
||||
return title
|
||||
return tmdbinfo.get("title") if tmdbinfo.get("media_type") == MediaType.MOVIE else tmdbinfo.get("name")
|
||||
|
||||
# 查找中文名
|
||||
# 原标题
|
||||
org_title = tmdb_info.get("title") \
|
||||
if tmdb_info.get("media_type") == MediaType.MOVIE \
|
||||
else tmdb_info.get("name")
|
||||
# 查找中文名
|
||||
if not StringUtils.is_chinese(org_title):
|
||||
cn_title = __get_tmdb_chinese_title(tmdb_info)
|
||||
if cn_title and cn_title != org_title:
|
||||
# 使用中文别名
|
||||
if tmdb_info.get("media_type") == MediaType.MOVIE:
|
||||
tmdb_info['title'] = cn_title
|
||||
else:
|
||||
tmdb_info['name'] = cn_title
|
||||
else:
|
||||
# 使用新加坡名
|
||||
sg_title = tmdb_info.get("sg_title")
|
||||
if sg_title and sg_title != org_title and StringUtils.is_chinese(sg_title):
|
||||
if tmdb_info.get("media_type") == MediaType.MOVIE:
|
||||
tmdb_info['title'] = sg_title
|
||||
else:
|
||||
tmdb_info['name'] = sg_title
|
||||
|
||||
@staticmethod
|
||||
def __update_tmdbinfo_en_title(tmdb_info: dict):
|
||||
def __update_tmdbinfo_extra_title(tmdb_info: dict):
|
||||
"""
|
||||
更新TMDB信息中的英文名称
|
||||
更新TMDB信息中的其它语种名称
|
||||
"""
|
||||
|
||||
def __get_tmdb_english_title(tmdbinfo):
|
||||
def __get_tmdb_lang_title(tmdbinfo: dict, lang: str = "US"):
|
||||
"""
|
||||
从别名中获取英文标题
|
||||
从译名中获取其它语种标题
|
||||
"""
|
||||
if not tmdbinfo:
|
||||
return None
|
||||
translations = tmdb_info.get("translations", {}).get("translations", [])
|
||||
for translation in translations:
|
||||
if translation.get("iso_3166_1") == "US":
|
||||
if translation.get("iso_3166_1") == lang:
|
||||
return translation.get("data", {}).get("title") if tmdbinfo.get("media_type") == MediaType.MOVIE \
|
||||
else translation.get("data", {}).get("name")
|
||||
return None
|
||||
|
||||
# 查找英文名
|
||||
# 原标题
|
||||
org_title = (
|
||||
tmdb_info.get("original_title")
|
||||
if tmdb_info.get("media_type") == MediaType.MOVIE
|
||||
else tmdb_info.get("original_name")
|
||||
)
|
||||
# 查找英文名
|
||||
if tmdb_info.get("original_language") == "en":
|
||||
tmdb_info['en_title'] = org_title
|
||||
# TODO: 对于日文标题,使用罗马字作为英文标题可能更合适?
|
||||
else:
|
||||
en_title = __get_tmdb_english_title(tmdb_info)
|
||||
en_title = __get_tmdb_lang_title(tmdb_info, "US")
|
||||
tmdb_info['en_title'] = en_title or org_title
|
||||
# 查找新加坡名(用于替代中文名)
|
||||
tmdb_info['sg_title'] = __get_tmdb_lang_title(tmdb_info, "SG") or org_title
|
||||
|
||||
def __get_movie_detail(self,
|
||||
tmdbid: int,
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
from typing import Optional, Tuple, Union
|
||||
|
||||
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.modules.thetvdb import tvdbapi
|
||||
from app.utils.http import RequestUtils
|
||||
|
||||
|
||||
class TheTvDbModule(_ModuleBase):
|
||||
|
||||
tvdb: tvdbapi.Tvdb = None
|
||||
|
||||
def init_module(self) -> None:
|
||||
@@ -20,6 +19,17 @@ class TheTvDbModule(_ModuleBase):
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
ret = RequestUtils(proxies=settings.PROXY).get_res("https://api.thetvdb.com/series/81189")
|
||||
if ret and ret.status_code == 200:
|
||||
return True, ""
|
||||
elif ret:
|
||||
return False, f"无法连接 api.thetvdb.com,错误码:{ret.status_code}"
|
||||
return False, "api.thetvdb.com 网络连接失败"
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
pass
|
||||
|
||||
|
||||
@@ -26,6 +26,16 @@ class TransmissionModule(_ModuleBase):
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
if self.transmission.is_inactive():
|
||||
self.transmission.reconnect()
|
||||
if not self.transmission.transfer_info():
|
||||
return False, "无法获取Transmission状态,请检查参数配置"
|
||||
return True, ""
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
return "DOWNLOADER", "transmission"
|
||||
|
||||
@@ -38,7 +48,8 @@ class TransmissionModule(_ModuleBase):
|
||||
self.transmission.reconnect()
|
||||
|
||||
def download(self, content: Union[Path, str], download_dir: Path, cookie: str,
|
||||
episodes: Set[int] = None, category: str = None) -> Optional[Tuple[Optional[str], str]]:
|
||||
episodes: Set[int] = None, category: str = None,
|
||||
downloader: str = settings.DEFAULT_DOWNLOADER) -> Optional[Tuple[Optional[str], str]]:
|
||||
"""
|
||||
根据种子文件,选择并添加下载任务
|
||||
:param content: 种子文件地址或者磁力链接
|
||||
@@ -46,6 +57,7 @@ class TransmissionModule(_ModuleBase):
|
||||
:param cookie: cookie
|
||||
:param episodes: 需要下载的集数
|
||||
:param category: 分类,TR中未使用
|
||||
:param downloader: 下载器
|
||||
:return: 种子Hash
|
||||
"""
|
||||
|
||||
@@ -63,8 +75,12 @@ class TransmissionModule(_ModuleBase):
|
||||
logger.error(f"获取种子名称失败:{e}")
|
||||
return "", 0
|
||||
|
||||
# 不是默认下载器不处理
|
||||
if downloader != "transmission":
|
||||
return None
|
||||
|
||||
if not content:
|
||||
return
|
||||
return None
|
||||
if isinstance(content, Path) and not content.exists():
|
||||
return None, f"种子文件不存在:{content}"
|
||||
|
||||
@@ -121,31 +137,41 @@ class TransmissionModule(_ModuleBase):
|
||||
return torrent_hash, "获取种子文件失败,下载任务可能在暂停状态"
|
||||
# 需要的文件信息
|
||||
file_ids = []
|
||||
unwanted_file_ids = []
|
||||
for torrent_file in torrent_files:
|
||||
file_id = torrent_file.id
|
||||
file_name = torrent_file.name
|
||||
meta_info = MetaInfo(file_name)
|
||||
if not meta_info.episode_list:
|
||||
unwanted_file_ids.append(file_id)
|
||||
continue
|
||||
selected = set(meta_info.episode_list).issubset(set(episodes))
|
||||
if not selected:
|
||||
unwanted_file_ids.append(file_id)
|
||||
continue
|
||||
file_ids.append(file_id)
|
||||
# 选择文件
|
||||
self.transmission.set_files(torrent_hash, file_ids)
|
||||
self.transmission.set_unwanted_files(torrent_hash, unwanted_file_ids)
|
||||
# 开始任务
|
||||
self.transmission.start_torrents(torrent_hash)
|
||||
return torrent_hash, "添加下载任务成功"
|
||||
else:
|
||||
return torrent_hash, "添加下载任务成功"
|
||||
|
||||
def list_torrents(self, status: TorrentStatus = None,
|
||||
hashs: Union[list, str] = None) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]:
|
||||
hashs: Union[list, str] = None,
|
||||
downloader: str = settings.DEFAULT_DOWNLOADER
|
||||
) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]:
|
||||
"""
|
||||
获取下载器种子列表
|
||||
:param status: 种子状态
|
||||
:param hashs: 种子Hash
|
||||
:param downloader: 下载器
|
||||
:return: 下载器中符合状态的种子列表
|
||||
"""
|
||||
if downloader != "transmission":
|
||||
return None
|
||||
ret_torrents = []
|
||||
if hashs:
|
||||
# 按Hash获取
|
||||
@@ -200,14 +226,17 @@ class TransmissionModule(_ModuleBase):
|
||||
return None
|
||||
return ret_torrents
|
||||
|
||||
def transfer_completed(self, hashs: Union[str, list],
|
||||
path: Path = None) -> None:
|
||||
def transfer_completed(self, hashs: Union[str, list], path: Path = None,
|
||||
downloader: str = settings.DEFAULT_DOWNLOADER) -> None:
|
||||
"""
|
||||
转移完成后的处理
|
||||
:param hashs: 种子Hash
|
||||
:param path: 源目录
|
||||
:param downloader: 下载器
|
||||
:return: None
|
||||
"""
|
||||
if downloader != "transmission":
|
||||
return None
|
||||
self.transmission.set_torrent_tag(ids=hashs, tags=['已整理'])
|
||||
# 移动模式删除种子
|
||||
if settings.TRANSFER_TYPE == "move":
|
||||
@@ -220,47 +249,61 @@ class TransmissionModule(_ModuleBase):
|
||||
logger.warn(f"删除残留文件夹:{path}")
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
|
||||
def remove_torrents(self, hashs: Union[str, list], delete_file: bool = True) -> bool:
|
||||
def remove_torrents(self, hashs: Union[str, list], delete_file: bool = True,
|
||||
downloader: str = settings.DEFAULT_DOWNLOADER) -> Optional[bool]:
|
||||
"""
|
||||
删除下载器种子
|
||||
:param hashs: 种子Hash
|
||||
:param delete_file: 是否删除文件
|
||||
:param downloader: 下载器
|
||||
:return: bool
|
||||
"""
|
||||
if downloader != "transmission":
|
||||
return None
|
||||
return self.transmission.delete_torrents(delete_file=delete_file, ids=hashs)
|
||||
|
||||
def start_torrents(self, hashs: Union[list, str]) -> bool:
|
||||
def start_torrents(self, hashs: Union[list, str],
|
||||
downloader: str = settings.DEFAULT_DOWNLOADER) -> Optional[bool]:
|
||||
"""
|
||||
开始下载
|
||||
:param hashs: 种子Hash
|
||||
:param downloader: 下载器
|
||||
:return: bool
|
||||
"""
|
||||
if downloader != "transmission":
|
||||
return None
|
||||
return self.transmission.start_torrents(ids=hashs)
|
||||
|
||||
def stop_torrents(self, hashs: Union[list, str]) -> bool:
|
||||
def stop_torrents(self, hashs: Union[list, str],
|
||||
downloader: str = settings.DEFAULT_DOWNLOADER) -> Optional[bool]:
|
||||
"""
|
||||
停止下载
|
||||
:param hashs: 种子Hash
|
||||
:param downloader: 下载器
|
||||
:return: bool
|
||||
"""
|
||||
if downloader != "transmission":
|
||||
return None
|
||||
return self.transmission.start_torrents(ids=hashs)
|
||||
|
||||
def torrent_files(self, tid: str) -> Optional[List[File]]:
|
||||
def torrent_files(self, tid: str, downloader: str = settings.DEFAULT_DOWNLOADER) -> Optional[List[File]]:
|
||||
"""
|
||||
获取种子文件列表
|
||||
"""
|
||||
if downloader != "transmission":
|
||||
return None
|
||||
return self.transmission.get_files(tid=tid)
|
||||
|
||||
def downloader_info(self) -> schemas.DownloaderInfo:
|
||||
def downloader_info(self) -> [schemas.DownloaderInfo]:
|
||||
"""
|
||||
下载器信息
|
||||
"""
|
||||
info = self.transmission.transfer_info()
|
||||
if not info:
|
||||
return schemas.DownloaderInfo()
|
||||
return schemas.DownloaderInfo(
|
||||
return [schemas.DownloaderInfo()]
|
||||
return [schemas.DownloaderInfo(
|
||||
download_speed=info.download_speed,
|
||||
upload_speed=info.upload_speed,
|
||||
download_size=info.current_stats.downloaded_bytes,
|
||||
upload_size=info.current_stats.uploaded_bytes
|
||||
)
|
||||
)]
|
||||
|
||||
@@ -242,6 +242,19 @@ class Transmission:
|
||||
logger.error(f"设置下载文件状态出错:{str(err)}")
|
||||
return False
|
||||
|
||||
def set_unwanted_files(self, tid: str, file_ids: list) -> bool:
|
||||
"""
|
||||
设置下载文件的状态
|
||||
"""
|
||||
if not self.trc:
|
||||
return False
|
||||
try:
|
||||
self.trc.change_torrent(ids=tid, files_unwanted=file_ids)
|
||||
return True
|
||||
except Exception as err:
|
||||
logger.error(f"设置下载文件状态出错:{str(err)}")
|
||||
return False
|
||||
|
||||
def transfer_info(self) -> Optional[SessionStats]:
|
||||
"""
|
||||
获取传输信息
|
||||
@@ -359,3 +372,4 @@ class Transmission:
|
||||
except Exception as err:
|
||||
logger.error(f"修改tracker出错:{str(err)}")
|
||||
return False
|
||||
|
||||
|
||||
128
app/modules/vocechat/__init__.py
Normal file
128
app/modules/vocechat/__init__.py
Normal file
@@ -0,0 +1,128 @@
|
||||
import json
|
||||
from typing import Optional, Union, List, Tuple, Any, Dict
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.context import Context, MediaInfo
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase, checkMessage
|
||||
from app.modules.vocechat.vocechat import VoceChat
|
||||
from app.schemas import MessageChannel, CommingMessage, Notification
|
||||
|
||||
|
||||
class VoceChatModule(_ModuleBase):
|
||||
vocechat: VoceChat = None
|
||||
|
||||
def init_module(self) -> None:
|
||||
self.vocechat = VoceChat()
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
state = self.vocechat.get_state()
|
||||
if state:
|
||||
return True, ""
|
||||
return False, "获取VoceChat频道失败"
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
return "MESSAGER", "vocechat"
|
||||
|
||||
@staticmethod
|
||||
def message_parser(body: Any, form: Any,
|
||||
args: Any) -> Optional[CommingMessage]:
|
||||
"""
|
||||
解析消息内容,返回字典,注意以下约定值:
|
||||
userid: 用户ID
|
||||
username: 用户名
|
||||
text: 内容
|
||||
:param body: 请求体
|
||||
:param form: 表单
|
||||
:param args: 参数
|
||||
:return: 渠道、消息体
|
||||
"""
|
||||
try:
|
||||
"""
|
||||
{
|
||||
"created_at": 1672048481664, //消息创建的时间戳
|
||||
"detail": {
|
||||
"content": "hello this is my message to you", //消息内容
|
||||
"content_type": "text/plain", //消息类型,text/plain:纯文本消息,text/markdown:markdown消息,vocechat/file:文件类消息
|
||||
"expires_in": null, //消息过期时长,如果有大于0数字,说明该消息是个限时消息
|
||||
"properties": null, //一些有关消息的元数据,比如at信息,文件消息的具体类型信息,如果是个图片消息,还会有一些宽高,图片名称等元信息
|
||||
"type": "normal" //消息类型,normal代表是新消息
|
||||
},
|
||||
"from_uid": 7910, //来自于谁
|
||||
"mid": 2978, //消息ID
|
||||
"target": { "gid": 2 } //发送给谁,gid代表是发送给频道,uid代表是发送给个人,此时的数据结构举例:{"uid":1}
|
||||
}
|
||||
"""
|
||||
# 报文体
|
||||
msg_body = json.loads(body)
|
||||
# 类型
|
||||
msg_type = msg_body.get("detail", {}).get("type")
|
||||
if msg_type != "normal":
|
||||
# 非新消息
|
||||
return None
|
||||
logger.debug(f"收到VoceChat请求:{msg_body}")
|
||||
# token校验
|
||||
token = args.get("token")
|
||||
if not token or token != settings.API_TOKEN:
|
||||
logger.warn(f"VoceChat请求token校验失败:{token}")
|
||||
return None
|
||||
# 文本内容
|
||||
content = msg_body.get("detail", {}).get("content")
|
||||
# 用户ID
|
||||
gid = msg_body.get("target", {}).get("gid")
|
||||
if gid and str(gid) == str(settings.VOCECHAT_CHANNEL_ID):
|
||||
# 来自监听频道的消息
|
||||
userid = f"GID#{gid}"
|
||||
else:
|
||||
# 来自个人的消息
|
||||
userid = f"UID#{msg_body.get('from_uid')}"
|
||||
|
||||
# 处理消息内容
|
||||
if content and userid:
|
||||
logger.info(f"收到VoceChat消息:userid={userid}, text={content}")
|
||||
return CommingMessage(channel=MessageChannel.VoceChat,
|
||||
userid=userid, username=userid, text=content)
|
||||
except Exception as err:
|
||||
logger.error(f"VoceChat消息处理发生错误:{str(err)}")
|
||||
return None
|
||||
|
||||
@checkMessage(MessageChannel.VoceChat)
|
||||
def post_message(self, message: Notification) -> None:
|
||||
"""
|
||||
发送消息
|
||||
:param message: 消息内容
|
||||
:return: 成功或失败
|
||||
"""
|
||||
self.vocechat.send_msg(title=message.title, text=message.text, userid=message.userid)
|
||||
|
||||
@checkMessage(MessageChannel.VoceChat)
|
||||
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> Optional[bool]:
|
||||
"""
|
||||
发送媒体信息选择列表
|
||||
:param message: 消息内容
|
||||
:param medias: 媒体列表
|
||||
:return: 成功或失败
|
||||
"""
|
||||
# 先发送标题
|
||||
self.vocechat.send_msg(title=message.title, userid=message.userid)
|
||||
# 再发送内容
|
||||
return self.vocechat.send_medias_msg(title=message.title, medias=medias, userid=message.userid)
|
||||
|
||||
@checkMessage(MessageChannel.VoceChat)
|
||||
def post_torrents_message(self, message: Notification, torrents: List[Context]) -> Optional[bool]:
|
||||
"""
|
||||
发送种子信息选择列表
|
||||
:param message: 消息内容
|
||||
:param torrents: 种子列表
|
||||
:return: 成功或失败
|
||||
"""
|
||||
return self.vocechat.send_torrents_msg(title=message.title, torrents=torrents, userid=message.userid)
|
||||
|
||||
def register_commands(self, commands: Dict[str, dict]):
|
||||
pass
|
||||
191
app/modules/vocechat/vocechat.py
Normal file
191
app/modules/vocechat/vocechat.py
Normal file
@@ -0,0 +1,191 @@
|
||||
import re
|
||||
import threading
|
||||
from typing import Optional, List
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo, Context
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.log import logger
|
||||
from app.utils.common import retry
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
lock = threading.Lock()
|
||||
|
||||
|
||||
class VoceChat:
|
||||
# host
|
||||
_host = None
|
||||
# apikey
|
||||
_apikey = None
|
||||
# 频道ID
|
||||
_channel_id = None
|
||||
# 请求对象
|
||||
_client = None
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
初始化
|
||||
"""
|
||||
self._host = settings.VOCECHAT_HOST
|
||||
if self._host:
|
||||
if not self._host.endswith("/"):
|
||||
self._host += "/"
|
||||
if not self._host.startswith("http"):
|
||||
self._playhost = "http://" + self._host
|
||||
self._apikey = settings.VOCECHAT_API_KEY
|
||||
self._channel_id = settings.VOCECHAT_CHANNEL_ID
|
||||
if self._apikey and self._host and self._channel_id:
|
||||
self._client = RequestUtils(headers={
|
||||
"content-type": "text/markdown",
|
||||
"x-api-key": self._apikey,
|
||||
"accept": "application/json; charset=utf-8"
|
||||
})
|
||||
|
||||
def get_state(self):
|
||||
"""
|
||||
获取状态
|
||||
"""
|
||||
return True if self.get_groups() else False
|
||||
|
||||
def get_groups(self):
|
||||
"""
|
||||
获取频道列表
|
||||
"""
|
||||
if not self._client:
|
||||
return None
|
||||
result = self._client.get_res(f"{self._host}api/bot")
|
||||
if result and result.status_code == 200:
|
||||
return result.json()
|
||||
|
||||
def send_msg(self, title: str, text: str = "", userid: str = None) -> Optional[bool]:
|
||||
"""
|
||||
微信消息发送入口,支持文本、图片、链接跳转、指定发送对象
|
||||
:param title: 消息标题
|
||||
:param text: 消息内容
|
||||
:param userid: 消息发送对象的ID,为空则发给所有人
|
||||
:return: 发送状态,错误信息
|
||||
"""
|
||||
if not self._client:
|
||||
return None
|
||||
|
||||
if not title and not text:
|
||||
logger.warn("标题和内容不能同时为空")
|
||||
return False
|
||||
|
||||
try:
|
||||
if text:
|
||||
caption = f"**{title}**\n{text}"
|
||||
else:
|
||||
caption = f"**{title}**"
|
||||
|
||||
if userid:
|
||||
chat_id = userid
|
||||
else:
|
||||
chat_id = f"GID#{self._channel_id}"
|
||||
|
||||
return self.__send_request(userid=chat_id, caption=caption)
|
||||
|
||||
except Exception as msg_e:
|
||||
logger.error(f"发送消息失败:{msg_e}")
|
||||
return False
|
||||
|
||||
def send_medias_msg(self, title: str, medias: List[MediaInfo], userid: str = "") -> Optional[bool]:
|
||||
"""
|
||||
发送列表类消息
|
||||
"""
|
||||
if not self._client:
|
||||
return None
|
||||
|
||||
try:
|
||||
index, caption = 1, "**%s**" % title
|
||||
for media in medias:
|
||||
if media.vote_average:
|
||||
caption = "%s\n%s. [%s](%s)\n_%s,%s_" % (caption,
|
||||
index,
|
||||
media.title_year,
|
||||
media.detail_link,
|
||||
f"类型:{media.type.value}",
|
||||
f"评分:{media.vote_average}")
|
||||
else:
|
||||
caption = "%s\n%s. [%s](%s)\n_%s_" % (caption,
|
||||
index,
|
||||
media.title_year,
|
||||
media.detail_link,
|
||||
f"类型:{media.type.value}")
|
||||
index += 1
|
||||
|
||||
if userid:
|
||||
chat_id = userid
|
||||
else:
|
||||
chat_id = f"GID#{self._channel_id}"
|
||||
|
||||
return self.__send_request(userid=chat_id, caption=caption)
|
||||
|
||||
except Exception as msg_e:
|
||||
logger.error(f"发送消息失败:{msg_e}")
|
||||
return False
|
||||
|
||||
def send_torrents_msg(self, torrents: List[Context],
|
||||
userid: str = "", title: str = "") -> Optional[bool]:
|
||||
"""
|
||||
发送列表消息
|
||||
"""
|
||||
if not self._client:
|
||||
return None
|
||||
|
||||
if not torrents:
|
||||
return False
|
||||
|
||||
try:
|
||||
index, caption = 1, "**%s**" % title
|
||||
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}↑"
|
||||
caption = f"{caption}\n{index}.【{site_name}】[{title}]({link}) " \
|
||||
f"{StringUtils.str_filesize(torrent.size)} {free} {seeder}"
|
||||
index += 1
|
||||
|
||||
if userid:
|
||||
chat_id = userid
|
||||
else:
|
||||
chat_id = f"GID#{self._channel_id}"
|
||||
|
||||
return self.__send_request(userid=chat_id, caption=caption)
|
||||
|
||||
except Exception as msg_e:
|
||||
logger.error(f"发送消息失败:{msg_e}")
|
||||
return False
|
||||
|
||||
@retry(Exception, logger=logger)
|
||||
def __send_request(self, userid: str, caption: str) -> bool:
|
||||
"""
|
||||
向VoceChat发送报文
|
||||
userid格式:UID#xxx / GID#xxx
|
||||
"""
|
||||
if not self._client:
|
||||
return False
|
||||
if userid.startswith("GID#"):
|
||||
action = "send_to_group"
|
||||
else:
|
||||
action = "send_to_user"
|
||||
idstr = userid[4:]
|
||||
|
||||
with lock:
|
||||
result = self._client.post_res(f"{self._host}api/bot/{action}/{idstr}", data=caption.encode("utf-8"))
|
||||
if result and result.status_code == 200:
|
||||
return True
|
||||
elif result is not None:
|
||||
logger.error(f"VoceChat发送消息失败,错误码:{result.status_code}")
|
||||
return False
|
||||
else:
|
||||
raise Exception("VoceChat发送消息失败,连接失败")
|
||||
@@ -20,6 +20,15 @@ class WechatModule(_ModuleBase):
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
state = self.wechat.get_state()
|
||||
if state:
|
||||
return True, ""
|
||||
return False, "获取微信token失败"
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
return "MESSAGER", "wechat"
|
||||
|
||||
|
||||
@@ -47,6 +47,12 @@ class WeChat:
|
||||
if self._corpid and self._appsecret and self._appid:
|
||||
self.__get_access_token()
|
||||
|
||||
def get_state(self):
|
||||
"""
|
||||
获取状态
|
||||
"""
|
||||
return True if self.__get_access_token else False
|
||||
|
||||
@retry(Exception, logger=logger)
|
||||
def __get_access_token(self, force=False):
|
||||
"""
|
||||
|
||||
220
app/scheduler.py
220
app/scheduler.py
@@ -1,10 +1,12 @@
|
||||
import logging
|
||||
import threading
|
||||
import traceback
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List
|
||||
|
||||
import pytz
|
||||
from apscheduler.executors.pool import ThreadPoolExecutor
|
||||
from apscheduler.jobstores.base import JobLookupError
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
from app import schemas
|
||||
@@ -37,14 +39,21 @@ class Scheduler(metaclass=Singleton):
|
||||
定时任务管理
|
||||
"""
|
||||
# 定时服务
|
||||
_scheduler = BackgroundScheduler(timezone=settings.TZ,
|
||||
executors={
|
||||
'default': ThreadPoolExecutor(20)
|
||||
})
|
||||
_scheduler = None
|
||||
# 退出事件
|
||||
_event = threading.Event()
|
||||
# 锁
|
||||
_lock = threading.Lock()
|
||||
# 各服务的运行状态
|
||||
_jobs = {}
|
||||
|
||||
def __init__(self):
|
||||
self.init()
|
||||
|
||||
def init(self):
|
||||
"""
|
||||
初始化定时服务
|
||||
"""
|
||||
|
||||
def clear_cache():
|
||||
"""
|
||||
@@ -88,10 +97,19 @@ class Scheduler(metaclass=Singleton):
|
||||
}
|
||||
}
|
||||
|
||||
# 停止定时服务
|
||||
self.stop()
|
||||
|
||||
# 调试模式不启动定时服务
|
||||
if settings.DEV:
|
||||
return
|
||||
|
||||
# 创建定时服务
|
||||
self._scheduler = BackgroundScheduler(timezone=settings.TZ,
|
||||
executors={
|
||||
'default': ThreadPoolExecutor(100)
|
||||
})
|
||||
|
||||
# CookieCloud定时同步
|
||||
if settings.COOKIECLOUD_INTERVAL \
|
||||
and str(settings.COOKIECLOUD_INTERVAL).isdigit():
|
||||
@@ -232,25 +250,8 @@ class Scheduler(metaclass=Singleton):
|
||||
)
|
||||
|
||||
# 注册插件公共服务
|
||||
plugin_services = PluginManager().get_plugin_services()
|
||||
for service in plugin_services:
|
||||
try:
|
||||
self._jobs[service["id"]] = {
|
||||
"func": service["func"],
|
||||
"running": False,
|
||||
}
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
service["trigger"],
|
||||
id=service["id"],
|
||||
name=service["name"],
|
||||
**service["kwargs"],
|
||||
kwargs={
|
||||
'job_id': service["id"]
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"注册插件服务失败:{str(e)} - {service}")
|
||||
for pid in PluginManager().get_running_plugin_ids():
|
||||
self.update_plugin_job(pid)
|
||||
|
||||
# 打印服务
|
||||
logger.debug(self._scheduler.print_jobs())
|
||||
@@ -263,56 +264,153 @@ class Scheduler(metaclass=Singleton):
|
||||
启动定时服务
|
||||
"""
|
||||
# 处理job_id格式
|
||||
job = self._jobs.get(job_id)
|
||||
if not job:
|
||||
return
|
||||
if job.get("running"):
|
||||
logger.warning(f"定时任务 {job_id} 正在运行 ...")
|
||||
return
|
||||
self._jobs[job_id]["running"] = True
|
||||
with self._lock:
|
||||
job = self._jobs.get(job_id)
|
||||
if not job:
|
||||
return
|
||||
if job.get("running"):
|
||||
logger.warning(f"定时任务 {job_id} 正在运行 ...")
|
||||
return
|
||||
self._jobs[job_id]["running"] = True
|
||||
# 开始运行
|
||||
try:
|
||||
if not kwargs:
|
||||
kwargs = job.get("kwargs") or {}
|
||||
job["func"](*args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"定时任务 {job_id} 执行失败:{str(e)}")
|
||||
self._jobs[job_id]["running"] = False
|
||||
logger.error(f"定时任务 {job_id} 执行失败:{str(e)} - {traceback.format_exc()}")
|
||||
# 运行结束
|
||||
with self._lock:
|
||||
try:
|
||||
self._jobs[job_id]["running"] = False
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def update_plugin_job(self, pid: str):
|
||||
"""
|
||||
更新插件定时服务
|
||||
"""
|
||||
if not self._scheduler:
|
||||
return
|
||||
# 移除该插件的全部服务
|
||||
self.remove_plugin_job(pid)
|
||||
# 获取插件服务列表
|
||||
with self._lock:
|
||||
try:
|
||||
plugin_services = PluginManager().run_plugin_method(pid, "get_service") or []
|
||||
except Exception as e:
|
||||
logger.error(f"运行插件 {pid} 服务失败:{str(e)} - {traceback.format_exc()}")
|
||||
return
|
||||
# 获取插件名称
|
||||
plugin_name = PluginManager().get_plugin_attr(pid, "plugin_name")
|
||||
# 开始注册插件服务
|
||||
for service in plugin_services:
|
||||
try:
|
||||
sid = f"{service['id']}"
|
||||
job_id = sid.split("|")[0]
|
||||
if job_id not in self._jobs:
|
||||
self._jobs[job_id] = {
|
||||
"func": service["func"],
|
||||
"name": service["name"],
|
||||
"pid": pid,
|
||||
"plugin_name": plugin_name,
|
||||
"running": False,
|
||||
}
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
service["trigger"],
|
||||
id=sid,
|
||||
name=service["name"],
|
||||
**service["kwargs"],
|
||||
kwargs={
|
||||
'job_id': job_id
|
||||
}
|
||||
)
|
||||
logger.info(f"注册插件{plugin_name}服务:{service['name']} - {service['trigger']}")
|
||||
except Exception as e:
|
||||
logger.error(f"注册插件{plugin_name}服务失败:{str(e)} - {service}")
|
||||
|
||||
def remove_plugin_job(self, pid: str):
|
||||
"""
|
||||
移除插件定时服务
|
||||
"""
|
||||
if not self._scheduler:
|
||||
return
|
||||
with self._lock:
|
||||
# 获取插件名称
|
||||
plugin_name = PluginManager().get_plugin_attr(pid, "plugin_name")
|
||||
for job_id, service in self._jobs.copy().items():
|
||||
try:
|
||||
if service.get("pid") == pid:
|
||||
self._jobs.pop(job_id, None)
|
||||
try:
|
||||
self._scheduler.remove_job(job_id)
|
||||
except JobLookupError:
|
||||
pass
|
||||
logger.info(f"移除插件服务({plugin_name}):{service.get('name')}")
|
||||
except Exception as e:
|
||||
logger.error(f"移除插件服务失败:{str(e)} - {job_id}: {service}")
|
||||
|
||||
def list(self) -> List[schemas.ScheduleInfo]:
|
||||
"""
|
||||
当前所有任务
|
||||
"""
|
||||
# 返回计时任务
|
||||
schedulers = []
|
||||
# 去重
|
||||
added = []
|
||||
jobs = self._scheduler.get_jobs()
|
||||
# 按照下次运行时间排序
|
||||
jobs.sort(key=lambda x: x.next_run_time)
|
||||
for job in jobs:
|
||||
if job.name not in added:
|
||||
added.append(job.name)
|
||||
else:
|
||||
continue
|
||||
job_id = job.id.split("|")[0]
|
||||
if not self._jobs.get(job_id):
|
||||
continue
|
||||
# 任务状态
|
||||
status = "正在运行" if self._jobs[job_id].get("running") else "等待"
|
||||
# 下次运行时间
|
||||
next_run = TimerUtils.time_difference(job.next_run_time)
|
||||
schedulers.append(schemas.ScheduleInfo(
|
||||
id=job_id,
|
||||
name=job.name,
|
||||
status=status,
|
||||
next_run=next_run
|
||||
))
|
||||
return schedulers
|
||||
if not self._scheduler:
|
||||
return []
|
||||
with self._lock:
|
||||
# 返回计时任务
|
||||
schedulers = []
|
||||
# 去重
|
||||
added = []
|
||||
jobs = self._scheduler.get_jobs()
|
||||
# 按照下次运行时间排序
|
||||
jobs.sort(key=lambda x: x.next_run_time)
|
||||
# 将正在运行的任务提取出来 (保障一次性任务正常显示)
|
||||
for job_id, service in self._jobs.items():
|
||||
name = service.get("name")
|
||||
plugin_name = service.get("plugin_name")
|
||||
if service.get("running") and name and plugin_name:
|
||||
if name not in added:
|
||||
added.append(name)
|
||||
schedulers.append(schemas.ScheduleInfo(
|
||||
id=job_id,
|
||||
name=name,
|
||||
provider=plugin_name,
|
||||
status="正在运行",
|
||||
))
|
||||
# 获取其他待执行任务
|
||||
for job in jobs:
|
||||
if job.name not in added:
|
||||
added.append(job.name)
|
||||
else:
|
||||
continue
|
||||
job_id = job.id.split("|")[0]
|
||||
service = self._jobs.get(job_id)
|
||||
if not service:
|
||||
continue
|
||||
# 任务状态
|
||||
status = "正在运行" if service.get("running") else "等待"
|
||||
# 下次运行时间
|
||||
next_run = TimerUtils.time_difference(job.next_run_time)
|
||||
schedulers.append(schemas.ScheduleInfo(
|
||||
id=job_id,
|
||||
name=job.name,
|
||||
provider=service.get("plugin_name", "[系统]"),
|
||||
status=status,
|
||||
next_run=next_run
|
||||
))
|
||||
return schedulers
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
关闭定时服务
|
||||
"""
|
||||
self._event.set()
|
||||
if self._scheduler.running:
|
||||
self._scheduler.shutdown()
|
||||
try:
|
||||
if self._scheduler:
|
||||
self._event.set()
|
||||
self._scheduler.remove_all_jobs()
|
||||
if self._scheduler.running:
|
||||
self._scheduler.shutdown()
|
||||
self._scheduler = None
|
||||
except Exception as e:
|
||||
logger.error(f"停止定时任务失败::{str(e)} - {traceback.format_exc()}")
|
||||
|
||||
@@ -94,7 +94,7 @@ class MediaInfo(BaseModel):
|
||||
# 海报图片
|
||||
poster_path: Optional[str] = None
|
||||
# 评分
|
||||
vote_average: Optional[int] = 0
|
||||
vote_average: Optional[float] = 0
|
||||
# 描述
|
||||
overview: Optional[str] = None
|
||||
# 二级分类
|
||||
|
||||
@@ -56,6 +56,8 @@ class ScheduleInfo(BaseModel):
|
||||
id: Optional[str] = None
|
||||
# 名称
|
||||
name: Optional[str] = None
|
||||
# 提供者
|
||||
provider: Optional[str] = None
|
||||
# 状态
|
||||
status: Optional[str] = None
|
||||
# 下次执行时间
|
||||
|
||||
@@ -53,3 +53,5 @@ class NotificationSwitch(BaseModel):
|
||||
slack: Optional[bool] = False
|
||||
# SynologyChat开关
|
||||
synologychat: Optional[bool] = False
|
||||
# VoceChat开关
|
||||
vocechat: Optional[bool] = False
|
||||
|
||||
@@ -59,6 +59,8 @@ class Subscribe(BaseModel):
|
||||
current_priority: Optional[int] = None
|
||||
# 保存路径
|
||||
save_path: Optional[str] = None
|
||||
# 是否使用 imdbid 搜索
|
||||
search_imdbid: Optional[int] = 0
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
@@ -31,6 +31,7 @@ class DownloadingTorrent(BaseModel):
|
||||
dlspeed: Optional[str] = None
|
||||
media: Optional[dict] = {}
|
||||
userid: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
left_time: Optional[str] = None
|
||||
|
||||
|
||||
|
||||
@@ -116,3 +116,4 @@ class MessageChannel(Enum):
|
||||
Telegram = "Telegram"
|
||||
Slack = "Slack"
|
||||
SynologyChat = "SynologyChat"
|
||||
VoceChat = "VoceChat"
|
||||
|
||||
@@ -389,6 +389,8 @@ class SystemUtils:
|
||||
"""
|
||||
判断是否为蓝光原盘目录
|
||||
"""
|
||||
if not dir_path.is_dir():
|
||||
return False
|
||||
# 蓝光原盘目录必备的文件或文件夹
|
||||
required_files = ['BDMV', 'CERTIFICATE']
|
||||
# 检查目录下是否存在所需文件或文件夹
|
||||
|
||||
@@ -56,7 +56,7 @@ class TimerUtils:
|
||||
|
||||
days = time_difference.days
|
||||
hours, remainder = divmod(time_difference.seconds, 3600)
|
||||
minutes, _ = divmod(remainder, 60)
|
||||
minutes, second = divmod(remainder, 60)
|
||||
|
||||
time_difference_string = ""
|
||||
if days > 0:
|
||||
@@ -65,6 +65,8 @@ class TimerUtils:
|
||||
time_difference_string += f"{hours}小时"
|
||||
if minutes > 0:
|
||||
time_difference_string += f"{minutes}分钟"
|
||||
if not time_difference_string and second:
|
||||
time_difference_string = f"{second}秒"
|
||||
|
||||
return time_difference_string
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ DEV=false
|
||||
SUPERUSER=admin
|
||||
# 大内存模式,开启后会增加缓存数量,但会占用更多内存
|
||||
BIG_MEMORY_MODE=false
|
||||
# 元数据识别缓存过期时间,数字型,单位小时,0为系统默认(大内存模式为7天,滞则为3天),调大该值可减少themoviedb的访问次数
|
||||
META_CACHE_EXPIRE=0
|
||||
# 自动检查和更新站点资源包(索引、认证等)
|
||||
AUTO_UPDATE_RESOURCE=true
|
||||
# 【*】API密钥,建议更换复杂字符串,有Jellyseerr/Overseerr、媒体服务器Webhook等配置以及部分支持API_TOKEN的API中使用
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
# 配置电影的分类策略, 配置为空或者不配置该项则不启用电影分类
|
||||
####### 配置说明 #######
|
||||
# 1. 该配置文件用于配置电影和电视剧的分类策略,配置后程序会按照配置的分类策略名称进行分类,配置文件采用yaml格式,需要严格附合语法规则
|
||||
# 2. 配置文件中的一级分类名称:`movie`、`tv` 为固定名称不可修改,二级名称同时也是目录名称,会按先后顺序匹配,匹配后程序会按这个名称建立二级目录
|
||||
# 3. 支持的分类条件:
|
||||
# `original_language` 语种,具体含义参考下方字典
|
||||
# `production_countries` 国家或地区(电影)、`origin_country` 国家或地区(电视剧),具体含义参考下方字典
|
||||
# `genre_ids` 内容类型,具体含义参考下方字典
|
||||
# themoviedb 详情API返回的其它一级字段
|
||||
# 4. 配置多项条件时需要同时满足,一个条件需要匹配多个值是使用`,`分隔
|
||||
|
||||
# 配置电影的分类策略
|
||||
movie:
|
||||
# 分类名同时也是目录名,会按先后顺序匹配,匹配后程序会按这个名称建立二级目录
|
||||
# 分类名同时也是目录名
|
||||
动画电影:
|
||||
# 分类依据,可以是:original_language 语种、production_countries 国家或地区、genre_ids 内容类型等,只要TMDB API返回的字段中有就行
|
||||
# 配置多项条件时,需要同时满足;不需要的匹配项可以删掉或者配置为空
|
||||
# 匹配 genre_ids 内容类型,16是动漫
|
||||
genre_ids: '16'
|
||||
华语电影:
|
||||
# 匹配语种
|
||||
original_language: 'zh,cn,bo,za'
|
||||
# 未配置任何过滤条件时,则按先后顺序不符合上面分类的都会在这个分类下,建议配置在最末尾
|
||||
# 未匹配以上条件时,分类为外语电影
|
||||
外语电影:
|
||||
|
||||
# 配置电视剧的分类策略, 配置为空或者不配置该项则不启用电视剧分类
|
||||
# 配置电视剧的分类策略
|
||||
tv:
|
||||
# 分类名同时也是目录名,会按先后顺序匹配,匹配后程序会按这个名称建立二级目录
|
||||
# 分类名同时也是目录名
|
||||
动漫:
|
||||
# 分类依据,可以是:original_language 语种、origin_country 国家或地区、genre_ids 内容类型等,只要TMDB API返回的字段中有就行
|
||||
# 配置多项条件时,需要同时满足;不需要的匹配项可以删掉或者配置为空
|
||||
# 匹配 genre_ids 内容类型,16是动漫
|
||||
genre_ids: '16'
|
||||
纪录片:
|
||||
@@ -41,6 +47,15 @@ tv:
|
||||
# 未匹配以上分类,则命名为未分类
|
||||
未分类:
|
||||
|
||||
# 配置动漫的分类策略
|
||||
anime:
|
||||
国漫:
|
||||
# 匹配 origin_country 国家,CN是中国大陆,TW是中国台湾,HK是中国香港
|
||||
origin_country: 'CN,TW,HK'
|
||||
日番:
|
||||
# 匹配 origin_country 国家,JP是日本
|
||||
origin_country: 'JP'
|
||||
未分类:
|
||||
|
||||
## genre_ids 内容类型 字典,注意部分中英文是不一样的
|
||||
# 28 Action
|
||||
@@ -163,7 +178,7 @@ tv:
|
||||
# cn 中文
|
||||
# zu 祖鲁语
|
||||
|
||||
## origin_country 国家地区 字典
|
||||
## origin_country/production_countries 国家地区 字典
|
||||
# AR 阿根廷
|
||||
# AU 澳大利亚
|
||||
# BE 比利时
|
||||
|
||||
30
database/versions/127a25fdf0e8_1_0_13.py
Normal file
30
database/versions/127a25fdf0e8_1_0_13.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""1.0.13
|
||||
|
||||
Revision ID: 127a25fdf0e8
|
||||
Revises: d71e624f0208
|
||||
Create Date: 2024-02-24 03:11:32.005540
|
||||
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '127a25fdf0e8'
|
||||
down_revision = 'd71e624f0208'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with contextlib.suppress(Exception):
|
||||
with op.batch_alter_table("subscribe") as batch_op:
|
||||
batch_op.add_column(sa.Column('search_imdbid', sa.Integer, nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
31
database/versions/f94cd1217fd7_1_0_14.py
Normal file
31
database/versions/f94cd1217fd7_1_0_14.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""1_0_14
|
||||
|
||||
Revision ID: f94cd1217fd7
|
||||
Revises: 127a25fdf0e8
|
||||
Create Date: 2024-03-06 19:19:33.053186
|
||||
|
||||
"""
|
||||
import contextlib
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f94cd1217fd7'
|
||||
down_revision = '127a25fdf0e8'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with contextlib.suppress(Exception):
|
||||
with op.batch_alter_table("subscribe") as batch_op:
|
||||
batch_op.add_column(sa.Column('manual_total_episode', sa.Integer, nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
16
update
16
update
@@ -19,6 +19,8 @@ download_and_unzip() {
|
||||
|
||||
# 下载程序资源,$1: 后端版本路径
|
||||
install_backend_and_download_resources() {
|
||||
# 清理临时目录,上次安装失败可能有残留
|
||||
rm -rf /tmp/*
|
||||
if download_and_unzip "https://github.com/jxxghp/MoviePilot/archive/refs/${1}" "App"; then
|
||||
echo "后端程序下载成功"
|
||||
pip install ${PIP_OPTIONS} --upgrade pip
|
||||
@@ -28,6 +30,12 @@ install_backend_and_download_resources() {
|
||||
if [[ "${frontend_version}" == *v* ]]; then
|
||||
if download_and_unzip "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${frontend_version}/dist.zip" "dist"; then
|
||||
echo "前端程序下载成功"
|
||||
# 提前备份插件目录
|
||||
rm -rf /plugins
|
||||
mkdir -p /plugins
|
||||
cp -a /app/app/plugins/* /plugins/
|
||||
# 不备份__init__.py
|
||||
rm -f /plugins/__init__.py
|
||||
# 清空目录
|
||||
rm -rf /app
|
||||
mkdir -p /app
|
||||
@@ -43,12 +51,6 @@ install_backend_and_download_resources() {
|
||||
echo "开始更新插件..."
|
||||
if download_and_unzip "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" "Plugins"; then
|
||||
echo "插件下载成功"
|
||||
# 备份插件目录
|
||||
rm -rf /plugins
|
||||
mkdir -p /plugins
|
||||
cp -a /app/app/plugins/* /plugins/
|
||||
# 不备份__init__.py
|
||||
rm -f /plugins/__init__.py
|
||||
# 恢复插件目录
|
||||
cp -a /plugins/* /app/app/plugins/
|
||||
# 插件仓库
|
||||
@@ -128,4 +130,4 @@ elif [[ "${MOVIEPILOT_AUTO_UPDATE}" = "false" ]]; then
|
||||
echo "程序自动升级已关闭,如需自动升级请在创建容器时设置环境变量:MOVIEPILOT_AUTO_UPDATE=release"
|
||||
else
|
||||
echo "MOVIEPILOT_AUTO_UPDATE 变量设置错误"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -1 +1 @@
|
||||
APP_VERSION = 'v1.6.7'
|
||||
APP_VERSION = 'v1.7.2'
|
||||
|
||||
Reference in New Issue
Block a user