Compare commits

...

63 Commits

Author SHA1 Message Date
jxxghp
1cd768b3d0 v1.7.2
- 站点索引新增支持`蟹黄堡`,修复了`蝴蝶`、`蜜柑`的索引问题
- 针对themoviedb被大量删除中文标题的问题,补充使用新加坡(zh-sg)中文标题搜索和刮削
- 支持设定识别元数据的缓存时间(`META_CACHE_EXPIRE`,单位小时)
- 修复了未设定anime分类策略时,原tv下动漫二级分类失效的问题
- 提升了插件升级的使用体验
2024-03-13 08:21:59 +08:00
jxxghp
abc26b65ed fix #1645 兼容蝴蝶种子链接格式 2024-03-12 17:01:41 +08:00
jxxghp
dc1a41da90 fix 减少不必要的检测 2024-03-12 13:48:37 +08:00
jxxghp
a95dac1b32 fix 目录检测 2024-03-12 13:36:33 +08:00
jxxghp
18d9620687 #1653 搜索词中加入新加坡标题,同时主标题不是中文时会考虑使用中文新加坡标题 2024-03-12 11:55:47 +08:00
jxxghp
8808dcee52 fix 1659 2024-03-12 11:16:10 +08:00
jxxghp
17adc4deab Merge pull request #1662 from thsrite/main 2024-03-11 16:36:19 +08:00
thsrite
9351489166 fix 不查缓存识别媒体信息也应更新最新信息到缓存 2024-03-11 16:34:53 +08:00
jxxghp
e2148cb77f fix 2024-03-11 16:28:36 +08:00
jxxghp
e322204094 Merge pull request #1661 from jeblove/main 2024-03-11 16:25:05 +08:00
jxxghp
0fa884157a 支持设定meta缓存时间 2024-03-11 16:23:07 +08:00
jeblove
96468213fe Merge branch 'main' of https://github.com/jeblove/MoviePilot 2024-03-11 16:17:36 +08:00
jeblove
d044a9db00 fix 继续观看部分剧集图片 2024-03-11 16:17:10 +08:00
jxxghp
d5f5e0d526 Merge pull request #1660 from thsrite/main 2024-03-11 15:59:20 +08:00
thsrite
14a3bb8fc2 add db订阅、下载历史根据类型和时间查询列表(插件方法) 2024-03-11 15:56:19 +08:00
jxxghp
5921d43ae8 fix #1655 2024-03-11 12:34:19 +08:00
jxxghp
635061c054 Merge pull request #1654 from jeblove/main 2024-03-11 11:32:27 +08:00
jeblove
3c8c6e5375 fix 语法问题 2024-03-11 11:27:24 +08:00
jeblove
dd063bb16b fix 播放剧集微信消息推送图片问题 2024-03-11 01:57:57 +08:00
jeblove
750711611b fix 语法问题 2024-03-11 00:15:55 +08:00
jxxghp
d3983c51c2 Merge pull request #1652 from jeblove/main 2024-03-10 18:39:16 +08:00
jeblove
b9dec73773 fix 语法问题 2024-03-10 18:10:09 +08:00
jeblove
b310367d25 fix 播放微信消息推送图片问题 2024-03-10 17:50:01 +08:00
jxxghp
55beea87fd Merge pull request #1649 from thsrite/main 2024-03-10 11:24:57 +08:00
thsrite
4510382f74 fix tv动漫分类不生效 2024-03-10 09:27:48 +08:00
jxxghp
9b9ae9401e fix bug 2024-03-09 21:33:59 +08:00
jxxghp
e10464c278 v1.7.1
- 动漫独立目录时支持二级分类(category.yml配置模板已更新)
- 支持同时启用两个下载器,但只有第1个才会被默认使用(官方插件库个别插件进行了适配升级)
- 实时日志的最新日志显示在最顶部
- 优化了下载器及媒体目录的健康检查
- 优化了版本升级后因为浏览器缓存一直加载中的问题
- 优先级规则新增支持`官种`
- 修复了普通用户无法查看下载中任务的问题
- 修复了设定中修改定时服务相关设置时不立即生效的问题
2024-03-09 21:20:36 +08:00
jxxghp
542531a1ca fix yyyymmdd期 识别 2024-03-09 21:13:23 +08:00
jxxghp
04c21232e3 fix yyyymmdd期 识别 2024-03-09 21:03:29 +08:00
jxxghp
48a19fd57c fix 下载器测试 2024-03-09 20:03:57 +08:00
jxxghp
59cb69a96b Merge pull request #1643 from jeblove/main 2024-03-09 19:39:31 +08:00
jeblove
e7d94f7f70 fix 对接Library/VirtualFolders接口参数 2024-03-09 19:03:02 +08:00
jxxghp
27d2d01a20 feat:下载器支持多选 2024-03-09 18:52:27 +08:00
jxxghp
8b4495c857 feat:下载器支持多选 2024-03-09 18:25:04 +08:00
jxxghp
15bdb694cc fix 优化部分消息格式 2024-03-09 17:43:21 +08:00
jxxghp
3ef9c5ea2c fix 优化部分消息格式 2024-03-09 17:34:49 +08:00
jxxghp
ab6577f752 fix #1561 2024-03-09 17:12:21 +08:00
jxxghp
49a82d7a48 feat:新增官种优先级规则
fix #1635
feat:动漫支持二级目录 fix #1633
2024-03-09 09:53:15 +08:00
jxxghp
bdcbb168a0 Merge pull request #1636 from HankunYu/main 2024-03-09 08:05:49 +08:00
HankunYu
2e1cb0bd76 fix #1630
这里混淆了remove_tags与delete_tags。将原来的remove tags函数更正为delete,并新增一个remove tags函数。
2024-03-08 18:23:42 +00:00
jxxghp
851864cd49 fix 定时服务立即生效
fix #1615
2024-03-08 16:22:53 +08:00
jxxghp
b5d7b6fb53 fix 订阅全局通知 2024-03-08 15:38:40 +08:00
jxxghp
92bab2fc2f fix 下载全局通知 2024-03-08 15:27:24 +08:00
jxxghp
0dad6860c4 fix 下载任务userid登记 2024-03-08 14:40:00 +08:00
jxxghp
de4a7becc2 Merge pull request #1620 from thsrite/main 2024-03-08 11:58:52 +08:00
thsrite
2eeb24e22d fix 不开下载器监控,link测试无意义 2024-03-08 09:29:30 +08:00
jxxghp
e4a67ea052 - 修复了健康检查themoviedb、thetvdb时未使用内置代理的问题
- 修复了VoceChat部分场景下消息发送失败的问题
- 修复了VoceChat响应干扰了微信回调的问题
- 提升了VoceChat的安全性,机器人Webhook需要参考说明重新设置

注意:VoceChat机器人Webhook回调地址相对路径调整为:`/api/v1/message/?token=moviepilot`,其中`moviepilot`为环境变量中设置的`API_TOKEN`
2024-03-07 18:22:17 +08:00
jxxghp
a4df2f5213 fix wechat bug 2024-03-07 18:15:04 +08:00
jxxghp
4f89780a0f Merge remote-tracking branch 'origin/main' 2024-03-07 18:01:57 +08:00
jxxghp
26d6201b30 fix wechat bug 2024-03-07 18:01:50 +08:00
jxxghp
c9a9ff2692 Merge pull request #1613 from WangEdward/main 2024-03-07 17:48:31 +08:00
Edward
0be49953b4 fix: change vote to float 2024-03-07 09:45:14 +00:00
jxxghp
0de952f090 fix 2024-03-07 17:15:04 +08:00
jxxghp
2b570bf48f fix:提升VoceChat安全性 2024-03-07 17:07:28 +08:00
jxxghp
9476017af5 Merge remote-tracking branch 'origin/main' 2024-03-07 12:43:05 +08:00
jxxghp
54f808485e fix #1608 2024-03-07 12:42:59 +08:00
jxxghp
fa5c82899b Merge pull request #1605 from HankunYu/main
Update 中文字幕过滤
2024-03-07 12:33:06 +08:00
HankunYu
4a57071809 Update 中字过滤规则
添加匹配小写
2024-03-07 02:48:29 +00:00
HankunYu
4631db9a45 Update 中字过滤规则
去除重复 简体, 严格CHT以及CHS匹配规则
2024-03-07 02:43:59 +00:00
jxxghp
0f09da55b0 Merge pull request #1606 from thsrite/main 2024-03-07 09:22:21 +08:00
thsrite
b14b41c2c1 fix 系统健康检查tmdb、tvdb走代理 2024-03-07 09:20:20 +08:00
HankunYu
897758d829 Merge remote-tracking branch 'upstream/main' 2024-03-06 19:54:39 +00:00
HankunYu
c450dfc0fa Update 中文字幕过滤
添加对于动画番剧中文字幕识别的支持
2024-03-06 14:09:19 +00:00
44 changed files with 587 additions and 197 deletions

View File

@@ -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`/`VoceChat`远程管理,其中 微信/Telegram 将会自动添加操作菜单微信菜单条数有限制部分菜单不显示微信需要在官方页面设置回调地址SynologyChat/VoceChat 需要设置机器人传入地址/Webhook地址相对路径均为`/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
```
![image](https://github.com/jxxghp/MoviePilot/assets/51039935/f2654b09-26f3-464f-a0af-1de3f97832ee)
![image](https://github.com/jxxghp/MoviePilot/assets/51039935/fcb87529-56dd-43df-8337-6e34b8582819)

View File

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

View File

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

View File

@@ -36,21 +36,11 @@ async def user_message(background_tasks: BackgroundTasks, request: Request):
return schemas.Response(success=True)
@router.get("/", summary="VoceChat验证")
def vocechat_verify() -> Any:
"""
VoceChat验证响应
"""
return {"status": "OK"}
@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,
@@ -68,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:
"""

View File

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

View File

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

View File

@@ -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]]:
"""
下载器信息
"""

View File

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

View File

@@ -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":
# 上一页

View File

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

View File

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

View File

@@ -143,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()))
@@ -323,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
)
@@ -708,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)
# 判断是否要完成订阅
@@ -858,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))

View File

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

View File

@@ -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']
# 索引器
@@ -123,7 +125,7 @@ class Settings(BaseSettings):
VOCECHAT_API_KEY: str = ""
# VoceChat 频道ID
VOCECHAT_CHANNEL_ID: str = ""
# 下载器 qbittorrent/transmission
# 下载器 qbittorrent/transmission,启用多个下载器时使用,分隔,只有第一个会被默认使用
DOWNLOADER: str = "qbittorrent"
# 下载器监控开关
DOWNLOADER_MONITOR: bool = True
@@ -228,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:
@@ -280,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,
@@ -288,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
@@ -360,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:

View File

@@ -139,6 +139,8 @@ class MediaInfo:
title: str = None
# 英文标题
en_title: str = None
# 新加坡标题
sg_title: str = None
# 年份
year: str = None
# 季
@@ -164,7 +166,7 @@ class MediaInfo:
# LOGO
logo_path: str = None
# 评分
vote_average: int = 0
vote_average: float = 0
# 描述
overview: str = None
# 风格ID
@@ -374,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')

View File

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

View File

@@ -51,6 +51,13 @@ 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]:
"""
测试模块

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
import time
from sqlalchemy import Column, Integer, String, Sequence
from sqlalchemy.orm import Session
@@ -126,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)

View File

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

View File

@@ -324,24 +324,26 @@ class TorrentHelper(metaclass=Singleton):
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,
f"{torrent_info.title} {torrent_info.description}", re.I):
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,
f"{torrent_info.title} {torrent_info.description}", re.I):
if re.search(r"%s" % exclude, content, re.I):
logger.info(f"{torrent_info.title} 匹配排除规则 {exclude}")
return False
# 质量

View File

@@ -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刷新一个项目的媒体库

View File

@@ -34,21 +34,31 @@ class FileTransferModule(_ModuleBase):
if not settings.DOWNLOAD_PATH:
return False, "下载目录未设置"
# 检查下载目录
download_path = Path(settings.DOWNLOAD_PATH)
if not download_path.exists():
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
download_devid = download_path.stat().st_dev
# 比较媒体库目录的设备ID
for path in settings.LIBRARY_PATHS:
library_path = Path(path)
if not library_path.exists():
return False, f"目录不存在:{library_path}"
if settings.TRANSFER_TYPE == "link":
if library_path.stat().st_dev != download_devid:
return False, "下载目录与媒体库目录不在同一设备,将导致硬链接失败"
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]]:
@@ -195,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

View File

@@ -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'特效'],
@@ -256,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):
# 未发现包含项

View File

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

View File

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

View File

@@ -32,8 +32,8 @@ class QbittorrentModule(_ModuleBase):
"""
if self.qbittorrent.is_inactive():
self.qbittorrent.reconnect()
if self.qbittorrent.is_inactive():
return False, "无法连接Qbittorrent请检查参数配置"
if not self.qbittorrent.transfer_info():
return False, "无法获取Qbittorrent状态,请检查参数配置"
return True, ""
def init_setting(self) -> Tuple[str, Union[str, bool]]:
@@ -48,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: 种子文件地址或者磁力链接
@@ -56,6 +57,7 @@ class QbittorrentModule(_ModuleBase):
:param cookie: cookie
:param episodes: 需要下载的集数
:param category: 分类
:param downloader: 下载器
:return: 种子Hash错误信息
"""
@@ -73,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}"
@@ -161,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获取
@@ -227,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":
@@ -246,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")
)
)]

View File

@@ -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):
"""
设置种子状态为已整理,以及是否强制做种
@@ -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

View File

@@ -43,7 +43,8 @@ class TheMovieDbModule(_ModuleBase):
"""
测试模块连接性
"""
ret = RequestUtils().get_res(f"https://{settings.TMDB_API_DOMAIN}/3/movie/550?api_key={settings.TMDB_API_KEY}")
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:
@@ -131,7 +132,7 @@ class TheMovieDbModule(_ModuleBase):
logger.error("识别媒体信息时未提供元数据或tmdbid")
return None
# 保存到缓存
if meta and cache:
if meta:
self.cache.update(meta, info)
else:
# 使用缓存信息

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
from typing import Optional, Tuple, Union
from app.core.config import settings
from app.log import logger
from app.modules import _ModuleBase
@@ -9,7 +8,6 @@ from app.utils.http import RequestUtils
class TheTvDbModule(_ModuleBase):
tvdb: tvdbapi.Tvdb = None
def init_module(self) -> None:
@@ -25,7 +23,7 @@ class TheTvDbModule(_ModuleBase):
"""
测试模块连接性
"""
ret = RequestUtils().get_res("https://api.thetvdb.com/series/81189")
ret = RequestUtils(proxies=settings.PROXY).get_res("https://api.thetvdb.com/series/81189")
if ret and ret.status_code == 200:
return True, ""
elif ret:

View File

@@ -32,8 +32,8 @@ class TransmissionModule(_ModuleBase):
"""
if self.transmission.is_inactive():
self.transmission.reconnect()
if self.transmission.is_inactive():
return False, "无法连接Transmission请检查参数配置"
if not self.transmission.transfer_info():
return False, "无法获取Transmission状态,请检查参数配置"
return True, ""
def init_setting(self) -> Tuple[str, Union[str, bool]]:
@@ -48,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: 种子文件地址或者磁力链接
@@ -56,6 +57,7 @@ class TransmissionModule(_ModuleBase):
:param cookie: cookie
:param episodes: 需要下载的集数
:param category: 分类TR中未使用
:param downloader: 下载器
:return: 种子Hash
"""
@@ -73,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}"
@@ -154,13 +160,18 @@ class TransmissionModule(_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 != "transmission":
return None
ret_torrents = []
if hashs:
# 按Hash获取
@@ -215,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":
@@ -235,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
)
)]

View File

@@ -67,6 +67,11 @@ class VoceChatModule(_ModuleBase):
# 非新消息
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

View File

@@ -98,10 +98,8 @@ class VoceChat:
return None
try:
index, image, caption = 1, "", "**%s**" % title
index, caption = 1, "**%s**" % title
for media in medias:
if not image:
image = media.get_message_image()
if media.vote_average:
caption = "%s\n%s. [%s](%s)\n_%s%s_" % (caption,
index,
@@ -141,7 +139,6 @@ class VoceChat:
try:
index, caption = 1, "**%s**" % title
mediainfo = torrents[0].media_info
for context in torrents:
torrent = context.torrent_info
site_name = torrent.site_name
@@ -163,8 +160,7 @@ class VoceChat:
else:
chat_id = f"GID#{self._channel_id}"
return self.__send_request(userid=chat_id, caption=caption,
image=mediainfo.get_message_image())
return self.__send_request(userid=chat_id, caption=caption)
except Exception as msg_e:
logger.error(f"发送消息失败:{msg_e}")
@@ -183,17 +179,13 @@ class VoceChat:
else:
action = "send_to_user"
idstr = userid[4:]
with lock:
try:
logger.info(f"VoceChat发送消息action={action}, userid={idstr}, text={caption}")
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发送消息失败连接失败")
except Exception as msg_e:
logger.error(f"VoceChat发送消息错误{str(msg_e)}")
return False
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发送消息失败连接失败")

View File

@@ -39,16 +39,21 @@ class Scheduler(metaclass=Singleton):
定时任务管理
"""
# 定时服务
_scheduler = BackgroundScheduler(timezone=settings.TZ,
executors={
'default': ThreadPoolExecutor(100)
})
_scheduler = None
# 退出事件
_event = threading.Event()
# 锁
_lock = threading.Lock()
# 各服务的运行状态
_jobs = {}
def __init__(self):
self.init()
def init(self):
"""
初始化定时服务
"""
def clear_cache():
"""
@@ -92,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():
@@ -264,7 +278,7 @@ class Scheduler(metaclass=Singleton):
kwargs = job.get("kwargs") or {}
job["func"](*args, **kwargs)
except Exception as e:
logger.error(f"定时任务 {job_id} 执行失败:{str(e)}")
logger.error(f"定时任务 {job_id} 执行失败:{str(e)} - {traceback.format_exc()}")
# 运行结束
with self._lock:
try:
@@ -276,6 +290,8 @@ class Scheduler(metaclass=Singleton):
"""
更新插件定时服务
"""
if not self._scheduler:
return
# 移除该插件的全部服务
self.remove_plugin_job(pid)
# 获取插件服务列表
@@ -318,6 +334,8 @@ class Scheduler(metaclass=Singleton):
"""
移除插件定时服务
"""
if not self._scheduler:
return
with self._lock:
# 获取插件名称
plugin_name = PluginManager().get_plugin_attr(pid, "plugin_name")
@@ -337,6 +355,8 @@ class Scheduler(metaclass=Singleton):
"""
当前所有任务
"""
if not self._scheduler:
return []
with self._lock:
# 返回计时任务
schedulers = []
@@ -385,6 +405,12 @@ class Scheduler(metaclass=Singleton):
"""
关闭定时服务
"""
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()}")

View File

@@ -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
# 二级分类

View File

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

View File

@@ -389,6 +389,8 @@ class SystemUtils:
"""
判断是否为蓝光原盘目录
"""
if not dir_path.is_dir():
return False
# 蓝光原盘目录必备的文件或文件夹
required_files = ['BDMV', 'CERTIFICATE']
# 检查目录下是否存在所需文件或文件夹

View File

@@ -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中使用

View File

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

View File

@@ -1 +1 @@
APP_VERSION = 'v1.7.0'
APP_VERSION = 'v1.7.2'