mirror of
https://github.com/d0zingcat/MoviePilot-Plugins.git
synced 2026-05-13 15:09:12 +00:00
init v2 plugins
This commit is contained in:
39
package.json
39
package.json
@@ -137,7 +137,6 @@
|
||||
"icon": "scraper.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.5": "修复未获取fanart图片的问题",
|
||||
"v1.4.1": "修复nfo文件读取失败时任务中断问题"
|
||||
@@ -150,8 +149,7 @@
|
||||
"version": "1.2.2",
|
||||
"icon": "delete.jpg",
|
||||
"author": "jxxghp",
|
||||
"level": 2,
|
||||
"v2": true
|
||||
"level": 2
|
||||
},
|
||||
"MediaSyncDel": {
|
||||
"name": "媒体文件同步删除",
|
||||
@@ -161,7 +159,6 @@
|
||||
"icon": "mediasyncdel.png",
|
||||
"author": "thsrite",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.7": "修复重新整理被一并删除问题",
|
||||
"v1.6": "修复删除辅种",
|
||||
@@ -190,7 +187,6 @@
|
||||
"icon": "Librespeed_A.png",
|
||||
"author": "Shurelol",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.2": "增加不限速路径配置,以应对网盘直链播放的情况"
|
||||
}
|
||||
@@ -218,7 +214,6 @@
|
||||
"icon": "like.jpg",
|
||||
"author": "wlj",
|
||||
"level": 2,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v2.3": "修复定时任务运行问题,Jellyfin的Webhook需要主程序大于1.8.7才能正常订阅。",
|
||||
"v2.2": "修复运行报错问题"
|
||||
@@ -232,7 +227,6 @@
|
||||
"icon": "mediaplay.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.3": "兼容处理Emby部分客户端暂停重复推送停止播放webhook的场景",
|
||||
"v1.2": "播放通知增加超链接跳转(需要v1.9.4+)"
|
||||
@@ -245,8 +239,7 @@
|
||||
"version": "1.2",
|
||||
"icon": "refresh2.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"v2": true
|
||||
"level": 1
|
||||
},
|
||||
"WebHook": {
|
||||
"name": "Webhook",
|
||||
@@ -306,7 +299,6 @@
|
||||
"icon": "IYUU.png",
|
||||
"author": "jxxghp",
|
||||
"level": 2,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.9.5": "Revert qBittorrent跳检之后自动开始",
|
||||
"v1.9.4": "修复qBittorrent辅种后不会自动开始做种",
|
||||
@@ -330,7 +322,6 @@
|
||||
"icon": "qingwa.png",
|
||||
"author": "233@qingwa",
|
||||
"level": 2,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v2.2": "站点停用后会同步暂停对该站点的辅种",
|
||||
"v2.3": "站点辅种支持代理"
|
||||
@@ -344,7 +335,6 @@
|
||||
"icon": "vcbmonitor.png",
|
||||
"author": "pixel@qingwa",
|
||||
"level": 2,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.8.2.1": "修复日志输出&同步目录监控插件功能",
|
||||
"v1.8.2": "提高识别率",
|
||||
@@ -361,7 +351,6 @@
|
||||
"icon": "seed.png",
|
||||
"author": "jxxghp",
|
||||
"level": 2,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.5": "修复在转移时只保留了第一个tracker,导致红种问题。此修复确保保留所有的tracker,以提高在不同网络条件下的可达性。",
|
||||
"v1.4": "支持自动删除源下载器在目的下载器中存在的种子"
|
||||
@@ -375,7 +364,6 @@
|
||||
"icon": "rss.png",
|
||||
"author": "jxxghp",
|
||||
"level": 2,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.5": "支持按种子大小过滤种子",
|
||||
"v1.4": "修复剧集本地是否存在的判断错误问题",
|
||||
@@ -390,7 +378,6 @@
|
||||
"icon": "Youtube-dl_A.png",
|
||||
"author": "thsrite",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.1.1": "修复时区问题导致的上次同步后8h内的种子不同步的问题"
|
||||
}
|
||||
@@ -403,7 +390,6 @@
|
||||
"icon": "brush.jpg",
|
||||
"author": "jxxghp,InfinityPacer",
|
||||
"level": 2,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v3.8": "添加自动归档记录天数配置项,支持定时归档已删除数据",
|
||||
"v3.7": "下载数量调整为仅获取刷流标签种子并修复了一些细节问题",
|
||||
@@ -438,8 +424,7 @@
|
||||
"version": "1.1",
|
||||
"icon": "clean.png",
|
||||
"author": "thsrite",
|
||||
"level": 2,
|
||||
"v2": true
|
||||
"level": 2
|
||||
},
|
||||
"InvitesSignin": {
|
||||
"name": "药丸签到",
|
||||
@@ -462,7 +447,6 @@
|
||||
"icon": "actor.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v1.4": "人物图片调整为优先从TMDB获取,避免douban图片CDN加载过慢的问题",
|
||||
"v1.3": "修复v1.8.5版本后刮削报错问题"
|
||||
@@ -489,8 +473,7 @@
|
||||
"version": "1.3",
|
||||
"icon": "clouddisk.png",
|
||||
"author": "thsrite",
|
||||
"level": 1,
|
||||
"v2": true
|
||||
"level": 1
|
||||
},
|
||||
"BarkMsg": {
|
||||
"name": "Bark消息推送",
|
||||
@@ -530,7 +513,6 @@
|
||||
"icon": "setting.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v2.6": "支持DOH相关配置项",
|
||||
"v2.5": "增加Github加速服务器设置项"
|
||||
@@ -553,8 +535,7 @@
|
||||
"version": "1.1",
|
||||
"icon": "Element_A.png",
|
||||
"author": "叮叮当",
|
||||
"level": 1,
|
||||
"v2": true
|
||||
"level": 1
|
||||
},
|
||||
"CustomIndexer": {
|
||||
"name": "自定义索引站点",
|
||||
@@ -573,8 +554,7 @@
|
||||
"version": "1.2",
|
||||
"icon": "ffmpeg.png",
|
||||
"author": "jxxghp",
|
||||
"level": 1,
|
||||
"v2": true
|
||||
"level": 1
|
||||
},
|
||||
"PushPlusMsg": {
|
||||
"name": "PushPlus消息推送",
|
||||
@@ -594,7 +574,6 @@
|
||||
"icon": "Youtube-dl_B.png",
|
||||
"author": "叮叮当",
|
||||
"level": 1,
|
||||
"v2": true,
|
||||
"history": {
|
||||
"v2.1": "修复错误的TmdbHelper模块引用"
|
||||
}
|
||||
@@ -684,8 +663,7 @@
|
||||
"version": "1.3",
|
||||
"icon": "Gatus_A.png",
|
||||
"author": "jeblove",
|
||||
"level": 1,
|
||||
"v2": true
|
||||
"level": 1
|
||||
},
|
||||
"QbCommand": {
|
||||
"name": "QB远程操作",
|
||||
@@ -740,7 +718,8 @@
|
||||
"v1.4": "支持仪表板组件显示",
|
||||
"v1.3": "修复观众做种数据异常问题",
|
||||
"v1.2": "修复契约检查无数据返回的问题"
|
||||
}
|
||||
},
|
||||
"v2": true
|
||||
},
|
||||
"FeiShuMsg": {
|
||||
"name": "飞书机器人消息通知",
|
||||
|
||||
603
plugins.v2/autoclean/__init__.py
Normal file
603
plugins.v2/autoclean/__init__.py
Normal file
@@ -0,0 +1,603 @@
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager
|
||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||
from app.db.transferhistory_oper import TransferHistoryOper
|
||||
from app.plugins import _PluginBase
|
||||
from typing import Any, List, Dict, Tuple, Optional
|
||||
from app.log import logger
|
||||
from app.schemas import NotificationType, DownloadHistory
|
||||
from app.schemas.types import EventType
|
||||
|
||||
|
||||
class AutoClean(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "定时清理媒体库"
|
||||
# 插件描述
|
||||
plugin_desc = "定时清理用户下载的种子、源文件、媒体库文件。"
|
||||
# 插件图标
|
||||
plugin_icon = "clean.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.1"
|
||||
# 插件作者
|
||||
plugin_author = "thsrite"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/thsrite"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "autoclean_"
|
||||
# 加载顺序
|
||||
plugin_order = 23
|
||||
# 可使用的用户级别
|
||||
auth_level = 2
|
||||
|
||||
# 私有属性
|
||||
_enabled = False
|
||||
# 任务执行间隔
|
||||
_cron = None
|
||||
_type = None
|
||||
_onlyonce = False
|
||||
_notify = False
|
||||
_cleantype = None
|
||||
_cleandate = None
|
||||
_cleanuser = None
|
||||
_downloadhis = None
|
||||
_transferhis = None
|
||||
|
||||
# 定时器
|
||||
_scheduler: Optional[BackgroundScheduler] = None
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
# 停止现有任务
|
||||
self.stop_service()
|
||||
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._cron = config.get("cron")
|
||||
self._onlyonce = config.get("onlyonce")
|
||||
self._notify = config.get("notify")
|
||||
self._cleantype = config.get("cleantype")
|
||||
self._cleandate = config.get("cleandate")
|
||||
self._cleanuser = config.get("cleanuser")
|
||||
|
||||
# 加载模块
|
||||
if self._enabled:
|
||||
self._downloadhis = DownloadHistoryOper()
|
||||
self._transferhis = TransferHistoryOper()
|
||||
|
||||
if self._onlyonce:
|
||||
# 定时服务
|
||||
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
logger.info(f"定时清理媒体库服务启动,立即运行一次")
|
||||
self._scheduler.add_job(func=self.__clean, trigger='date',
|
||||
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
|
||||
name="定时清理媒体库")
|
||||
# 关闭一次性开关
|
||||
self._onlyonce = False
|
||||
self.update_config({
|
||||
"onlyonce": False,
|
||||
"cron": self._cron,
|
||||
"cleantype": self._cleantype,
|
||||
"cleandate": self._cleandate,
|
||||
"enabled": self._enabled,
|
||||
"cleanuser": self._cleanuser,
|
||||
"notify": self._notify,
|
||||
})
|
||||
|
||||
# 启动任务
|
||||
if self._scheduler.get_jobs():
|
||||
self._scheduler.print_jobs()
|
||||
self._scheduler.start()
|
||||
|
||||
def __get_clean_date(self, deltatime: str = None):
|
||||
# 清理日期
|
||||
current_time = datetime.now()
|
||||
if deltatime:
|
||||
days_ago = current_time - timedelta(days=int(deltatime))
|
||||
else:
|
||||
days_ago = current_time - timedelta(days=int(self._cleandate))
|
||||
return days_ago.strftime("%Y-%m-%d")
|
||||
|
||||
def __clean(self):
|
||||
"""
|
||||
定时清理媒体库
|
||||
"""
|
||||
if not self._cleandate:
|
||||
logger.error("未配置媒体库全局清理时间,停止运行")
|
||||
return
|
||||
|
||||
# 查询用户清理日期之前的下载历史,不填默认清理全部用户的下载
|
||||
if not self._cleanuser:
|
||||
clean_date = self.__get_clean_date()
|
||||
downloadhis_list = self._downloadhis.list_by_user_date(date=clean_date)
|
||||
logger.info(f'获取到日期 {clean_date} 之前的下载历史 {len(downloadhis_list)} 条')
|
||||
self.__clean_history(date=clean_date, clean_type=self._cleantype, downloadhis_list=downloadhis_list)
|
||||
|
||||
# 根据填写的信息判断怎么清理
|
||||
else:
|
||||
# username:days#cleantype
|
||||
clean_type = self._cleantype
|
||||
clean_date = self._cleandate
|
||||
|
||||
# 1.3.7版本及之前处理多位用户
|
||||
if str(self._cleanuser).count(','):
|
||||
for username in str(self._cleanuser).split(","):
|
||||
downloadhis_list = self._downloadhis.list_by_user_date(date=clean_date,
|
||||
username=username)
|
||||
logger.info(
|
||||
f'获取到用户 {username} 日期 {clean_date} 之前的下载历史 {len(downloadhis_list)} 条')
|
||||
self.__clean_history(date=clean_date, clean_type=self._cleantype, downloadhis_list=downloadhis_list)
|
||||
return
|
||||
|
||||
for userinfo in str(self._cleanuser).split("\n"):
|
||||
if userinfo.count('#'):
|
||||
clean_type = userinfo.split('#')[1]
|
||||
username_and_days = userinfo.split('#')[0]
|
||||
else:
|
||||
username_and_days = userinfo
|
||||
if username_and_days.count(':'):
|
||||
clean_date = username_and_days.split(':')[1]
|
||||
username = username_and_days.split(':')[0]
|
||||
else:
|
||||
username = userinfo
|
||||
|
||||
# 转strftime
|
||||
clean_date = self.__get_clean_date(clean_date)
|
||||
logger.info(f'{username} 使用 {clean_type} 清理方式,清理 {clean_date} 之前的下载历史')
|
||||
downloadhis_list = self._downloadhis.list_by_user_date(date=clean_date,
|
||||
username=username)
|
||||
logger.info(
|
||||
f'获取到用户 {username} 日期 {clean_date} 之前的下载历史 {len(downloadhis_list)} 条')
|
||||
self.__clean_history(date=clean_date, clean_type=clean_type,
|
||||
downloadhis_list=downloadhis_list)
|
||||
|
||||
def __clean_history(self, date: str, clean_type: str, downloadhis_list: List[DownloadHistory]):
|
||||
"""
|
||||
清理下载历史、转移记录
|
||||
"""
|
||||
if not downloadhis_list:
|
||||
logger.warn(f"未获取到日期 {date} 之前的下载记录,停止运行")
|
||||
return
|
||||
|
||||
# 读取历史记录
|
||||
pulgin_history = self.get_data('history') or []
|
||||
|
||||
# 创建一个字典来保存分组结果
|
||||
downloadhis_grouped_dict: Dict[tuple, List[DownloadHistory]] = defaultdict(list)
|
||||
# 遍历DownloadHistory对象列表
|
||||
for downloadhis in downloadhis_list:
|
||||
# 获取type和tmdbid的值
|
||||
dtype = downloadhis.type
|
||||
tmdbid = downloadhis.tmdbid
|
||||
|
||||
# 将DownloadHistory对象添加到对应分组的列表中
|
||||
downloadhis_grouped_dict[(dtype, tmdbid)].append(downloadhis)
|
||||
|
||||
# 输出分组结果
|
||||
for key, downloadhis_list in downloadhis_grouped_dict.items():
|
||||
logger.info(f"开始清理 {key}")
|
||||
del_transferhis_cnt = 0
|
||||
del_media_name = downloadhis_list[0].title
|
||||
del_media_user = downloadhis_list[0].username
|
||||
del_media_type = downloadhis_list[0].type
|
||||
del_media_year = downloadhis_list[0].year
|
||||
del_media_season = downloadhis_list[0].seasons
|
||||
del_media_episode = downloadhis_list[0].episodes
|
||||
del_image = downloadhis_list[0].image
|
||||
for downloadhis in downloadhis_list:
|
||||
if not downloadhis.download_hash:
|
||||
logger.debug(f'下载历史 {downloadhis.id} {downloadhis.title} 未获取到download_hash,跳过处理')
|
||||
continue
|
||||
# 根据hash获取转移记录
|
||||
transferhis_list = self._transferhis.list_by_hash(download_hash=downloadhis.download_hash)
|
||||
if not transferhis_list:
|
||||
logger.warn(f"下载历史 {downloadhis.download_hash} 未查询到转移记录,跳过处理")
|
||||
continue
|
||||
|
||||
for history in transferhis_list:
|
||||
# 册除媒体库文件
|
||||
if clean_type in ["dest", "all"]:
|
||||
TransferChain().delete_files(Path(history.dest))
|
||||
# 删除记录
|
||||
self._transferhis.delete(history.id)
|
||||
# 删除源文件
|
||||
if clean_type in ["src", "all"]:
|
||||
TransferChain().delete_files(Path(history.src))
|
||||
# 发送事件
|
||||
eventmanager.send_event(
|
||||
EventType.DownloadFileDeleted,
|
||||
{
|
||||
"src": history.src
|
||||
}
|
||||
)
|
||||
|
||||
# 累加删除数量
|
||||
del_transferhis_cnt += len(transferhis_list)
|
||||
|
||||
if del_transferhis_cnt:
|
||||
# 发送消息
|
||||
if self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.MediaServer,
|
||||
title="【定时清理媒体库任务完成】",
|
||||
text=f"清理媒体名称 {del_media_name}\n"
|
||||
f"下载媒体用户 {del_media_user}\n"
|
||||
f"删除历史记录 {del_transferhis_cnt}")
|
||||
|
||||
pulgin_history.append({
|
||||
"type": del_media_type,
|
||||
"title": del_media_name,
|
||||
"year": del_media_year,
|
||||
"season": del_media_season,
|
||||
"episode": del_media_episode,
|
||||
"image": del_image,
|
||||
"del_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time()))
|
||||
})
|
||||
|
||||
# 保存历史
|
||||
self.save_data("history", pulgin_history)
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_service(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
注册插件公共服务
|
||||
[{
|
||||
"id": "服务ID",
|
||||
"name": "服务名称",
|
||||
"trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()",
|
||||
"func": self.xxx,
|
||||
"kwargs": {} # 定时器参数
|
||||
}]
|
||||
"""
|
||||
if self._enabled and self._cron:
|
||||
return [
|
||||
{
|
||||
"id": "AutoClean",
|
||||
"name": "清理媒体库定时服务",
|
||||
"trigger": CronTrigger.from_crontab(self._cron),
|
||||
"func": self.__clean,
|
||||
"kwargs": {}
|
||||
}
|
||||
]
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enabled',
|
||||
'label': '启用插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'onlyonce',
|
||||
'label': '立即运行一次',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'notify',
|
||||
'label': '开启通知',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'cron',
|
||||
'label': '执行周期',
|
||||
'placeholder': '0 0 ? ? ?'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSelect',
|
||||
'props': {
|
||||
'model': 'cleantype',
|
||||
'label': '全局清理方式',
|
||||
'items': [
|
||||
{'title': '媒体库文件', 'value': 'dest'},
|
||||
{'title': '源文件', 'value': 'src'},
|
||||
{'title': '所有文件', 'value': 'all'},
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'cleandate',
|
||||
'label': '全局清理日期',
|
||||
'placeholder': '清理多少天之前的下载记录(天)'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextarea',
|
||||
'props': {
|
||||
'model': 'cleanuser',
|
||||
'label': '清理配置',
|
||||
'rows': 6,
|
||||
'placeholder': '每一行一个配置,支持以下几种配置方式,清理方式支持 src、desc、all 分别对应源文件,媒体库文件,所有文件\n'
|
||||
'用户名缺省默认清理所有用户(慎重留空),清理天数缺省默认使用全局清理天数,清理方式缺省默认使用全局清理方式\n'
|
||||
'用户名/插件名(豆瓣想看、豆瓣榜单、RSS订阅)\n'
|
||||
'用户名#清理方式\n'
|
||||
'用户名:清理天数\n'
|
||||
'用户名:清理天数#清理方式',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"onlyonce": False,
|
||||
"notify": False,
|
||||
"cleantype": "dest",
|
||||
"cron": "",
|
||||
"cleanuser": "",
|
||||
"cleandate": 30
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
"""
|
||||
拼装插件详情页面,需要返回页面配置,同时附带数据
|
||||
"""
|
||||
# 查询同步详情
|
||||
historys = self.get_data('history')
|
||||
if not historys:
|
||||
return [
|
||||
{
|
||||
'component': 'div',
|
||||
'text': '暂无数据',
|
||||
'props': {
|
||||
'class': 'text-center',
|
||||
}
|
||||
}
|
||||
]
|
||||
# 数据按时间降序排序
|
||||
historys = sorted(historys, key=lambda x: x.get('del_time'), reverse=True)
|
||||
# 拼装页面
|
||||
contents = []
|
||||
for history in historys:
|
||||
htype = history.get("type")
|
||||
title = history.get("title")
|
||||
year = history.get("year")
|
||||
season = history.get("season")
|
||||
episode = history.get("episode")
|
||||
image = history.get("image")
|
||||
del_time = history.get("del_time")
|
||||
|
||||
if season:
|
||||
sub_contents = [
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'类型:{htype}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'标题:{title}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'年份:{year}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'季:{season}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'集:{episode}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'时间:{del_time}'
|
||||
}
|
||||
]
|
||||
else:
|
||||
sub_contents = [
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'类型:{htype}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'标题:{title}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'年份:{year}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'时间:{del_time}'
|
||||
}
|
||||
]
|
||||
|
||||
contents.append(
|
||||
{
|
||||
'component': 'VCard',
|
||||
'content': [
|
||||
{
|
||||
'component': 'div',
|
||||
'props': {
|
||||
'class': 'd-flex justify-space-start flex-nowrap flex-row',
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'div',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VImg',
|
||||
'props': {
|
||||
'src': image,
|
||||
'height': 120,
|
||||
'width': 80,
|
||||
'aspect-ratio': '2/3',
|
||||
'class': 'object-cover shadow ring-gray-500',
|
||||
'cover': True
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'div',
|
||||
'content': sub_contents
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
'component': 'div',
|
||||
'props': {
|
||||
'class': 'grid gap-3 grid-info-card',
|
||||
},
|
||||
'content': contents
|
||||
}
|
||||
]
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
退出插件
|
||||
"""
|
||||
try:
|
||||
if self._scheduler:
|
||||
self._scheduler.remove_all_jobs()
|
||||
if self._scheduler.running:
|
||||
self._scheduler.shutdown()
|
||||
self._scheduler = None
|
||||
except Exception as e:
|
||||
logger.error("退出插件失败:%s" % str(e))
|
||||
708
plugins.v2/bestfilmversion/__init__.py
Normal file
708
plugins.v2/bestfilmversion/__init__.py
Normal file
@@ -0,0 +1,708 @@
|
||||
from datetime import datetime, timedelta
|
||||
from functools import reduce
|
||||
from pathlib import Path
|
||||
from threading import RLock
|
||||
from typing import Optional, Any, List, Dict, Tuple
|
||||
from xml.dom.minidom import parseString
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from requests import Response
|
||||
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.event import eventmanager
|
||||
from app.log import logger
|
||||
from app.modules.emby import Emby
|
||||
from app.modules.jellyfin import Jellyfin
|
||||
from app.modules.plex import Plex
|
||||
from app.plugins import _PluginBase
|
||||
from app.schemas import WebhookEventInfo
|
||||
from app.schemas.types import MediaType, EventType
|
||||
from app.utils.http import RequestUtils
|
||||
|
||||
lock = RLock()
|
||||
|
||||
|
||||
class BestFilmVersion(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "收藏洗版"
|
||||
# 插件描述
|
||||
plugin_desc = "Jellyfin/Emby/Plex点击收藏电影后,自动订阅洗版。"
|
||||
# 插件图标
|
||||
plugin_icon = "like.jpg"
|
||||
# 插件版本
|
||||
plugin_version = "2.3"
|
||||
# 插件作者
|
||||
plugin_author = "wlj"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/developer-wlj"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "bestfilmversion_"
|
||||
# 加载顺序
|
||||
plugin_order = 13
|
||||
# 可使用的用户级别
|
||||
auth_level = 2
|
||||
|
||||
# 私有变量
|
||||
_scheduler: Optional[BackgroundScheduler] = None
|
||||
_cache_path: Optional[Path] = None
|
||||
subscribechain = None
|
||||
|
||||
# 配置属性
|
||||
_enabled: bool = False
|
||||
_cron: str = ""
|
||||
_notify: bool = False
|
||||
_webhook_enabled: bool = False
|
||||
_only_once: bool = False
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
self._cache_path = settings.TEMP_PATH / "__best_film_version_cache__"
|
||||
self.subscribechain = SubscribeChain()
|
||||
|
||||
# 停止现有任务
|
||||
self.stop_service()
|
||||
|
||||
# 配置
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._cron = config.get("cron")
|
||||
self._notify = config.get("notify")
|
||||
self._webhook_enabled = config.get("webhook_enabled")
|
||||
self._only_once = config.get("only_once")
|
||||
|
||||
if self._only_once:
|
||||
self._only_once = False
|
||||
self.update_config({
|
||||
"enabled": self._enabled,
|
||||
"cron": self._cron,
|
||||
"notify": self._notify,
|
||||
"webhook_enabled": self._webhook_enabled,
|
||||
"only_once": self._only_once
|
||||
})
|
||||
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
self._scheduler.add_job(self.sync, 'date',
|
||||
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
|
||||
name="立即运行收藏洗版")
|
||||
# 启动任务
|
||||
if self._scheduler.get_jobs():
|
||||
self._scheduler.print_jobs()
|
||||
self._scheduler.start()
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取插件API
|
||||
[{
|
||||
"path": "/xx",
|
||||
"endpoint": self.xxx,
|
||||
"methods": ["GET", "POST"],
|
||||
"summary": "API说明"
|
||||
}]
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_service(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
注册插件公共服务
|
||||
[{
|
||||
"id": "服务ID",
|
||||
"name": "服务名称",
|
||||
"trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()",
|
||||
"func": self.xxx,
|
||||
"kwargs": {} # 定时器参数
|
||||
}]
|
||||
"""
|
||||
if self._enabled and not self._webhook_enabled:
|
||||
if self._cron:
|
||||
return [{
|
||||
"id": "BestFilmVersion",
|
||||
"name": "收藏洗版定时服务",
|
||||
"trigger": CronTrigger.from_crontab(self._cron),
|
||||
"func": self.sync,
|
||||
"kwargs": {}
|
||||
}]
|
||||
return [
|
||||
{
|
||||
"id": "BestFilmVersion",
|
||||
"name": "收藏洗版定时服务",
|
||||
"trigger": "interval",
|
||||
"func": self.sync,
|
||||
"kwargs": {
|
||||
"minutes": 30
|
||||
}
|
||||
}
|
||||
]
|
||||
return []
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 3
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enabled',
|
||||
'label': '启用插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 3
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'notify',
|
||||
'label': '发送通知',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 3
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'only_once',
|
||||
'label': '立即运行一次',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 3
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'webhook_enabled',
|
||||
'label': 'Webhook',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'cron',
|
||||
'label': '执行周期',
|
||||
'placeholder': '5位cron表达式,留空自动'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '支持主动定时获取媒体库数据和Webhook实时触发两种方式,两者只能选其一,'
|
||||
'Webhook需要在媒体服务器设置发送Webhook报文。'
|
||||
'Plex使用主动获取时,建议执行周期设置大于1小时,'
|
||||
'收藏Api调用Plex官网接口,有频率限制。'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"notify": False,
|
||||
"cron": "*/30 * * * *",
|
||||
"webhook_enabled": False,
|
||||
"only_once": False
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
"""
|
||||
拼装插件详情页面,需要返回页面配置,同时附带数据
|
||||
"""
|
||||
# 查询同步详情
|
||||
historys = self.get_data('history')
|
||||
if not historys:
|
||||
return [
|
||||
{
|
||||
'component': 'div',
|
||||
'text': '暂无数据',
|
||||
'props': {
|
||||
'class': 'text-center',
|
||||
}
|
||||
}
|
||||
]
|
||||
# 数据按时间降序排序
|
||||
historys = sorted(historys, key=lambda x: x.get('time'), reverse=True)
|
||||
# 拼装页面
|
||||
contents = []
|
||||
for history in historys:
|
||||
title = history.get("title")
|
||||
poster = history.get("poster")
|
||||
mtype = history.get("type")
|
||||
time_str = history.get("time")
|
||||
tmdbid = history.get("tmdbid")
|
||||
contents.append(
|
||||
{
|
||||
'component': 'VCard',
|
||||
'content': [
|
||||
{
|
||||
'component': 'div',
|
||||
'props': {
|
||||
'class': 'd-flex justify-space-start flex-nowrap flex-row',
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'div',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VImg',
|
||||
'props': {
|
||||
'src': poster,
|
||||
'height': 120,
|
||||
'width': 80,
|
||||
'aspect-ratio': '2/3',
|
||||
'class': 'object-cover shadow ring-gray-500',
|
||||
'cover': True
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'div',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCardTitle',
|
||||
'props': {
|
||||
'class': 'ps-1 pe-5 break-words whitespace-break-spaces'
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'a',
|
||||
'props': {
|
||||
'href': f"https://www.themoviedb.org/movie/{tmdbid}",
|
||||
'target': '_blank'
|
||||
},
|
||||
'text': title
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'类型:{mtype}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'时间:{time_str}'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
'component': 'div',
|
||||
'props': {
|
||||
'class': 'grid gap-3 grid-info-card',
|
||||
},
|
||||
'content': contents
|
||||
}
|
||||
]
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
退出插件
|
||||
"""
|
||||
try:
|
||||
if self._scheduler:
|
||||
self._scheduler.remove_all_jobs()
|
||||
if self._scheduler.running:
|
||||
self._scheduler.shutdown()
|
||||
self._scheduler = None
|
||||
except Exception as e:
|
||||
logger.error("退出插件失败:%s" % str(e))
|
||||
|
||||
def sync(self):
|
||||
"""
|
||||
通过流媒体管理工具收藏,自动洗版
|
||||
"""
|
||||
# 获取锁
|
||||
_is_lock: bool = lock.acquire(timeout=60)
|
||||
if not _is_lock:
|
||||
return
|
||||
try:
|
||||
# 读取缓存
|
||||
caches = self._cache_path.read_text().split("\n") if self._cache_path.exists() else []
|
||||
# 读取历史记录
|
||||
history = self.get_data('history') or []
|
||||
|
||||
# 媒体服务器类型,多个以,分隔
|
||||
if not settings.MEDIASERVER:
|
||||
return
|
||||
media_servers = settings.MEDIASERVER.split(',')
|
||||
|
||||
# 读取收藏
|
||||
all_items = {}
|
||||
for media_server in media_servers:
|
||||
if media_server == 'jellyfin':
|
||||
all_items['jellyfin'] = self.jellyfin_get_items()
|
||||
elif media_server == 'emby':
|
||||
all_items['emby'] = self.emby_get_items()
|
||||
else:
|
||||
all_items['plex'] = self.plex_get_watchlist()
|
||||
|
||||
def function(y, x):
|
||||
return y if (x['Name'] in [i['Name'] for i in y]) else (lambda z, u: (z.append(u), z))(y, x)[1]
|
||||
|
||||
# 处理所有结果
|
||||
for server, all_item in all_items.items():
|
||||
# all_item 根据电影名去重
|
||||
result = reduce(function, all_item, [])
|
||||
for data in result:
|
||||
# 检查缓存
|
||||
if data.get('Name') in caches:
|
||||
continue
|
||||
|
||||
# 获取详情
|
||||
if server == 'jellyfin':
|
||||
item_info_resp = Jellyfin().get_iteminfo(itemid=data.get('Id'))
|
||||
elif server == 'emby':
|
||||
item_info_resp = Emby().get_iteminfo(itemid=data.get('Id'))
|
||||
else:
|
||||
item_info_resp = self.plex_get_iteminfo(itemid=data.get('Id'))
|
||||
logger.debug(f'BestFilmVersion插件 item打印 {item_info_resp}')
|
||||
if not item_info_resp:
|
||||
continue
|
||||
|
||||
# 只接受Movie类型
|
||||
if data.get('Type') != 'Movie':
|
||||
continue
|
||||
|
||||
# 获取tmdb_id
|
||||
tmdb_id = item_info_resp.get("tmdbid") if server == 'plex' else item_info_resp.tmdbid
|
||||
if not tmdb_id:
|
||||
continue
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.chain.recognize_media(tmdbid=tmdb_id, mtype=MediaType.MOVIE)
|
||||
if not mediainfo:
|
||||
logger.warn(f'未识别到媒体信息,标题:{data.get("Name")},tmdbid:{tmdb_id}')
|
||||
continue
|
||||
# 添加订阅
|
||||
self.subscribechain.add(mtype=MediaType.MOVIE,
|
||||
title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
best_version=True,
|
||||
username="收藏洗版",
|
||||
exist_ok=True)
|
||||
# 加入缓存
|
||||
caches.append(data.get('Name'))
|
||||
# 存储历史记录
|
||||
if mediainfo.tmdb_id not in [h.get("tmdbid") for h in history]:
|
||||
history.append({
|
||||
"title": mediainfo.title,
|
||||
"type": mediainfo.type.value,
|
||||
"year": mediainfo.year,
|
||||
"poster": mediainfo.get_poster_image(),
|
||||
"overview": mediainfo.overview,
|
||||
"tmdbid": mediainfo.tmdb_id,
|
||||
"time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
})
|
||||
# 保存历史记录
|
||||
self.save_data('history', history)
|
||||
# 保存缓存
|
||||
self._cache_path.write_text("\n".join(caches))
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
def jellyfin_get_items(self) -> List[dict]:
|
||||
# 获取所有user
|
||||
users_url = "[HOST]Users?&apikey=[APIKEY]"
|
||||
users = self.get_users(Jellyfin().get_data(users_url))
|
||||
if not users:
|
||||
logger.info(f"bestfilmversion/users_url: {users_url}")
|
||||
return []
|
||||
all_items = []
|
||||
for user in users:
|
||||
# 根据加入日期 降序排序
|
||||
url = "[HOST]Users/" + user + "/Items?SortBy=DateCreated%2CSortName" \
|
||||
"&SortOrder=Descending" \
|
||||
"&Filters=IsFavorite" \
|
||||
"&Recursive=true" \
|
||||
"&Fields=PrimaryImageAspectRatio%2CBasicSyncInfo" \
|
||||
"&CollapseBoxSetItems=false" \
|
||||
"&ExcludeLocationTypes=Virtual" \
|
||||
"&EnableTotalRecordCount=false" \
|
||||
"&Limit=20" \
|
||||
"&apikey=[APIKEY]"
|
||||
resp = self.get_items(Jellyfin().get_data(url))
|
||||
if not resp:
|
||||
continue
|
||||
all_items.extend(resp)
|
||||
return all_items
|
||||
|
||||
def emby_get_items(self) -> List[dict]:
|
||||
# 获取所有user
|
||||
get_users_url = "[HOST]Users?&api_key=[APIKEY]"
|
||||
users = self.get_users(Emby().get_data(get_users_url))
|
||||
if not users:
|
||||
return []
|
||||
all_items = []
|
||||
for user in users:
|
||||
# 根据加入日期 降序排序
|
||||
url = "[HOST]emby/Users/" + user + "/Items?SortBy=DateCreated%2CSortName" \
|
||||
"&SortOrder=Descending" \
|
||||
"&Filters=IsFavorite" \
|
||||
"&Recursive=true" \
|
||||
"&Fields=PrimaryImageAspectRatio%2CBasicSyncInfo" \
|
||||
"&CollapseBoxSetItems=false" \
|
||||
"&ExcludeLocationTypes=Virtual" \
|
||||
"&EnableTotalRecordCount=false" \
|
||||
"&Limit=20&api_key=[APIKEY]"
|
||||
resp = self.get_items(Emby().get_data(url))
|
||||
if not resp:
|
||||
continue
|
||||
all_items.extend(resp)
|
||||
return all_items
|
||||
|
||||
@staticmethod
|
||||
def get_items(resp: Response):
|
||||
try:
|
||||
if resp:
|
||||
return resp.json().get("Items") or []
|
||||
else:
|
||||
return []
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def get_users(resp: Response):
|
||||
try:
|
||||
if resp:
|
||||
return [data['Id'] for data in resp.json()]
|
||||
else:
|
||||
logger.error(f"BestFilmVersion/Users 未获取到返回数据")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"连接BestFilmVersion/Users 出错:" + str(e))
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def plex_get_watchlist() -> List[dict]:
|
||||
# 根据加入日期 降序排序
|
||||
url = f"https://metadata.provider.plex.tv/library/sections/watchlist/all?type=1&sort=addedAt%3Adesc" \
|
||||
f"&X-Plex-Container-Start=0&X-Plex-Container-Size=50" \
|
||||
f"&X-Plex-Token={settings.PLEX_TOKEN}"
|
||||
res = []
|
||||
try:
|
||||
resp = RequestUtils().get_res(url=url)
|
||||
if resp:
|
||||
dom = parseString(resp.text)
|
||||
# 获取文档元素对象
|
||||
elem = dom.documentElement
|
||||
# 获取 指定元素
|
||||
eles = elem.getElementsByTagName('Video')
|
||||
if not eles:
|
||||
return []
|
||||
for ele in eles:
|
||||
data = {}
|
||||
# 获取标签中内容
|
||||
ele_id = ele.attributes['ratingKey'].nodeValue
|
||||
ele_title = ele.attributes['title'].nodeValue
|
||||
ele_type = ele.attributes['type'].nodeValue
|
||||
_type = "Movie" if ele_type == "movie" else ""
|
||||
data['Id'] = ele_id
|
||||
data['Name'] = ele_title
|
||||
data['Type'] = _type
|
||||
res.append(data)
|
||||
return res
|
||||
else:
|
||||
logger.error(f"Plex/Watchlist 未获取到返回数据")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"连接Plex/Watchlist 出错:" + str(e))
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def plex_get_iteminfo(itemid) -> dict:
|
||||
url = f"https://metadata.provider.plex.tv/library/metadata/{itemid}" \
|
||||
f"?X-Plex-Token={settings.PLEX_TOKEN}"
|
||||
try:
|
||||
resp = RequestUtils(accept_type="application/json, text/plain, */*").get_res(url=url)
|
||||
if resp:
|
||||
metadata = resp.json().get('MediaContainer').get('Metadata')
|
||||
for item in metadata:
|
||||
_guid = item.get('Guid')
|
||||
if not _guid:
|
||||
continue
|
||||
|
||||
id_list = [h.get('id') for h in _guid if h.get('id').__contains__("tmdb")]
|
||||
if not id_list:
|
||||
continue
|
||||
|
||||
return {'tmdbid': id_list[0].split("/")[-1]}
|
||||
|
||||
return {}
|
||||
else:
|
||||
logger.error(f"Plex/Items 未获取到返回数据")
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.error(f"连接Plex/Items 出错:" + str(e))
|
||||
return {}
|
||||
|
||||
@eventmanager.register(EventType.WebhookMessage)
|
||||
def webhook_message_action(self, event):
|
||||
|
||||
if not self._enabled:
|
||||
return
|
||||
if not self._webhook_enabled:
|
||||
return
|
||||
|
||||
data: WebhookEventInfo = event.event_data
|
||||
# 排除不是收藏调用
|
||||
if data.channel not in ['jellyfin', 'emby', 'plex']:
|
||||
return
|
||||
if data.channel in ['emby', 'plex'] and data.event != 'item.rate':
|
||||
return
|
||||
if data.channel == 'jellyfin' and data.save_reason != 'UpdateUserRating':
|
||||
return
|
||||
logger.info(f'BestFilmVersion/webhook_message_action WebhookEventInfo打印:{data}')
|
||||
|
||||
# 获取锁
|
||||
_is_lock: bool = lock.acquire(timeout=60)
|
||||
if not _is_lock:
|
||||
return
|
||||
try:
|
||||
if not data.tmdb_id:
|
||||
info = None
|
||||
if (data.channel == 'jellyfin'
|
||||
and data.save_reason == 'UpdateUserRating'
|
||||
and data.item_favorite):
|
||||
info = Jellyfin().get_iteminfo(itemid=data.item_id)
|
||||
elif data.channel == 'emby' and data.event == 'item.rate':
|
||||
info = Emby().get_iteminfo(itemid=data.item_id)
|
||||
elif data.channel == 'plex' and data.event == 'item.rate':
|
||||
info = Plex().get_iteminfo(itemid=data.item_id)
|
||||
logger.debug(f'BestFilmVersion/webhook_message_action item打印:{info}')
|
||||
if not info:
|
||||
return
|
||||
if info.item_type not in ['Movie', 'MOV', 'movie']:
|
||||
return
|
||||
# 获取tmdb_id
|
||||
tmdb_id = info.tmdbid
|
||||
else:
|
||||
tmdb_id = data.tmdb_id
|
||||
if (data.channel == 'jellyfin'
|
||||
and (data.save_reason != 'UpdateUserRating' or not data.item_favorite)):
|
||||
return
|
||||
if data.item_type not in ['Movie', 'MOV', 'movie']:
|
||||
return
|
||||
# 识别媒体信息
|
||||
mediainfo = self.chain.recognize_media(tmdbid=tmdb_id, mtype=MediaType.MOVIE)
|
||||
if not mediainfo:
|
||||
logger.warn(f'未识别到媒体信息,标题:{data.item_name},tmdbID:{tmdb_id}')
|
||||
return
|
||||
# 读取缓存
|
||||
caches = self._cache_path.read_text().split("\n") if self._cache_path.exists() else []
|
||||
# 检查缓存
|
||||
if data.item_name in caches:
|
||||
return
|
||||
# 读取历史记录
|
||||
history = self.get_data('history') or []
|
||||
# 添加订阅
|
||||
self.subscribechain.add(mtype=MediaType.MOVIE,
|
||||
title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
best_version=True,
|
||||
username="收藏洗版",
|
||||
exist_ok=True)
|
||||
# 加入缓存
|
||||
caches.append(data.item_name)
|
||||
# 存储历史记录
|
||||
if mediainfo.tmdb_id not in [h.get("tmdbid") for h in history]:
|
||||
history.append({
|
||||
"title": mediainfo.title,
|
||||
"type": mediainfo.type.value,
|
||||
"year": mediainfo.year,
|
||||
"poster": mediainfo.get_poster_image(),
|
||||
"overview": mediainfo.overview,
|
||||
"tmdbid": mediainfo.tmdb_id,
|
||||
"time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
})
|
||||
# 保存历史记录
|
||||
self.save_data('history', history)
|
||||
# 保存缓存
|
||||
self._cache_path.write_text("\n".join(caches))
|
||||
finally:
|
||||
lock.release()
|
||||
4054
plugins.v2/brushflow/__init__.py
Normal file
4054
plugins.v2/brushflow/__init__.py
Normal file
File diff suppressed because it is too large
Load Diff
918
plugins.v2/cleaninvalidseed/__init__.py
Normal file
918
plugins.v2/cleaninvalidseed/__init__.py
Normal file
@@ -0,0 +1,918 @@
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from app.modules.qbittorrent import Qbittorrent
|
||||
from app.utils.string import StringUtils
|
||||
from app.schemas.types import EventType
|
||||
from app.core.event import eventmanager, Event
|
||||
|
||||
from app.core.config import settings
|
||||
from app.plugins import _PluginBase
|
||||
from typing import Any, List, Dict, Tuple, Optional
|
||||
from app.log import logger
|
||||
from app.schemas import NotificationType
|
||||
|
||||
|
||||
class CleanInvalidSeed(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "清理QB无效做种"
|
||||
# 插件描述
|
||||
plugin_desc = "清理已经被站点删除的种子及源文件,仅支持QB"
|
||||
# 插件图标
|
||||
plugin_icon = "clean_a.png"
|
||||
# 插件版本
|
||||
plugin_version = "2.2"
|
||||
# 插件作者
|
||||
plugin_author = "DzAvril"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/DzAvril"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "cleaninvalidseed"
|
||||
# 加载顺序
|
||||
plugin_order = 1
|
||||
# 可使用的用户级别
|
||||
auth_level = 1
|
||||
|
||||
# 私有属性
|
||||
_enabled = False
|
||||
_cron = None
|
||||
_notify = False
|
||||
_onlyonce = False
|
||||
_qb = None
|
||||
_detect_invalid_files = False
|
||||
_delete_invalid_files = False
|
||||
_delete_invalid_torrents = False
|
||||
_notify_all = False
|
||||
_label_only = False
|
||||
_label = ""
|
||||
_download_dirs = ""
|
||||
_exclude_keywords = ""
|
||||
_exclude_categories = ""
|
||||
_exclude_labels = ""
|
||||
_more_logs = False
|
||||
# 定时器
|
||||
_scheduler: Optional[BackgroundScheduler] = None
|
||||
_error_msg = [
|
||||
"torrent not registered with this tracker",
|
||||
"Torrent not registered with this tracker",
|
||||
"torrent banned",
|
||||
"err torrent banned",
|
||||
]
|
||||
_custom_error_msg = ""
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
# 停止现有任务
|
||||
self.stop_service()
|
||||
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._cron = config.get("cron")
|
||||
self._notify = config.get("notify")
|
||||
self._onlyonce = config.get("onlyonce")
|
||||
self._delete_invalid_torrents = config.get("delete_invalid_torrents")
|
||||
self._delete_invalid_files = config.get("delete_invalid_files")
|
||||
self._detect_invalid_files = config.get("detect_invalid_files")
|
||||
self._notify_all = config.get("notify_all")
|
||||
self._label_only = config.get("label_only")
|
||||
self._label = config.get("label")
|
||||
self._download_dirs = config.get("download_dirs")
|
||||
self._exclude_keywords = config.get("exclude_keywords")
|
||||
self._exclude_categories = config.get("exclude_categories")
|
||||
self._exclude_labels = config.get("exclude_labels")
|
||||
self._custom_error_msg = config.get("custom_error_msg")
|
||||
self._more_logs = config.get("more_logs")
|
||||
self._qb = Qbittorrent()
|
||||
|
||||
# 加载模块
|
||||
if self._onlyonce:
|
||||
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
logger.info(f"清理无效种子服务启动,立即运行一次")
|
||||
self._scheduler.add_job(
|
||||
func=self.clean_invalid_seed,
|
||||
trigger="date",
|
||||
run_date=datetime.now(tz=pytz.timezone(settings.TZ))
|
||||
+ timedelta(seconds=3),
|
||||
name="清理无效种子",
|
||||
)
|
||||
# 关闭一次性开关
|
||||
self._onlyonce = False
|
||||
self._update_config()
|
||||
|
||||
# 启动任务
|
||||
if self._scheduler.get_jobs():
|
||||
self._scheduler.print_jobs()
|
||||
self._scheduler.start()
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
def _update_config(self):
|
||||
self.update_config(
|
||||
{
|
||||
"onlyonce": False,
|
||||
"cron": self._cron,
|
||||
"enabled": self._enabled,
|
||||
"notify": self._notify,
|
||||
"delete_invalid_torrents": self._delete_invalid_torrents,
|
||||
"delete_invalid_files": self._delete_invalid_files,
|
||||
"detect_invalid_files": self._detect_invalid_files,
|
||||
"notify_all": self._notify_all,
|
||||
"label_only": self._label_only,
|
||||
"label": self._label,
|
||||
"download_dirs": self._download_dirs,
|
||||
"exclude_keywords": self._exclude_keywords,
|
||||
"exclude_categories": self._exclude_categories,
|
||||
"exclude_labels": self._exclude_labels,
|
||||
"custom_error_msg": self._custom_error_msg,
|
||||
"more_logs": self._more_logs,
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
定义远程控制命令
|
||||
:return: 命令关键字、事件、描述、附带数据
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"cmd": "/detect_invalid_torrents",
|
||||
"event": EventType.PluginAction,
|
||||
"desc": "检测无效做种",
|
||||
"category": "QB",
|
||||
"data": {"action": "detect_invalid_torrents"},
|
||||
},
|
||||
{
|
||||
"cmd": "/delete_invalid_torrents",
|
||||
"event": EventType.PluginAction,
|
||||
"desc": "清理无效做种",
|
||||
"category": "QB",
|
||||
"data": {"action": "delete_invalid_torrents"},
|
||||
},
|
||||
{
|
||||
"cmd": "/detect_invalid_files",
|
||||
"event": EventType.PluginAction,
|
||||
"desc": "检测无效源文件",
|
||||
"category": "QB",
|
||||
"data": {"action": "detect_invalid_files"},
|
||||
},
|
||||
{
|
||||
"cmd": "/delete_invalid_files",
|
||||
"event": EventType.PluginAction,
|
||||
"desc": "清理无效源文件",
|
||||
"category": "QB",
|
||||
"data": {"action": "delete_invalid_files"},
|
||||
},
|
||||
{
|
||||
"cmd": "/toggle_notify_all",
|
||||
"event": EventType.PluginAction,
|
||||
"desc": "QB清理插件切换全量通知",
|
||||
"category": "QB",
|
||||
"data": {"action": "toggle_notify_all"},
|
||||
},
|
||||
]
|
||||
|
||||
@eventmanager.register(EventType.PluginAction)
|
||||
def handle_commands(self, event: Event):
|
||||
if event:
|
||||
event_data = event.event_data
|
||||
if event_data:
|
||||
if not (
|
||||
event_data.get("action") == "detect_invalid_torrents"
|
||||
or event_data.get("action") == "delete_invalid_torrents"
|
||||
or event_data.get("action") == "detect_invalid_files"
|
||||
or event_data.get("action") == "delete_invalid_files"
|
||||
or event_data.get("action") == "toggle_notify_all"
|
||||
):
|
||||
return
|
||||
self.post_message(
|
||||
channel=event.event_data.get("channel"),
|
||||
title="开始执行远程命令...",
|
||||
userid=event.event_data.get("user"),
|
||||
)
|
||||
old_delete_invalid_torrents = self._delete_invalid_torrents
|
||||
old_detect_invalid_files = self._detect_invalid_files
|
||||
old_delete_invalid_files = self._delete_invalid_files
|
||||
if event_data.get("action") == "detect_invalid_torrents":
|
||||
logger.info("收到远程命令,开始检测无效做种")
|
||||
self._delete_invalid_torrents = False
|
||||
self._detect_invalid_files = False
|
||||
self._delete_invalid_files = False
|
||||
self.clean_invalid_seed()
|
||||
elif event_data.get("action") == "delete_invalid_torrents":
|
||||
logger.info("收到远程命令,开始清理无效做种")
|
||||
self._delete_invalid_torrents = True
|
||||
self._detect_invalid_files = False
|
||||
self._delete_invalid_files = False
|
||||
self.clean_invalid_seed()
|
||||
elif event_data.get("action") == "detect_invalid_files":
|
||||
logger.info("收到远程命令,开始检测无效源文件")
|
||||
self._delete_invalid_files = False
|
||||
self.detect_invalid_files()
|
||||
elif event_data.get("action") == "delete_invalid_files":
|
||||
logger.info("收到远程命令,开始清理无效源文件")
|
||||
self._delete_invalid_files = True
|
||||
self.detect_invalid_files()
|
||||
elif event_data.get("action") == "toggle_notify_all":
|
||||
self._notify_all = not self._notify_all
|
||||
self._update_config()
|
||||
if self._notify_all:
|
||||
self.post_message(
|
||||
channel=event.event_data.get("channel"),
|
||||
title="已开启全量通知",
|
||||
userid=event.event_data.get("user"),
|
||||
)
|
||||
else:
|
||||
self.post_message(
|
||||
channel=event.event_data.get("channel"),
|
||||
title="已关闭全量通知",
|
||||
userid=event.event_data.get("user"),
|
||||
)
|
||||
return
|
||||
else:
|
||||
logger.error("收到未知远程命令")
|
||||
return
|
||||
self._delete_invalid_torrents = old_delete_invalid_torrents
|
||||
self._detect_invalid_files = old_detect_invalid_files
|
||||
self._delete_invalid_files = old_delete_invalid_files
|
||||
self.post_message(
|
||||
channel=event.event_data.get("channel"),
|
||||
title="远程命令执行完成!",
|
||||
userid=event.event_data.get("user"),
|
||||
)
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_service(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
注册插件公共服务
|
||||
[{
|
||||
"id": "服务ID",
|
||||
"name": "服务名称",
|
||||
"trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()",
|
||||
"func": self.xxx,
|
||||
"kwargs": {} # 定时器参数
|
||||
}]
|
||||
"""
|
||||
if self._enabled and self._cron:
|
||||
return [
|
||||
{
|
||||
"id": "CleanInvalidSeed",
|
||||
"name": "清理QB无效做种",
|
||||
"trigger": CronTrigger.from_crontab(self._cron),
|
||||
"func": self.clean_invalid_seed,
|
||||
"kwargs": {},
|
||||
}
|
||||
]
|
||||
|
||||
def get_all_torrents(self):
|
||||
all_torrents, error = self._qb.get_torrents()
|
||||
if error:
|
||||
logger.error(f"获取QB种子失败: {error}")
|
||||
if self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【清理QB无效做种】",
|
||||
text=f"获取QB种子失败,请检查QB配置",
|
||||
)
|
||||
return []
|
||||
|
||||
if not all_torrents:
|
||||
logger.warning("QB没有种子")
|
||||
if self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【清理QB无效做种】",
|
||||
text=f"QB中没有种子",
|
||||
)
|
||||
return []
|
||||
return all_torrents
|
||||
|
||||
def clean_invalid_seed(self):
|
||||
logger.info("开始清理QB无效做种")
|
||||
all_torrents = self.get_all_torrents()
|
||||
temp_invalid_torrents = []
|
||||
# tracker未工作,但暂时不能判定为失效做种,需人工判断
|
||||
tracker_not_working_torrents = []
|
||||
working_tracker_set = set()
|
||||
exclude_categories = (
|
||||
self._exclude_categories.split("\n") if self._exclude_categories else []
|
||||
)
|
||||
exclude_labels = (
|
||||
self._exclude_labels.split("\n") if self._exclude_labels else []
|
||||
)
|
||||
custom_msgs = (
|
||||
self._custom_error_msg.split("\n") if self._custom_error_msg else []
|
||||
)
|
||||
error_msgs = self._error_msg + custom_msgs
|
||||
# 第一轮筛选出所有未工作的种子
|
||||
for torrent in all_torrents:
|
||||
trackers = torrent.trackers
|
||||
is_invalid = True
|
||||
is_tracker_working = False
|
||||
for tracker in trackers:
|
||||
if tracker.get("tier") == -1:
|
||||
continue
|
||||
tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1]
|
||||
# 有一个tracker工作即为有效做种
|
||||
if (tracker.get("status") == 2) or (tracker.get("status") == 3):
|
||||
is_tracker_working = True
|
||||
|
||||
if not (
|
||||
(tracker.get("status") == 4) and (tracker.get("msg") in error_msgs)
|
||||
):
|
||||
is_invalid = False
|
||||
working_tracker_set.add(tracker_domian)
|
||||
|
||||
if self._more_logs:
|
||||
logger.info(f"处理 [{torrent.name}] tracker [{tracker_domian}]: 分类: [{torrent.category}], 标签: [{torrent.tags}], 状态: [{tracker.get('status')}], msg: [{tracker.get('msg')}], is_invalid: [{is_invalid}], is_working: [{is_tracker_working}]")
|
||||
if is_invalid:
|
||||
temp_invalid_torrents.append(torrent)
|
||||
elif not is_tracker_working:
|
||||
# 排除已暂停的种子
|
||||
if not torrent.state_enum.is_paused:
|
||||
tracker_not_working_torrents.append(torrent)
|
||||
|
||||
logger.info(f"初筛共有{len(temp_invalid_torrents)}个无效做种")
|
||||
# 第二轮筛选出tracker有正常工作种子而当前种子未工作的,避免因临时关站或tracker失效导致误删的问题
|
||||
# 失效做种但通过种子分类排除的种子
|
||||
invalid_torrents_exclude_categories = []
|
||||
# 失效做种但通过种子标签排除的种子
|
||||
invalid_torrents_exclude_labels = []
|
||||
# 将invalid_torrents基本信息保存起来,在种子被删除后依然可以打印这些信息
|
||||
invalid_torrent_tuple_list = []
|
||||
deleted_torrent_tuple_list = []
|
||||
for torrent in temp_invalid_torrents:
|
||||
trackers = torrent.trackers
|
||||
for tracker in trackers:
|
||||
if tracker.get("tier") == -1:
|
||||
continue
|
||||
tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1]
|
||||
if tracker_domian in working_tracker_set:
|
||||
# tracker是正常的,说明该种子是无效的
|
||||
invalid_torrent_tuple_list.append(
|
||||
(
|
||||
torrent.name,
|
||||
torrent.category,
|
||||
torrent.tags,
|
||||
torrent.size,
|
||||
tracker_domian,
|
||||
tracker.msg,
|
||||
)
|
||||
)
|
||||
if self._delete_invalid_torrents or self._label_only:
|
||||
# 检查种子分类和标签是否排除
|
||||
is_excluded = False
|
||||
if torrent.category in exclude_categories:
|
||||
is_excluded = True
|
||||
invalid_torrents_exclude_categories.append(torrent)
|
||||
torrent_labels = [
|
||||
tag.strip() for tag in torrent.tags.split(",")
|
||||
]
|
||||
for label in torrent_labels:
|
||||
if label in exclude_labels:
|
||||
is_excluded = True
|
||||
invalid_torrents_exclude_labels.append(torrent)
|
||||
if not is_excluded:
|
||||
if self._label_only:
|
||||
# 仅标记
|
||||
self._qb.set_torrents_tag(ids=torrent.get("hash"), tags=[self._label if self._label != "" else "无效做种"])
|
||||
else:
|
||||
# 只删除种子不删除文件,以防其它站点辅种
|
||||
self._qb.delete_torrents(False, torrent.get("hash"))
|
||||
# 标记已处理种子信息
|
||||
deleted_torrent_tuple_list.append(
|
||||
(
|
||||
torrent.name,
|
||||
torrent.category,
|
||||
torrent.tags,
|
||||
torrent.size,
|
||||
tracker_domian,
|
||||
tracker.msg,
|
||||
)
|
||||
)
|
||||
break
|
||||
invalid_msg = f"检测到{len(invalid_torrent_tuple_list)}个失效做种\n"
|
||||
tracker_not_working_msg = f"检测到{len(tracker_not_working_torrents)}个tracker未工作做种,请检查种子状态\n"
|
||||
|
||||
if self._label_only or self._delete_invalid_torrents:
|
||||
if self._label_only:
|
||||
deleted_msg = f"标记了{len(deleted_torrent_tuple_list)}个失效种子\n"
|
||||
else:
|
||||
deleted_msg = f"删除了{len(deleted_torrent_tuple_list)}个失效种子\n"
|
||||
if len(exclude_categories) != 0:
|
||||
exclude_categories_msg = f"分类排除{len(invalid_torrents_exclude_categories)}个失效种子未删除,请手动处理\n"
|
||||
if len(exclude_labels) != 0:
|
||||
exclude_labels_msg = f"标签排除{len(invalid_torrents_exclude_labels)}个失效种子未删除,请手动处理\n"
|
||||
for index in range(len(invalid_torrent_tuple_list)):
|
||||
torrent = invalid_torrent_tuple_list[index]
|
||||
invalid_msg += f"{index + 1}. {torrent[0]},分类:{torrent[1]},标签:{torrent[2]}, 大小:{StringUtils.str_filesize(torrent[3])},Trackers: {torrent[4]}:{torrent[5]}\n"
|
||||
|
||||
for index in range(len(tracker_not_working_torrents)):
|
||||
torrent = tracker_not_working_torrents[index]
|
||||
trackers = torrent.trackers
|
||||
tracker_msg = ""
|
||||
for tracker in trackers:
|
||||
if tracker.get("tier") == -1:
|
||||
continue
|
||||
tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1]
|
||||
tracker_msg += f" {tracker_domian}:{tracker.msg} "
|
||||
tracker_not_working_msg += f"{index + 1}. {torrent.name},分类:{torrent.category},标签:{torrent.tags}, 大小:{StringUtils.str_filesize(torrent.size)},Trackers: {tracker_msg}\n"
|
||||
|
||||
for index in range(len(invalid_torrents_exclude_categories)):
|
||||
torrent = invalid_torrents_exclude_categories[index]
|
||||
trackers = torrent.trackers
|
||||
tracker_msg = ""
|
||||
for tracker in trackers:
|
||||
if tracker.get("tier") == -1:
|
||||
continue
|
||||
tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1]
|
||||
tracker_msg += f" {tracker_domian}:{tracker.msg} "
|
||||
exclude_categories_msg += f"{index + 1}. {torrent.name},分类:{torrent.category},标签:{torrent.tags}, 大小:{StringUtils.str_filesize(torrent.size)},Trackers: {tracker_msg}\n"
|
||||
|
||||
for index in range(len(invalid_torrents_exclude_labels)):
|
||||
torrent = invalid_torrents_exclude_labels[index]
|
||||
trackers = torrent.trackers
|
||||
tracker_msg = ""
|
||||
for tracker in trackers:
|
||||
if tracker.get("tier") == -1:
|
||||
continue
|
||||
tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1]
|
||||
tracker_msg += f" {tracker_domian}:{tracker.msg} "
|
||||
exclude_labels_msg += f"{index + 1}. {torrent.name},分类:{torrent.category},标签:{torrent.tags}, 大小:{StringUtils.str_filesize(torrent.size)},Trackers: {tracker_msg}\n"
|
||||
|
||||
for index in range(len(deleted_torrent_tuple_list)):
|
||||
torrent = deleted_torrent_tuple_list[index]
|
||||
deleted_msg += f"{index + 1}. {torrent[0]},分类:{torrent[1]},标签:{torrent[2]}, 大小:{StringUtils.str_filesize(torrent[3])},Trackers: {torrent[4]}:{torrent[5]}\n"
|
||||
|
||||
# 日志
|
||||
logger.info(invalid_msg)
|
||||
logger.info(tracker_not_working_msg)
|
||||
if self._delete_invalid_torrents:
|
||||
logger.info(deleted_msg)
|
||||
if len(exclude_categories) != 0:
|
||||
logger.info(exclude_categories_msg)
|
||||
if len(exclude_labels) != 0:
|
||||
logger.info(exclude_labels_msg)
|
||||
# 通知
|
||||
if self._notify:
|
||||
invalid_msg = invalid_msg.replace("_", "\_")
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【清理无效做种】",
|
||||
text=invalid_msg,
|
||||
)
|
||||
if self._notify_all:
|
||||
tracker_not_working_msg = tracker_not_working_msg.replace("_", "\_")
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【清理无效做种】",
|
||||
text=tracker_not_working_msg,
|
||||
)
|
||||
if self._label_only or self._delete_invalid_torrents:
|
||||
deleted_msg = deleted_msg.replace("_", "\_")
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【清理无效做种】",
|
||||
text=deleted_msg,
|
||||
)
|
||||
if self._notify_all:
|
||||
exclude_categories_msg = exclude_categories_msg.replace("_", "\_")
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【清理无效做种】",
|
||||
text=exclude_categories_msg,
|
||||
)
|
||||
exclude_labels_msg = exclude_labels_msg.replace("_", "\_")
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【清理无效做种】",
|
||||
text=exclude_labels_msg,
|
||||
)
|
||||
logger.info("检测无效做种任务结束")
|
||||
if self._detect_invalid_files:
|
||||
self.detect_invalid_files()
|
||||
|
||||
def detect_invalid_files(self):
|
||||
logger.info("开始检测未做种的无效源文件")
|
||||
all_torrents = self.get_all_torrents()
|
||||
source_path_map = {}
|
||||
source_paths = []
|
||||
total_size = 0
|
||||
deleted_file_cnt = 0
|
||||
exclude_key_words = (
|
||||
self._exclude_keywords.split("\n") if self._exclude_keywords else []
|
||||
)
|
||||
if not self._download_dirs:
|
||||
logger.error("未配置下载目录,无法检测未做种无效源文件")
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【检测无效源文件】",
|
||||
text="未配置下载目录,无法检测未做种无效源文件",
|
||||
)
|
||||
return
|
||||
for path in self._download_dirs.split("\n"):
|
||||
mp_path, qb_path = path.split(":")
|
||||
source_path_map[mp_path] = qb_path
|
||||
source_paths.append(mp_path)
|
||||
# 所有做种源文件路径
|
||||
content_path_set = set()
|
||||
for torrent in all_torrents:
|
||||
content_path_set.add(torrent.content_path)
|
||||
|
||||
message = "检测未做种无效源文件:\n"
|
||||
for source_path_str in source_paths:
|
||||
source_path = Path(source_path_str)
|
||||
# 判断source_path是否存在
|
||||
if not source_path.exists():
|
||||
logger.error(f"{source_path} 不存在,无法检测未做种无效源文件")
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【检测无效源文件】",
|
||||
text=f"{source_path} 不存在,无法检测未做种无效源文件",
|
||||
)
|
||||
continue
|
||||
source_files = []
|
||||
# 获取source_path下的所有文件包括文件夹
|
||||
for file in source_path.iterdir():
|
||||
source_files.append(file)
|
||||
for source_file in source_files:
|
||||
skip = False
|
||||
for key_word in exclude_key_words:
|
||||
if key_word in source_file.name:
|
||||
logger.info(f"{str(source_file)}命中关键字{key_word},不做处理")
|
||||
skip = True
|
||||
break
|
||||
if skip:
|
||||
continue
|
||||
# 将mp_path替换成 qb_path
|
||||
qb_path = (str(source_file)).replace(
|
||||
source_path_str, source_path_map[source_path_str]
|
||||
)
|
||||
# todo: 优化性能
|
||||
is_exist = False
|
||||
for content_path in content_path_set:
|
||||
if qb_path in content_path:
|
||||
is_exist = True
|
||||
break
|
||||
|
||||
if not is_exist:
|
||||
deleted_file_cnt += 1
|
||||
message += f"{deleted_file_cnt}. {str(source_file)}\n"
|
||||
total_size += self.get_size(source_file)
|
||||
if self._delete_invalid_files:
|
||||
if source_file.is_file():
|
||||
source_file.unlink()
|
||||
elif source_file.is_dir():
|
||||
shutil.rmtree(source_file)
|
||||
|
||||
message += f"检测到{deleted_file_cnt}个未做种的无效源文件,共占用{StringUtils.str_filesize(total_size)}空间。\n"
|
||||
if self._delete_invalid_files:
|
||||
message += f"***已删除无效源文件,释放{StringUtils.str_filesize(total_size)}空间!***\n"
|
||||
logger.info(message)
|
||||
if self._notify:
|
||||
message = message.replace("_", "\_")
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【清理无效做种】",
|
||||
text=message,
|
||||
)
|
||||
logger.info("检测无效源文件任务结束")
|
||||
|
||||
def get_size(self, path: Path):
|
||||
total_size = 0
|
||||
if path.is_file():
|
||||
return path.stat().st_size
|
||||
# rglob 方法用于递归遍历所有文件和目录
|
||||
for entry in path.rglob("*"):
|
||||
if entry.is_file():
|
||||
total_size += entry.stat().st_size
|
||||
return total_size
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"component": "VForm",
|
||||
"content": [
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "enabled",
|
||||
"label": "启用插件",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "notify",
|
||||
"label": "开启通知",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "onlyonce",
|
||||
"label": "立即运行一次",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "delete_invalid_torrents",
|
||||
"label": "删除无效种子(确认无误后再开启)",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "detect_invalid_files",
|
||||
"label": "检测无效源文件",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "delete_invalid_files",
|
||||
"label": "删除无效源文件(确认无误后再开启)",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "notify_all",
|
||||
"label": "全量通知",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "label_only",
|
||||
"label": "仅标记模式(开启后不会删种)",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "more_logs",
|
||||
"label": "打印更多日志",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": { "cols": 12, "md": 6 },
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextField",
|
||||
"props": {
|
||||
"model": "cron",
|
||||
"label": "执行周期",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": { "cols": 12, "md": 6 },
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextField",
|
||||
"props": {
|
||||
"model": "label",
|
||||
"label": "增加标签",
|
||||
"placeholder": "仅标记模式下生效,给待处理的种子打标签",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextarea",
|
||||
"props": {
|
||||
"model": "download_dirs",
|
||||
"label": "下载目录映射",
|
||||
"rows": 2,
|
||||
"placeholder": "填写要监控的源文件目录,并设置MP和QB的目录映射关系,如/mp/download:/qb/download,多个目录请换行",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"props": {"style": {"margin-top": "0px"}},
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextarea",
|
||||
"props": {
|
||||
"model": "exclude_keywords",
|
||||
"label": "过滤删源文件关键字",
|
||||
"rows": 2,
|
||||
"placeholder": "多个关键字请换行,仅针对删除源文件",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextarea",
|
||||
"props": {
|
||||
"model": "exclude_categories",
|
||||
"label": "过滤删种分类",
|
||||
"rows": 2,
|
||||
"placeholder": "多个分类请换行,仅针对删除种子",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextarea",
|
||||
"props": {
|
||||
"model": "exclude_labels",
|
||||
"label": "过滤删种标签",
|
||||
"rows": 2,
|
||||
"placeholder": "多个标签请换行,仅针对删除种子",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextarea",
|
||||
"props": {
|
||||
"model": "custom_error_msg",
|
||||
"label": "自定义无效做种tracker错误信息",
|
||||
"rows": 5,
|
||||
"placeholder": "填入想要清理的种子的tracker错误信息,如'skipping tracker announce (unreachable)',多个信息请换行",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VAlert",
|
||||
"props": {
|
||||
"type": "error",
|
||||
"variant": "tonal",
|
||||
"text": "谨慎起见删除种子/源文件功能做了开关,请确认无误后再开启删除功能",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VAlert",
|
||||
"props": {
|
||||
"type": "info",
|
||||
"variant": "tonal",
|
||||
"text": "下载目录映射填入源文件根目录,并设置MP和QB的目录映射关系。如某种子下载的源文件A存放路径为/qb/download/A,则目录映射填入/mp/download:/qb/download,多个目录请换行。注意映射目录不要有多余的'/'",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"notify": False,
|
||||
"download_dirs": "",
|
||||
"delete_invalid_torrents": False,
|
||||
"delete_invalid_files": False,
|
||||
"detect_invalid_files": False,
|
||||
"notify_all": False,
|
||||
"onlyonce": False,
|
||||
"cron": "0 0 * * *",
|
||||
"label_only": False,
|
||||
"label": "",
|
||||
"more_logs": False,
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
退出插件
|
||||
"""
|
||||
try:
|
||||
if self._scheduler:
|
||||
self._scheduler.remove_all_jobs()
|
||||
if self._scheduler.running:
|
||||
self._scheduler.shutdown()
|
||||
self._scheduler = None
|
||||
except Exception as e:
|
||||
logger.error("退出插件失败:%s" % str(e))
|
||||
540
plugins.v2/clouddiskdel/__init__.py
Normal file
540
plugins.v2/clouddiskdel/__init__.py
Normal file
@@ -0,0 +1,540 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.log import logger
|
||||
from app.plugins import _PluginBase
|
||||
from typing import Any, List, Dict, Tuple
|
||||
|
||||
from app.schemas.types import EventType, MediaImageType, NotificationType, MediaType
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class CloudDiskDel(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "云盘文件删除"
|
||||
# 插件描述
|
||||
plugin_desc = "媒体库删除strm文件后同步删除云盘资源。"
|
||||
# 插件图标
|
||||
plugin_icon = "clouddisk.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.3"
|
||||
# 插件作者
|
||||
plugin_author = "thsrite"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/thsrite"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "clouddiskdel_"
|
||||
# 加载顺序
|
||||
plugin_order = 26
|
||||
# 可使用的用户级别
|
||||
auth_level = 1
|
||||
|
||||
# 私有属性
|
||||
_enabled = False
|
||||
# 任务执行间隔
|
||||
_paths = {}
|
||||
_notify = False
|
||||
_del_history = False
|
||||
|
||||
_video_formats = ('.mp4', '.avi', '.rmvb', '.wmv', '.mov', '.mkv', '.flv', '.ts', '.webm', '.iso', '.mpg')
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._notify = config.get("notify")
|
||||
self._del_history = config.get("del_history")
|
||||
for path in str(config.get("path")).split("\n"):
|
||||
paths = path.split(":")
|
||||
self._paths[paths[0]] = paths[1]
|
||||
|
||||
# 清理插件历史
|
||||
if self._del_history:
|
||||
self.del_data(key="history")
|
||||
self.update_config({
|
||||
"enabled": self._enabled,
|
||||
"notify": self._notify,
|
||||
"path": config.get("path"),
|
||||
"del_history": False
|
||||
})
|
||||
|
||||
@eventmanager.register(EventType.PluginAction)
|
||||
def clouddisk_del(self, event: Event):
|
||||
if not self._enabled:
|
||||
return
|
||||
if not event:
|
||||
return
|
||||
|
||||
event_data = event.event_data
|
||||
if not event_data or event_data.get("action") != "networkdisk_del":
|
||||
return
|
||||
|
||||
logger.info(f"获取到云盘删除请求 {event_data}")
|
||||
|
||||
media_path = event_data.get("media_path")
|
||||
if not media_path:
|
||||
logger.error("未获取到删除路径")
|
||||
return
|
||||
|
||||
media_name = event_data.get("media_name")
|
||||
tmdb_id = event_data.get("tmdb_id")
|
||||
media_type = event_data.get("media_type")
|
||||
season_num = event_data.get("season_num")
|
||||
episode_num = event_data.get("episode_num")
|
||||
|
||||
# 不是网盘监控路径,直接排除
|
||||
cloud_file_flag = False
|
||||
|
||||
# 判断删除媒体路径是否与配置的媒体库路径相符,相符则继续删除,不符则跳过
|
||||
for library_path in list(self._paths.keys()):
|
||||
if str(media_path).startswith(library_path):
|
||||
cloud_file_flag = True
|
||||
# 替换网盘路径
|
||||
media_path = str(media_path).replace(library_path, self._paths.get(library_path))
|
||||
logger.info(f"获取到moviepilot本地云盘挂载路径 {media_path}")
|
||||
path = Path(media_path)
|
||||
if path.is_file() or media_path.endswith(".strm"):
|
||||
# 删除文件、nfo、jpg等同名文件
|
||||
pattern = path.stem.replace('[', '?').replace(']', '?')
|
||||
logger.info(f"开始筛选同名文件 {pattern}")
|
||||
files = path.parent.glob(f"{pattern}.*")
|
||||
|
||||
remove_flag = False
|
||||
for file in files:
|
||||
Path(file).unlink()
|
||||
logger.info(f"云盘文件 {file} 已删除")
|
||||
self.__remove_json(file)
|
||||
remove_flag = True
|
||||
|
||||
if not remove_flag:
|
||||
for ext in self._video_formats:
|
||||
file = path.stem + ext
|
||||
if Path(file).exists():
|
||||
Path(file).unlink()
|
||||
logger.info(f"云盘文件 {file} 已删除")
|
||||
self.__remove_json(file)
|
||||
else:
|
||||
# 非根目录,才删除目录
|
||||
shutil.rmtree(path)
|
||||
# 删除目录
|
||||
logger.warn(f"云盘目录 {path} 已删除")
|
||||
self.__remove_json(path)
|
||||
|
||||
# 判断当前媒体父路径下是否有媒体文件,如有则无需遍历父级
|
||||
if not SystemUtils.exits_files(path.parent, settings.RMT_MEDIAEXT):
|
||||
# 判断父目录是否为空, 为空则删除
|
||||
for parent_path in path.parents:
|
||||
if str(parent_path.parent) != str(path.root):
|
||||
# 父目录非根目录,才删除父目录
|
||||
if not SystemUtils.exits_files(parent_path, settings.RMT_MEDIAEXT):
|
||||
# 当前路径下没有媒体文件则删除
|
||||
shutil.rmtree(parent_path)
|
||||
logger.warn(f"云盘目录 {parent_path} 已删除")
|
||||
self.__remove_json(parent_path)
|
||||
break
|
||||
|
||||
if cloud_file_flag:
|
||||
# 发送消息
|
||||
image = 'https://emby.media/notificationicon.png'
|
||||
media_type = MediaType.MOVIE if media_type in ["Movie", "MOV"] else MediaType.TV
|
||||
if self._notify:
|
||||
backrop_image = self.chain.obtain_specific_image(
|
||||
mediaid=tmdb_id,
|
||||
mtype=media_type,
|
||||
image_type=MediaImageType.Backdrop,
|
||||
season=season_num,
|
||||
episode=episode_num
|
||||
) or image
|
||||
|
||||
# 类型
|
||||
if media_type == MediaType.MOVIE:
|
||||
msg = f'电影 {media_name} {tmdb_id}'
|
||||
# 删除电视剧
|
||||
elif media_type == MediaType.TV and not season_num and not episode_num:
|
||||
msg = f'剧集 {media_name} {tmdb_id}'
|
||||
# 删除季 S02
|
||||
elif media_type == MediaType.TV and season_num and not episode_num:
|
||||
msg = f'剧集 {media_name} S{season_num} {tmdb_id}'
|
||||
# 删除剧集S02E02
|
||||
elif media_type == MediaType.TV and season_num and episode_num:
|
||||
msg = f'剧集 {media_name} S{season_num}E{episode_num} {tmdb_id}'
|
||||
else:
|
||||
msg = media_name
|
||||
|
||||
# 发送通知
|
||||
self.post_message(
|
||||
mtype=NotificationType.MediaServer,
|
||||
title="云盘同步删除任务完成",
|
||||
image=backrop_image,
|
||||
text=f"{msg}\n"
|
||||
f"时间 {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}"
|
||||
)
|
||||
|
||||
# 读取历史记录
|
||||
history = self.get_data('history') or []
|
||||
|
||||
# 获取poster
|
||||
poster_image = self.chain.obtain_specific_image(
|
||||
mediaid=tmdb_id,
|
||||
mtype=media_type,
|
||||
image_type=MediaImageType.Poster,
|
||||
) or image
|
||||
history.append({
|
||||
"type": media_type.value,
|
||||
"title": media_name,
|
||||
"path": media_path,
|
||||
"season": season_num if season_num and str(season_num).isdigit() else None,
|
||||
"episode": episode_num if episode_num and str(episode_num).isdigit() else None,
|
||||
"image": poster_image,
|
||||
"del_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time()))
|
||||
})
|
||||
|
||||
# 保存历史
|
||||
self.save_data("history", history)
|
||||
|
||||
def __remove_json(self, path):
|
||||
"""
|
||||
删除json中的文件内容
|
||||
"""
|
||||
try:
|
||||
# 删除本地缓存文件
|
||||
cloud_files_json = os.path.join(settings.PLUGIN_DATA_PATH, "CloudStrm", "cloud_files.json")
|
||||
if Path(cloud_files_json).exists():
|
||||
# 删除json文件中已删除部分文件
|
||||
# 尝试加载本地
|
||||
with open(cloud_files_json, 'r') as file:
|
||||
content = file.read()
|
||||
if content:
|
||||
__cloud_files = json.loads(content)
|
||||
if __cloud_files:
|
||||
if not isinstance(__cloud_files, list):
|
||||
__cloud_files = [__cloud_files]
|
||||
if str(path) in __cloud_files:
|
||||
# 删除已删除文件
|
||||
__cloud_files.remove(str(path))
|
||||
# 重新写入本地
|
||||
file = open(cloud_files_json, 'w')
|
||||
file.write(json.dumps(__cloud_files))
|
||||
file.close()
|
||||
else:
|
||||
remove_flag = False
|
||||
# 删除目录下文件
|
||||
for cloud_file in __cloud_files:
|
||||
if str(cloud_file).startswith(str(path)):
|
||||
__cloud_files.remove(cloud_file)
|
||||
remove_flag = True
|
||||
if remove_flag:
|
||||
# 重新写入本地
|
||||
file = open(cloud_files_json, 'w')
|
||||
file.write(json.dumps(__cloud_files))
|
||||
file.close()
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
定义远程控制命令
|
||||
:return: 命令关键字、事件、描述、附带数据
|
||||
"""
|
||||
return [{
|
||||
"cmd": "/networkdisk_del",
|
||||
"event": EventType.PluginAction,
|
||||
"desc": "云盘文件删除",
|
||||
"category": "",
|
||||
"data": {
|
||||
"action": "networkdisk_del"
|
||||
}
|
||||
}]
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enabled',
|
||||
'label': '启用插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'notify',
|
||||
'label': '开启通知',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'del_history',
|
||||
'label': '删除历史',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextarea',
|
||||
'props': {
|
||||
'model': 'path',
|
||||
'rows': '2',
|
||||
'label': '媒体库路径映射',
|
||||
'placeholder': '媒体服务器路径:moviepilot内云盘挂载路径(一行一个)'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '需要开启媒体库删除插件且正确配置排除路径。'
|
||||
'主要针对于strm文件删除后同步删除云盘资源。'
|
||||
'如遇删除失败,请检查文件权限问题。'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '关于路径映射:'
|
||||
'emby:/data/series/A.mp4,'
|
||||
'moviepilot内云盘挂载路径:/mnt/link/series/A.mp4。'
|
||||
'路径映射填/data:/mnt/link'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"path": "",
|
||||
"notify": False,
|
||||
"del_history": False
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
"""
|
||||
拼装插件详情页面,需要返回页面配置,同时附带数据
|
||||
"""
|
||||
# 查询同步详情
|
||||
historys = self.get_data('history')
|
||||
if not historys:
|
||||
return [
|
||||
{
|
||||
'component': 'div',
|
||||
'text': '暂无数据',
|
||||
'props': {
|
||||
'class': 'text-center',
|
||||
}
|
||||
}
|
||||
]
|
||||
# 数据按时间降序排序
|
||||
historys = sorted(historys, key=lambda x: x.get('del_time'), reverse=True)
|
||||
# 拼装页面
|
||||
contents = []
|
||||
for history in historys:
|
||||
htype = history.get("type")
|
||||
title = history.get("title")
|
||||
season = history.get("season")
|
||||
episode = history.get("episode")
|
||||
image = history.get("image")
|
||||
del_time = history.get("del_time")
|
||||
|
||||
if season:
|
||||
sub_contents = [
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'类型:{htype}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'标题:{title}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'季:{season}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'集:{episode}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'时间:{del_time}'
|
||||
}
|
||||
]
|
||||
else:
|
||||
sub_contents = [
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'类型:{htype}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'标题:{title}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'时间:{del_time}'
|
||||
}
|
||||
]
|
||||
|
||||
contents.append(
|
||||
{
|
||||
'component': 'VCard',
|
||||
'content': [
|
||||
{
|
||||
'component': 'div',
|
||||
'props': {
|
||||
'class': 'd-flex justify-space-start flex-nowrap flex-row',
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'div',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VImg',
|
||||
'props': {
|
||||
'src': image,
|
||||
'height': 120,
|
||||
'width': 80,
|
||||
'aspect-ratio': '2/3',
|
||||
'class': 'object-cover shadow ring-gray-500',
|
||||
'cover': True
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'div',
|
||||
'content': sub_contents
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
'component': 'div',
|
||||
'props': {
|
||||
'class': 'grid gap-3 grid-info-card',
|
||||
},
|
||||
'content': contents
|
||||
}
|
||||
]
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
退出插件
|
||||
"""
|
||||
pass
|
||||
597
plugins.v2/configcenter/__init__.py
Normal file
597
plugins.v2/configcenter/__init__.py
Normal file
@@ -0,0 +1,597 @@
|
||||
import copy
|
||||
from typing import Any, List, Dict, Tuple
|
||||
|
||||
from dotenv import set_key
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.module import ModuleManager
|
||||
from app.log import logger
|
||||
from app.plugins import _PluginBase
|
||||
|
||||
|
||||
class ConfigCenter(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "配置中心"
|
||||
# 插件描述
|
||||
plugin_desc = "快速调整部分系统设定。"
|
||||
# 插件图标
|
||||
plugin_icon = "setting.png"
|
||||
# 插件版本
|
||||
plugin_version = "2.6"
|
||||
# 插件作者
|
||||
plugin_author = "jxxghp"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/jxxghp"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "configcenter_"
|
||||
# 加载顺序
|
||||
plugin_order = 0
|
||||
# 可使用的用户级别
|
||||
auth_level = 1
|
||||
|
||||
# 私有属性
|
||||
_enabled = False
|
||||
_params = ""
|
||||
_writeenv = False
|
||||
settings_attributes = [
|
||||
"GITHUB_TOKEN", "API_TOKEN", "TMDB_API_DOMAIN", "TMDB_IMAGE_DOMAIN", "WALLPAPER",
|
||||
"RECOGNIZE_SOURCE", "SCRAP_FOLLOW_TMDB", "AUTO_DOWNLOAD_USER",
|
||||
"OCR_HOST", "DOWNLOAD_SUBTITLE", "PLUGIN_MARKET", "MOVIE_RENAME_FORMAT",
|
||||
"TV_RENAME_FORMAT", "FANART_ENABLE", "DOH_ENABLE", "SEARCH_MULTIPLE_NAME", "META_CACHE_EXPIRE",
|
||||
"GITHUB_PROXY", "DOH_DOMAINS", "DOH_RESOLVERS"
|
||||
]
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
if not config:
|
||||
return
|
||||
|
||||
self._enabled = config.get("enabled")
|
||||
self._writeenv = config.get("writeenv")
|
||||
if not self._enabled:
|
||||
return
|
||||
logger.info(f"正在应用配置中心配置:{config}")
|
||||
for attribute in self.settings_attributes:
|
||||
setattr(settings, attribute, config.get(attribute) or getattr(settings, attribute))
|
||||
# 自定义配置,以换行分隔
|
||||
self._params = config.get("params") or ""
|
||||
for key, value in self.__parse_params(self._params).items():
|
||||
if hasattr(settings, key):
|
||||
setattr(settings, key, str(value))
|
||||
|
||||
# 重新加载模块
|
||||
ModuleManager().stop()
|
||||
ModuleManager().load_modules()
|
||||
|
||||
# 如果写入app.env文件,则关闭插件开关
|
||||
if self._writeenv:
|
||||
# 写入env文件
|
||||
self.update_env(config)
|
||||
# 自动关闭插件
|
||||
self._enabled = False
|
||||
logger.info("配置中心设置已写入app.env文件,插件关闭...")
|
||||
# 保存配置
|
||||
config.update({"enabled": False})
|
||||
self.update_config(config)
|
||||
|
||||
def update_env(self, config: dict):
|
||||
"""
|
||||
更新设置到app.env
|
||||
"""
|
||||
if not config:
|
||||
return
|
||||
|
||||
# 避免修改原值
|
||||
conf = copy.deepcopy(config)
|
||||
|
||||
# 自定义配置,以换行分隔
|
||||
config_params = self.__parse_params(conf.get("params"))
|
||||
conf.update(config_params)
|
||||
# 读写app.env
|
||||
env_path = settings.CONFIG_PATH / "app.env"
|
||||
for key, value in conf.items():
|
||||
if not key:
|
||||
continue
|
||||
# 如果参数不在支持列表中, 则跳过
|
||||
if key not in self.settings_attributes and key not in config_params:
|
||||
continue
|
||||
if value is None or str(value) == "None":
|
||||
value = ''
|
||||
else:
|
||||
value = str(value)
|
||||
set_key(env_path, key, value)
|
||||
logger.info("app.env文件写入完成")
|
||||
self.systemmessage.put("配置中心设置已写入app.env文件,插件关闭", title="配置中心")
|
||||
|
||||
@staticmethod
|
||||
def __parse_params(param_str: str) -> dict:
|
||||
"""
|
||||
解析自定义配置
|
||||
"""
|
||||
if not param_str:
|
||||
return {}
|
||||
result = {}
|
||||
params = param_str.split("\n")
|
||||
for param in params:
|
||||
if not param:
|
||||
continue
|
||||
if str(param).strip().startswith("#"):
|
||||
continue
|
||||
parts = param.split("=", 1)
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
key = parts[0].strip()
|
||||
value = parts[1].strip()
|
||||
if not key:
|
||||
continue
|
||||
if not value:
|
||||
continue
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
default_settings = {
|
||||
"enabled": False,
|
||||
"params": "",
|
||||
}
|
||||
for attribute in self.settings_attributes:
|
||||
default_settings[attribute] = getattr(settings, attribute)
|
||||
return [
|
||||
{
|
||||
"component": "VForm",
|
||||
"content": [
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
"md": 6
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "enabled",
|
||||
"label": "启用插件"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
"md": 6
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "writeenv",
|
||||
"label": "写入app.env文件"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
"md": 6
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextField",
|
||||
"props": {
|
||||
"model": "GITHUB_TOKEN",
|
||||
"label": "Github Token"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
"md": 6
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextField",
|
||||
"props": {
|
||||
"model": "API_TOKEN",
|
||||
"label": "API密钥"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
"md": 6
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextField",
|
||||
"props": {
|
||||
"model": "TMDB_API_DOMAIN",
|
||||
"label": "TMDB API地址"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
"md": 6
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextField",
|
||||
"props": {
|
||||
"model": "TMDB_IMAGE_DOMAIN",
|
||||
"label": "TheMovieDb图片服务器"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
"md": 6
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSelect",
|
||||
"props": {
|
||||
"model": "RECOGNIZE_SOURCE",
|
||||
"label": "媒体信息识别来源",
|
||||
"items": [
|
||||
{"title": "TheMovieDb", "value": "themoviedb"},
|
||||
{"title": "豆瓣", "value": "douban"}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
"md": 6
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSelect",
|
||||
"props": {
|
||||
"model": "SCRAP_SOURCE",
|
||||
"label": "刮削元数据及图片使用的数据源",
|
||||
"items": [
|
||||
{"title": "TheMovieDb", "value": "themoviedb"},
|
||||
{"title": "豆瓣", "value": "douban"},
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
"md": 6
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSelect",
|
||||
"props": {
|
||||
"model": "WALLPAPER",
|
||||
"label": "登录首页电影海报",
|
||||
"items": [
|
||||
{"title": "TheMovieDb电影海报", "value": "tmdb"},
|
||||
{"title": "Bing每日壁纸", "value": "bing"}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
"md": 6
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextField",
|
||||
"props": {
|
||||
"model": "OCR_HOST",
|
||||
"label": "验证码识别服务器"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
"md": 6
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextField",
|
||||
"props": {
|
||||
"model": "GITHUB_PROXY",
|
||||
"label": "Github加速服务器",
|
||||
"placeholder": "https://mirror.ghproxy.com/"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
"md": 6
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextField",
|
||||
"props": {
|
||||
"model": "DOH_DOMAINS",
|
||||
"label": "DOH解析的域名",
|
||||
"placeholder": "多个域名使用,分隔"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
"md": 6
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextField",
|
||||
"props": {
|
||||
"model": "DOH_RESOLVERS",
|
||||
"label": "DOH解析服务器",
|
||||
"placeholder": "多个地址使用,分隔"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextarea",
|
||||
"props": {
|
||||
"model": "MOVIE_RENAME_FORMAT",
|
||||
"label": "电影重命名格式"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextarea",
|
||||
"props": {
|
||||
"model": "TV_RENAME_FORMAT",
|
||||
"label": "电视剧重命名格式"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextarea",
|
||||
"props": {
|
||||
"model": "PLUGIN_MARKET",
|
||||
"label": "插件市场",
|
||||
"placeholder": "多个地址使用,分隔"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextarea",
|
||||
"props": {
|
||||
"model": "params",
|
||||
"label": "自定义配置",
|
||||
"placeholder": "每行一个配置项,格式:配置项=值"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
"md": 4
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "DOWNLOAD_SUBTITLE",
|
||||
"label": "自动下载站点字幕"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
"md": 4
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "SCRAP_FOLLOW_TMDB",
|
||||
"label": "新增入库跟随TMDB信息变化"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
"md": 4
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "FANART_ENABLE",
|
||||
"label": "使用Fanart图片数据源"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
"md": 4
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "DOH_ENABLE",
|
||||
"label": "启用DNS over HTTPS"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
"md": 4
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "SEARCH_MULTIPLE_NAME",
|
||||
"label": "资源搜索整合多名称搜索结果"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '注意:开启写入app.env后将直接修改配置文件,否则只是运行时修改生效对应配置(插件关闭且重启后配置失效);有些自定义配置需要重启才能生效。'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], default_settings
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
退出插件
|
||||
"""
|
||||
pass
|
||||
1232
plugins.v2/crossseed/__init__.py
Normal file
1232
plugins.v2/crossseed/__init__.py
Normal file
File diff suppressed because it is too large
Load Diff
456
plugins.v2/diagparamadjust/__init__.py
Normal file
456
plugins.v2/diagparamadjust/__init__.py
Normal file
@@ -0,0 +1,456 @@
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.modules.emby import Emby
|
||||
from app.core.config import settings
|
||||
from app.plugins import _PluginBase
|
||||
from app.log import logger
|
||||
from typing import List, Tuple, Dict, Any, Optional
|
||||
import pytz
|
||||
from app.schemas import WebhookEventInfo
|
||||
from app.schemas.types import EventType
|
||||
from app.core.event import eventmanager, Event
|
||||
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
|
||||
class DiagParamAdjust(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "诊断参数调整"
|
||||
# 插件描述
|
||||
plugin_desc = "Emby专用插件|暂时性解决emby字幕偏移问题,需要emby安装Diagnostics插件。"
|
||||
# 插件图标
|
||||
plugin_icon = "Gatus_A.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.3"
|
||||
# 插件作者
|
||||
plugin_author = "jeblove"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/jeblove"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "dpa_"
|
||||
# 加载顺序
|
||||
plugin_order = 14
|
||||
# 可使用的用户级别
|
||||
auth_level = 1
|
||||
|
||||
# 私有属性
|
||||
_enabled: bool = False
|
||||
# 修正字幕偏移用途(播放时执行)
|
||||
_offset_play = True
|
||||
_onlyonce = False
|
||||
_base_url = None
|
||||
_endpoint = None
|
||||
_api_key = None
|
||||
_search_text = None
|
||||
_replace_text = None
|
||||
_cron = None
|
||||
_cron_switch = False
|
||||
|
||||
# 请求接口
|
||||
_url = "[HOST]emby/EncodingDiagnostics/DiagnosticOptions?api_key=[APIKEY]"
|
||||
# 定时器
|
||||
_scheduler: Optional[BackgroundScheduler] = None
|
||||
|
||||
# 目标消息
|
||||
_webhook_actions = {
|
||||
"playback.start": "开始播放",
|
||||
}
|
||||
|
||||
# 分辨率标识
|
||||
_resolution = None
|
||||
# 分辨率改动
|
||||
_last_resolution = None
|
||||
# 目标参数
|
||||
_target_search_text = None
|
||||
_target_replace_text = None
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
# 停止现有任务
|
||||
self.stop_service()
|
||||
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._offset_play = config.get("offset_play")
|
||||
self._onlyonce = config.get("onlyonce")
|
||||
self._search_text = config.get("search")
|
||||
self._replace_text = config.get("replace")
|
||||
self._cron = config.get("cron")
|
||||
self._cron_switch = config.get("cron_switch")
|
||||
|
||||
if self._onlyonce:
|
||||
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
logger.info(f"诊断参数调整服务启动,立刻运行一次")
|
||||
self._scheduler.add_job(func=self.run, trigger='date',
|
||||
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
|
||||
name="诊断参数调整")
|
||||
|
||||
# 关闭一次性开关
|
||||
self._onlyonce = False
|
||||
self.update_config({
|
||||
"enabled": self._enabled,
|
||||
"offset_play": self._offset_play,
|
||||
"onlyonce": False,
|
||||
"search": self._search_text,
|
||||
"replace": self._replace_text,
|
||||
"cron": self._cron,
|
||||
"cron_switch": self._cron_switch,
|
||||
})
|
||||
|
||||
# 启动任务
|
||||
if self._scheduler.get_jobs():
|
||||
self._scheduler.print_jobs()
|
||||
self._scheduler.start()
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_service(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
注册插件公共服务
|
||||
[{
|
||||
"id": "服务ID",
|
||||
"name": "服务名称",
|
||||
"trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()",
|
||||
"func": self.xxx,
|
||||
"kwargs": {} # 定时器参数
|
||||
}]
|
||||
"""
|
||||
if self._enabled and self._cron and self._cron_switch:
|
||||
return [{
|
||||
"id": "DiagParamAdjust",
|
||||
"name": "诊断参数调整定时服务",
|
||||
"trigger": CronTrigger.from_crontab(self._cron),
|
||||
"func": self.run,
|
||||
"kwargs": {}
|
||||
}]
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enabled',
|
||||
'label': '启用插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'offset_play',
|
||||
'label': '修正字幕偏移(播放时执行)',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'onlyonce',
|
||||
'label': '立即运行一次',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'search',
|
||||
'label': '搜索文本'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'replace',
|
||||
'label': '替换文本'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'cron',
|
||||
'label': '检测执行周期',
|
||||
'placeholder': '*/5 * * * *'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 3
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'cron_switch',
|
||||
'label': '周期模式',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '- 暂时性解决emby字幕偏移问题,如默认参数不合适请在基础上修改【替换文本】x、y至适合(4K视频情况下!),如[x=W/4:y=h/5]。\n - 【修正字幕偏移(播放时执行)】需要emby配置webhooks消息通知:勾选[播放-开始](具体可参考【媒体库服务器通知】插件)',
|
||||
'style': 'white-space: pre-line;'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '- 播放视频分辨率与上次视频分辨率不一致时,在通知延迟和已加载旧位置字幕影响下,需要片刻后才会加载到新位置字幕,或关闭视频再次打开(建议)。\n - 此替换文本参数应用于emby-Diagnostics-Parameter Adjustment。\n - 默认参数用于修改ffmpeg中字幕覆盖在视频上的位置。\n - 方案来源于https://opve.cn/archives/983.html',
|
||||
'style': 'white-space: pre-line;'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"offset_play": True,
|
||||
"onlyonce": False,
|
||||
"search": "x=(W-w)/2:y=(H-h):repeatlast=0",
|
||||
"replace": "x=W/4:y=h/4:repeatlast=0",
|
||||
"cron": "*/5 * * * *",
|
||||
"cron_switch": False,
|
||||
}
|
||||
|
||||
def detect(self):
|
||||
"""
|
||||
检测是否存在目标参数(修正字幕偏移用途)
|
||||
|
||||
:return True: 存在; False: 不存在
|
||||
"""
|
||||
logger.info('字幕偏移修正,检测目标参数')
|
||||
try:
|
||||
res = Emby().get_data(self._url)
|
||||
result = res.json()
|
||||
data = result['Object']['CommandLineOptions']
|
||||
searchText = data['SearchText']
|
||||
replaceText = data['ReplaceText']
|
||||
except json.JSONDecodeError:
|
||||
logger.error('服务停止,Emby请安装【Diagnostics】插件')
|
||||
return None
|
||||
except KeyError:
|
||||
# 已装插件,未设置过该参数
|
||||
# logger.info('目标参数为空')
|
||||
return False
|
||||
|
||||
# 符合所有情况
|
||||
if (('repeatlast' in replaceText
|
||||
and 'x=(W-w)/2:y=(H-h):repeatlast=0' in searchText
|
||||
and result['Object']['TranscodingOptions']['DisableHardwareSubtitleOverlay'] is True)
|
||||
or (searchText == "" and replaceText == "")) \
|
||||
and self._resolution == self._last_resolution:
|
||||
# (A or B) and C
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def set_options(self):
|
||||
"""
|
||||
向Emby发送请求设置参数
|
||||
"""
|
||||
|
||||
# 根据分辨率情况而选择是否替换
|
||||
if self._resolution == 0 and self._offset_play is True:
|
||||
# 1080p,不替换(清空文本)
|
||||
self._target_search_text = ""
|
||||
self._target_replace_text = ""
|
||||
logger.info('清空替换参数')
|
||||
else:
|
||||
# >1080p or 非字幕偏移用途
|
||||
self._target_search_text = self._search_text
|
||||
self._target_replace_text = self._replace_text
|
||||
logger.info("替换值为:{}".format(self._target_replace_text))
|
||||
|
||||
data = {
|
||||
"CommandLineOptions": {
|
||||
"SearchText": self._target_search_text,
|
||||
"ReplaceText": self._target_replace_text
|
||||
},
|
||||
"TranscodingOptions": {
|
||||
"DisableHardwareSubtitleOverlay": True
|
||||
}
|
||||
}
|
||||
data = json.dumps(data)
|
||||
headers = {
|
||||
'Content-Type': 'application/octet-stream'
|
||||
}
|
||||
res = Emby().post_data(self._url, data, headers)
|
||||
if res.status_code // 100 == 2:
|
||||
logger.info('参数设置成功')
|
||||
return True
|
||||
else:
|
||||
logger.error('参数设置失败 {}'.format(res.status_code))
|
||||
return False
|
||||
|
||||
@eventmanager.register(EventType.WebhookMessage)
|
||||
def get_msg(self, event: Event):
|
||||
# 消息方式开关
|
||||
if not self._enabled or not self._offset_play:
|
||||
return
|
||||
|
||||
# 消息获取
|
||||
event_info: WebhookEventInfo = event.event_data
|
||||
if not event_info:
|
||||
return
|
||||
|
||||
# 非目标消息
|
||||
if not self._webhook_actions.get(event_info.event):
|
||||
return
|
||||
|
||||
# 根据视频名获得分辨率信息
|
||||
item_path = event_info.item_path
|
||||
video_resolution = re.findall(r"\d{3,4}p", item_path)
|
||||
video_width = int(video_resolution[0][:-1])
|
||||
logger.info('视频分辨率:{}'.format(video_width))
|
||||
|
||||
self._last_resolution = self._resolution
|
||||
# 分辨率变化情况
|
||||
if video_width > 1080:
|
||||
# 2160p/4k
|
||||
self._resolution = 1
|
||||
else:
|
||||
self._resolution = 0
|
||||
self.run()
|
||||
|
||||
def run(self):
|
||||
# 字幕偏移修正,则带检测
|
||||
if self._offset_play:
|
||||
state = self.detect()
|
||||
if state:
|
||||
logger.info('参数正常,无需修正')
|
||||
return True
|
||||
elif state is None:
|
||||
logger.info('插件退出')
|
||||
return None
|
||||
|
||||
self.set_options()
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
退出插件
|
||||
"""
|
||||
try:
|
||||
if self._scheduler:
|
||||
self._scheduler.remove_all_jobs()
|
||||
if self._scheduler.running:
|
||||
self._scheduler.shutdown()
|
||||
self._scheduler = None
|
||||
except Exception as e:
|
||||
logger.error("退出插件失败:%s" % str(e))
|
||||
812
plugins.v2/downloadsitetag/__init__.py
Normal file
812
plugins.v2/downloadsitetag/__init__.py
Normal file
@@ -0,0 +1,812 @@
|
||||
import datetime
|
||||
import pytz
|
||||
import threading
|
||||
from typing import List, Tuple, Dict, Any, Optional
|
||||
|
||||
from app.core.context import Context
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.schemas.types import EventType, MediaType
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
from app.plugins import _PluginBase
|
||||
from app.modules.qbittorrent import Qbittorrent
|
||||
from app.modules.transmission import Transmission
|
||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||
from app.db.models.downloadhistory import DownloadHistory
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
class DownloadSiteTag(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "下载任务分类与标签"
|
||||
# 插件描述
|
||||
plugin_desc = "自动给下载任务分类与打站点标签、剧集名称标签"
|
||||
# 插件图标
|
||||
plugin_icon = "Youtube-dl_B.png"
|
||||
# 插件版本
|
||||
plugin_version = "2.1"
|
||||
# 插件作者
|
||||
plugin_author = "叮叮当"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/cikezhu"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "DownloadSiteTag_"
|
||||
# 加载顺序
|
||||
plugin_order = 2
|
||||
# 可使用的用户级别
|
||||
auth_level = 1
|
||||
# 日志前缀
|
||||
LOG_TAG = "[DownloadSiteTag] "
|
||||
|
||||
# 退出事件
|
||||
_event = threading.Event()
|
||||
# 私有属性
|
||||
downloader_qb = None
|
||||
downloader_tr = None
|
||||
downloadhistory_oper = None
|
||||
sites_helper = None
|
||||
_scheduler = None
|
||||
_enabled = False
|
||||
_onlyonce = False
|
||||
_interval = "计划任务"
|
||||
_interval_cron = "5 4 * * *"
|
||||
_interval_time = 6
|
||||
_interval_unit = "小时"
|
||||
_enabled_media_tag = False
|
||||
_enabled_tag = True
|
||||
_enabled_category = False
|
||||
_category_movie = None
|
||||
_category_tv = None
|
||||
_category_anime = None
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
self.downloader_qb = Qbittorrent()
|
||||
self.downloader_tr = Transmission()
|
||||
self.downloadhistory_oper = DownloadHistoryOper()
|
||||
self.sites_helper = SitesHelper()
|
||||
# 读取配置
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._onlyonce = config.get("onlyonce")
|
||||
self._interval = config.get("interval") or "计划任务"
|
||||
self._interval_cron = config.get("interval_cron") or "5 4 * * *"
|
||||
self._interval_time = self.str_to_number(config.get("interval_time"), 6)
|
||||
self._interval_unit = config.get("interval_unit") or "小时"
|
||||
self._enabled_media_tag = config.get("enabled_media_tag")
|
||||
self._enabled_tag = config.get("enabled_tag")
|
||||
self._enabled_category = config.get("enabled_category")
|
||||
self._category_movie = config.get("category_movie") or "电影"
|
||||
self._category_tv = config.get("category_tv") or "电视"
|
||||
self._category_anime = config.get("category_anime") or "动漫"
|
||||
if not ("interval_cron" in config):
|
||||
# 新版本v1.6更新插件配置默认配置
|
||||
config["interval"] = self._interval
|
||||
config["interval_cron"] = self._interval_cron
|
||||
config["interval_time"] = self._interval_time
|
||||
config["interval_unit"] = self._interval_unit
|
||||
self.update_config(config)
|
||||
logger.warn(f"{self.LOG_TAG}新版本v{self.plugin_version} 配置修正 ...")
|
||||
|
||||
# 停止现有任务
|
||||
self.stop_service()
|
||||
|
||||
if self._onlyonce:
|
||||
# 创建定时任务控制器
|
||||
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
# 执行一次, 关闭onlyonce
|
||||
self._onlyonce = False
|
||||
config.update({"onlyonce": self._onlyonce})
|
||||
self.update_config(config)
|
||||
# 添加 补全下载历史的标签与分类 任务
|
||||
self._scheduler.add_job(func=self._complemented_history, trigger='date',
|
||||
run_date=datetime.datetime.now(
|
||||
tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3)
|
||||
)
|
||||
|
||||
if self._scheduler and self._scheduler.get_jobs():
|
||||
# 启动服务
|
||||
self._scheduler.print_jobs()
|
||||
self._scheduler.start()
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_service(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
注册插件公共服务
|
||||
[{
|
||||
"id": "服务ID",
|
||||
"name": "服务名称",
|
||||
"trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()",
|
||||
"func": self.xxx,
|
||||
"kwargs": {} # 定时器参数
|
||||
}]
|
||||
"""
|
||||
if self._enabled:
|
||||
if self._interval == "计划任务" or self._interval == "固定间隔":
|
||||
if self._interval == "固定间隔":
|
||||
if self._interval_unit == "小时":
|
||||
return [{
|
||||
"id": "DownloadSiteTag",
|
||||
"name": "补全下载历史的标签与分类",
|
||||
"trigger": "interval",
|
||||
"func": self._complemented_history,
|
||||
"kwargs": {
|
||||
"hours": self._interval_time
|
||||
}
|
||||
}]
|
||||
else:
|
||||
if self._interval_time < 5:
|
||||
self._interval_time = 5
|
||||
logger.info(f"{self.LOG_TAG}启动定时服务: 最小不少于5分钟, 防止执行间隔太短任务冲突")
|
||||
return [{
|
||||
"id": "DownloadSiteTag",
|
||||
"name": "补全下载历史的标签与分类",
|
||||
"trigger": "interval",
|
||||
"func": self._complemented_history,
|
||||
"kwargs": {
|
||||
"minutes": self._interval_time
|
||||
}
|
||||
}]
|
||||
else:
|
||||
return [{
|
||||
"id": "DownloadSiteTag",
|
||||
"name": "补全下载历史的标签与分类",
|
||||
"trigger": CronTrigger.from_crontab(self._interval_cron),
|
||||
"func": self._complemented_history,
|
||||
"kwargs": {}
|
||||
}]
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def str_to_number(s: str, i: int) -> int:
|
||||
try:
|
||||
return int(s)
|
||||
except ValueError:
|
||||
return i
|
||||
|
||||
def _complemented_history(self):
|
||||
"""
|
||||
补全下载历史的标签与分类
|
||||
"""
|
||||
logger.info(f"{self.LOG_TAG}开始执行 ...")
|
||||
# 记录处理的种子, 供辅种(无下载历史)使用
|
||||
dispose_history = {}
|
||||
# 所有站点索引
|
||||
indexers = [indexer.get("name") for indexer in self.sites_helper.get_indexers()]
|
||||
# JackettIndexers索引器支持多个站点, 如果不存在历史记录, 则通过tracker会再次附加其他站点名称
|
||||
indexers.append("JackettIndexers")
|
||||
indexers = set(indexers)
|
||||
tracker_mappings = {
|
||||
"chdbits.xyz": "ptchdbits.co",
|
||||
"agsvpt.trackers.work": "agsvpt.com",
|
||||
"tracker.cinefiles.info": "audiences.me",
|
||||
}
|
||||
for DOWNLOADER in ["qbittorrent", "transmission"]:
|
||||
logger.info(f"{self.LOG_TAG}开始扫描下载器 {DOWNLOADER} ...")
|
||||
# 获取下载器中的种子
|
||||
downloader_obj = self._get_downloader(DOWNLOADER)
|
||||
if not downloader_obj:
|
||||
logger.error(f"{self.LOG_TAG} 获取下载器失败 {DOWNLOADER}")
|
||||
continue
|
||||
torrents, error = downloader_obj.get_torrents()
|
||||
# 如果下载器获取种子发生错误 或 没有种子 则跳过
|
||||
if error or not torrents:
|
||||
continue
|
||||
logger.info(f"{self.LOG_TAG}按时间重新排序 {DOWNLOADER} 种子数:{len(torrents)}")
|
||||
# 按添加时间进行排序, 时间靠前的按大小和名称加入处理历史, 判定为原始种子, 其他为辅种
|
||||
torrents = self._torrents_sort(torrents=torrents, dl_type=DOWNLOADER)
|
||||
logger.info(f"{self.LOG_TAG}下载器 {DOWNLOADER} 分析种子信息中 ...")
|
||||
for torrent in torrents:
|
||||
try:
|
||||
if self._event.is_set():
|
||||
logger.info(
|
||||
f"{self.LOG_TAG}停止服务")
|
||||
return
|
||||
# 获取已处理种子的key (size, name)
|
||||
_key = self._torrent_key(torrent=torrent, dl_type=DOWNLOADER)
|
||||
# 获取种子hash
|
||||
_hash = self._get_hash(torrent=torrent, dl_type=DOWNLOADER)
|
||||
if not _hash:
|
||||
continue
|
||||
# 获取种子当前标签
|
||||
torrent_tags = self._get_label(torrent=torrent, dl_type=DOWNLOADER)
|
||||
torrent_cat = self._get_category(torrent=torrent, dl_type=DOWNLOADER)
|
||||
# 提取种子hash对应的下载历史
|
||||
history: DownloadHistory = self.downloadhistory_oper.get_by_hash(_hash)
|
||||
if not history:
|
||||
# 如果找到已处理种子的历史, 表明当前种子是辅种, 否则创建一个空DownloadHistory
|
||||
if _key and _key in dispose_history:
|
||||
history = dispose_history[_key]
|
||||
# 因为辅种站点必定不同, 所以需要更新站点名字 history.torrent_site
|
||||
history.torrent_site = None
|
||||
else:
|
||||
history = DownloadHistory()
|
||||
else:
|
||||
# 加入历史记录
|
||||
if _key:
|
||||
dispose_history[_key] = history
|
||||
# 如果标签已经存在任意站点, 则不再添加站点标签
|
||||
if indexers.intersection(set(torrent_tags)):
|
||||
history.torrent_site = None
|
||||
# 如果站点名称为空, 尝试通过trackers识别
|
||||
elif not history.torrent_site:
|
||||
trackers = self._get_trackers(torrent=torrent, dl_type=DOWNLOADER)
|
||||
for tracker in trackers:
|
||||
# 检查tracker是否包含特定的关键字,并进行相应的映射
|
||||
for key, mapped_domain in tracker_mappings.items():
|
||||
if key in tracker:
|
||||
domain = mapped_domain
|
||||
break
|
||||
else:
|
||||
domain = StringUtils.get_url_domain(tracker)
|
||||
site_info = self.sites_helper.get_indexer(domain)
|
||||
if site_info:
|
||||
history.torrent_site = site_info.get("name")
|
||||
break
|
||||
# 如果通过tracker还是无法获取站点名称, 且tmdbid, type, title都是空的, 那么跳过当前种子
|
||||
if not history.torrent_site and not history.tmdbid and not history.type and not history.title:
|
||||
continue
|
||||
# 按设置生成需要写入的标签与分类
|
||||
_tags = []
|
||||
_cat = None
|
||||
# 站点标签, 如果勾选开关的话 因允许torrent_site为空时运行到此, 因此需要判断torrent_site不为空
|
||||
if self._enabled_tag and history.torrent_site:
|
||||
_tags.append(history.torrent_site)
|
||||
# 媒体标题标签, 如果勾选开关的话 因允许title为空时运行到此, 因此需要判断title不为空
|
||||
if self._enabled_media_tag and history.title:
|
||||
_tags.append(history.title)
|
||||
# 分类, 如果勾选开关的话 <tr暂不支持> 因允许mtype为空时运行到此, 因此需要判断mtype不为空。为防止不必要的识别, 种子已经存在分类torrent_cat时 也不执行
|
||||
if DOWNLOADER == "qbittorrent" and self._enabled_category and not torrent_cat and history.type:
|
||||
# 如果是电视剧 需要区分是否动漫
|
||||
genre_ids = None
|
||||
# 因允许tmdbid为空时运行到此, 因此需要判断tmdbid不为空
|
||||
history_type = MediaType(history.type) if history.type else None
|
||||
if history.tmdbid and history_type == MediaType.TV:
|
||||
# tmdb_id获取tmdb信息
|
||||
tmdb_info = self.chain.tmdb_info(mtype=history_type, tmdbid=history.tmdbid)
|
||||
if tmdb_info:
|
||||
genre_ids = tmdb_info.get("genre_ids")
|
||||
_cat = self._genre_ids_get_cat(history.type, genre_ids)
|
||||
|
||||
# 去除种子已经存在的标签
|
||||
if _tags and torrent_tags:
|
||||
_tags = list(set(_tags) - set(torrent_tags))
|
||||
# 如果分类一样, 那么不需要修改
|
||||
if _cat == torrent_cat:
|
||||
_cat = None
|
||||
# 判断当前种子是否不需要修改
|
||||
if not _cat and not _tags:
|
||||
continue
|
||||
# 执行通用方法, 设置种子标签与分类
|
||||
self._set_torrent_info(DOWNLOADER=DOWNLOADER, _hash=_hash, _torrent=torrent, _tags=_tags, _cat=_cat,
|
||||
_original_tags=torrent_tags)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"{self.LOG_TAG}分析种子信息时发生了错误: {str(e)}")
|
||||
|
||||
logger.info(f"{self.LOG_TAG}执行完成")
|
||||
|
||||
def _genre_ids_get_cat(self, mtype, genre_ids=None):
|
||||
"""
|
||||
根据genre_ids判断是否<动漫>分类
|
||||
"""
|
||||
_cat = None
|
||||
if mtype == MediaType.MOVIE or mtype == MediaType.MOVIE.value:
|
||||
# 电影
|
||||
_cat = self._category_movie
|
||||
elif mtype:
|
||||
ANIME_GENREIDS = settings.ANIME_GENREIDS
|
||||
if genre_ids \
|
||||
and set(genre_ids).intersection(set(ANIME_GENREIDS)):
|
||||
# 动漫
|
||||
_cat = self._category_anime
|
||||
else:
|
||||
# 电视剧
|
||||
_cat = self._category_tv
|
||||
return _cat
|
||||
|
||||
def _get_downloader(self, dtype: str):
|
||||
"""
|
||||
根据类型返回下载器实例
|
||||
"""
|
||||
if dtype == "qbittorrent":
|
||||
return self.downloader_qb
|
||||
elif dtype == "transmission":
|
||||
return self.downloader_tr
|
||||
else:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _torrent_key(torrent: Any, dl_type: str) -> Optional[Tuple[int, str]]:
|
||||
"""
|
||||
按种子大小和时间返回key
|
||||
"""
|
||||
if dl_type == "qbittorrent":
|
||||
size = torrent.get('size')
|
||||
name = torrent.get('name')
|
||||
else:
|
||||
size = torrent.total_size
|
||||
name = torrent.name
|
||||
if not size or not name:
|
||||
return None
|
||||
else:
|
||||
return size, name
|
||||
|
||||
@staticmethod
|
||||
def _torrents_sort(torrents: Any, dl_type: str):
|
||||
"""
|
||||
按种子添加时间排序
|
||||
"""
|
||||
if dl_type == "qbittorrent":
|
||||
torrents = sorted(torrents, key=lambda x: x.get("added_on"), reverse=False)
|
||||
else:
|
||||
torrents = sorted(torrents, key=lambda x: x.added_date, reverse=False)
|
||||
return torrents
|
||||
|
||||
@staticmethod
|
||||
def _get_hash(torrent: Any, dl_type: str):
|
||||
"""
|
||||
获取种子hash
|
||||
"""
|
||||
try:
|
||||
return torrent.get("hash") if dl_type == "qbittorrent" else torrent.hashString
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _get_trackers(torrent: Any, dl_type: str):
|
||||
"""
|
||||
获取种子trackers
|
||||
"""
|
||||
try:
|
||||
if dl_type == "qbittorrent":
|
||||
"""
|
||||
url 字符串 跟踪器网址
|
||||
status 整数 跟踪器状态。有关可能的值,请参阅下表
|
||||
tier 整数 跟踪器优先级。较低级别的跟踪器在较高级别的跟踪器之前试用。当特殊条目(如 DHT)不存在时,层号用作占位符时,层号有效。>= 0< 0tier
|
||||
num_peers 整数 跟踪器报告的当前 torrent 的对等体数量
|
||||
num_seeds 整数 当前种子的种子数,由跟踪器报告
|
||||
num_leeches 整数 当前种子的水蛭数量,如跟踪器报告的那样
|
||||
num_downloaded 整数 跟踪器报告的当前 torrent 的已完成下载次数
|
||||
msg 字符串 跟踪器消息(无法知道此消息是什么 - 由跟踪器管理员决定)
|
||||
"""
|
||||
return [tracker.get("url") for tracker in (torrent.trackers or []) if
|
||||
tracker.get("tier", -1) >= 0 and tracker.get("url")]
|
||||
else:
|
||||
"""
|
||||
class Tracker(Container):
|
||||
@property
|
||||
def id(self) -> int:
|
||||
return self.fields["id"]
|
||||
|
||||
@property
|
||||
def announce(self) -> str:
|
||||
return self.fields["announce"]
|
||||
|
||||
@property
|
||||
def scrape(self) -> str:
|
||||
return self.fields["scrape"]
|
||||
|
||||
@property
|
||||
def tier(self) -> int:
|
||||
return self.fields["tier"]
|
||||
"""
|
||||
return [tracker.announce for tracker in (torrent.trackers or []) if
|
||||
tracker.tier >= 0 and tracker.announce]
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _get_label(torrent: Any, dl_type: str):
|
||||
"""
|
||||
获取种子标签
|
||||
"""
|
||||
try:
|
||||
return [str(tag).strip() for tag in torrent.get("tags", "").split(',')] \
|
||||
if dl_type == "qbittorrent" else torrent.labels or []
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _get_category(torrent: Any, dl_type: str):
|
||||
"""
|
||||
获取种子分类
|
||||
"""
|
||||
try:
|
||||
return torrent.get("category") if dl_type == "qbittorrent" else None
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return None
|
||||
|
||||
def _set_torrent_info(self, DOWNLOADER: str, _hash: str, _torrent: Any = None, _tags=None, _cat: str = None,
|
||||
_original_tags: list = None):
|
||||
"""
|
||||
设置种子标签与分类
|
||||
"""
|
||||
# 当前下载器
|
||||
if _tags is None:
|
||||
_tags = []
|
||||
downloader_obj = self._get_downloader(DOWNLOADER)
|
||||
if not _torrent:
|
||||
_torrent, error = downloader_obj.get_torrents(ids=_hash)
|
||||
if not _torrent or error:
|
||||
logger.error(
|
||||
f"{self.LOG_TAG}设置种子标签与分类时发生了错误: 通过 {_hash} 查询不到任何种子!")
|
||||
return
|
||||
logger.info(
|
||||
f"{self.LOG_TAG}设置种子标签与分类: {_hash} 查询到 {len(_torrent)} 个种子")
|
||||
_torrent = _torrent[0]
|
||||
# 判断是否可执行
|
||||
if DOWNLOADER and downloader_obj and _hash and _torrent:
|
||||
# 下载器api不通用, 因此需分开处理
|
||||
if DOWNLOADER == "qbittorrent":
|
||||
# 设置标签
|
||||
if _tags:
|
||||
downloader_obj.set_torrents_tag(ids=_hash, tags=_tags)
|
||||
# 设置分类 <tr暂不支持>
|
||||
if _cat:
|
||||
# 尝试设置种子分类, 如果失败, 则创建再设置一遍
|
||||
try:
|
||||
_torrent.setCategory(category=_cat)
|
||||
except Exception as e:
|
||||
logger.warn(f"下载器 {DOWNLOADER} 种子id: {_hash} 设置分类 {_cat} 失败:{str(e)}, "
|
||||
f"尝试创建分类再设置 ...")
|
||||
downloader_obj.qbc.torrents_createCategory(name=_cat)
|
||||
_torrent.setCategory(category=_cat)
|
||||
else:
|
||||
# 设置标签
|
||||
if _tags:
|
||||
# _original_tags = None表示未指定, 因此需要获取原始标签
|
||||
if _original_tags is None:
|
||||
_original_tags = self._get_label(torrent=_torrent, dl_type=DOWNLOADER)
|
||||
# 如果原始标签不是空的, 那么合并原始标签
|
||||
if _original_tags:
|
||||
_tags = list(set(_original_tags).union(set(_tags)))
|
||||
downloader_obj.set_torrent_tag(ids=_hash, tags=_tags)
|
||||
logger.warn(
|
||||
f"{self.LOG_TAG}下载器: {DOWNLOADER} 种子id: {_hash} {(' 标签: ' + ','.join(_tags)) if _tags else ''} {(' 分类: ' + _cat) if _cat else ''}")
|
||||
|
||||
@eventmanager.register(EventType.DownloadAdded)
|
||||
def DownloadAdded(self, event: Event):
|
||||
"""
|
||||
添加下载事件
|
||||
"""
|
||||
if not self.get_state():
|
||||
return
|
||||
|
||||
if not event.event_data:
|
||||
return
|
||||
|
||||
try:
|
||||
context: Context = event.event_data.get("context")
|
||||
_hash = event.event_data.get("hash")
|
||||
_torrent = context.torrent_info
|
||||
_media = context.media_info
|
||||
_tags = []
|
||||
_cat = None
|
||||
# 站点标签, 如果勾选开关的话
|
||||
if self._enabled_tag and _torrent.site_name:
|
||||
_tags.append(_torrent.site_name)
|
||||
# 媒体标题标签, 如果勾选开关的话
|
||||
if self._enabled_media_tag and _media.title:
|
||||
_tags.append(_media.title)
|
||||
# 分类, 如果勾选开关的话 <tr暂不支持>
|
||||
if self._enabled_category and _media.type:
|
||||
_cat = self._genre_ids_get_cat(_media.type, _media.genre_ids)
|
||||
if _hash and (_tags or _cat):
|
||||
# 执行通用方法, 设置种子标签与分类
|
||||
self._set_torrent_info(DOWNLOADER=settings.DEFAULT_DOWNLOADER, _hash=_hash, _tags=_tags, _cat=_cat)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"{self.LOG_TAG}分析下载事件时发生了错误: {str(e)}")
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 3
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enabled',
|
||||
'label': '启用插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 3
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCheckboxBtn',
|
||||
'props': {
|
||||
'model': 'enabled_tag',
|
||||
'label': '自动站点标签',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 3
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCheckboxBtn',
|
||||
'props': {
|
||||
'model': 'enabled_media_tag',
|
||||
'label': '自动剧名标签',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 3
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCheckboxBtn',
|
||||
'props': {
|
||||
'model': 'enabled_category',
|
||||
'label': '自动设置分类',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 12
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCheckboxBtn',
|
||||
'props': {
|
||||
'model': 'onlyonce',
|
||||
'label': '补全下载历史的标签与分类(一次性任务)'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 3
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSelect',
|
||||
'props': {
|
||||
'model': 'interval',
|
||||
'label': '定时任务',
|
||||
'items': [
|
||||
{'title': '禁用', 'value': '禁用'},
|
||||
{'title': '计划任务', 'value': '计划任务'},
|
||||
{'title': '固定间隔', 'value': '固定间隔'}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 3,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'interval_cron',
|
||||
'label': '计划任务设置',
|
||||
'placeholder': '5 4 * * *'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 6,
|
||||
'md': 3,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'interval_time',
|
||||
'label': '固定间隔设置, 间隔每',
|
||||
'placeholder': '6'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 6,
|
||||
'md': 3,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSelect',
|
||||
'props': {
|
||||
'model': 'interval_unit',
|
||||
'label': '单位',
|
||||
'items': [
|
||||
{'title': '小时', 'value': '小时'},
|
||||
{'title': '分钟', 'value': '分钟'}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'category_movie',
|
||||
'label': '电影分类名称(默认: 电影)',
|
||||
'placeholder': '电影'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'category_tv',
|
||||
'label': '电视分类名称(默认: 电视)',
|
||||
'placeholder': '电视'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'category_anime',
|
||||
'label': '动漫分类名称(默认: 动漫)',
|
||||
'placeholder': '动漫'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '定时任务:支持两种定时方式,主要针对辅种刷流等种子补全站点信息。如没有对应的需求建议切换为禁用。'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"onlyonce": False,
|
||||
"enabled_tag": True,
|
||||
"enabled_media_tag": False,
|
||||
"enabled_category": False,
|
||||
"category_movie": "电影",
|
||||
"category_tv": "电视",
|
||||
"category_anime": "动漫",
|
||||
"interval": "计划任务",
|
||||
"interval_cron": "5 4 * * *",
|
||||
"interval_time": "6",
|
||||
"interval_unit": "小时"
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
停止服务
|
||||
"""
|
||||
try:
|
||||
if self._scheduler:
|
||||
self._scheduler.remove_all_jobs()
|
||||
if self._scheduler.running:
|
||||
self._event.set()
|
||||
self._scheduler.shutdown()
|
||||
self._event.clear()
|
||||
self._scheduler = None
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
872
plugins.v2/episodegroupmeta/__init__.py
Normal file
872
plugins.v2/episodegroupmeta/__init__.py
Normal file
@@ -0,0 +1,872 @@
|
||||
import base64
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Dict, Tuple, Optional, Union
|
||||
|
||||
from pydantic import BaseModel
|
||||
from requests import RequestException
|
||||
|
||||
from app import schemas
|
||||
from app.chain.mediaserver import MediaServerChain
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.core.meta import MetaBase
|
||||
from app.log import logger
|
||||
from app.modules.emby import Emby
|
||||
from app.modules.jellyfin import Jellyfin
|
||||
from app.modules.plex import Plex
|
||||
from app.modules.themoviedb.tmdbv3api import TV
|
||||
from app.plugins import _PluginBase
|
||||
from app.schemas.types import EventType
|
||||
from app.utils.common import retry
|
||||
from app.utils.http import RequestUtils
|
||||
|
||||
|
||||
class ExistMediaInfo(BaseModel):
|
||||
# 类型 电影、电视剧
|
||||
type: Optional[schemas.MediaType]
|
||||
# 季, 集
|
||||
groupep: Optional[Dict[int, list]] = {}
|
||||
# 集在媒体服务器的ID
|
||||
groupid: Optional[Dict[int, List[list]]] = {}
|
||||
# 媒体服务器
|
||||
server: Optional[str] = None
|
||||
# 媒体ID
|
||||
itemid: Optional[Union[str, int]] = None
|
||||
|
||||
|
||||
class EpisodeGroupMeta(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "TMDB剧集组刮削"
|
||||
# 插件描述
|
||||
plugin_desc = "从TMDB剧集组刮削季集的实际顺序。"
|
||||
# 插件图标
|
||||
plugin_icon = "Element_A.png"
|
||||
# 主题色
|
||||
plugin_color = "#098663"
|
||||
# 插件版本
|
||||
plugin_version = "1.1"
|
||||
# 插件作者
|
||||
plugin_author = "叮叮当"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/cikezhu"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "EpisodeGroupMeta_"
|
||||
# 加载顺序
|
||||
plugin_order = 29
|
||||
# 可使用的用户级别
|
||||
auth_level = 1
|
||||
|
||||
# 退出事件
|
||||
_event = threading.Event()
|
||||
|
||||
# 私有属性
|
||||
mschain = None
|
||||
tv = None
|
||||
emby = None
|
||||
plex = None
|
||||
jellyfin = None
|
||||
|
||||
_enabled = False
|
||||
_ignorelock = False
|
||||
_delay = 0
|
||||
_allowlist = []
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
self.mschain = MediaServerChain()
|
||||
self.tv = TV()
|
||||
self.emby = Emby()
|
||||
self.plex = Plex()
|
||||
self.jellyfin = Jellyfin()
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._ignorelock = config.get("ignorelock")
|
||||
self._delay = config.get("delay") or 120
|
||||
self._allowlist = []
|
||||
for s in str(config.get("allowlist", "")).split(","):
|
||||
s = s.strip()
|
||||
if s and s not in self._allowlist:
|
||||
self._allowlist.append(s)
|
||||
self.log_info(f"白名单数量: {len(self._allowlist)} > {self._allowlist}")
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enabled',
|
||||
'label': '启用插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'ignorelock',
|
||||
'label': '媒体信息锁定时也进行刮削',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'delay',
|
||||
'label': '入库延迟时间(秒)',
|
||||
'placeholder': '120'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextarea',
|
||||
'props': {
|
||||
'model': 'allowlist',
|
||||
'label': '刮削白名单',
|
||||
'rows': 6,
|
||||
'placeholder': '使用,分隔电视剧名称'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '注意:刮削白名单(留空), 则全部刮削. 否则仅刮削白名单.'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '注意:如需刮削已经入库的项目, 可通过mp重新整理单集即可.'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"ignorelock": False,
|
||||
"allowlist": "",
|
||||
"delay": 120
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
@eventmanager.register(EventType.TransferComplete)
|
||||
def scrap_rt(self, event: Event):
|
||||
"""
|
||||
根据事件实时刮削剧集组信息
|
||||
"""
|
||||
if not self.get_state():
|
||||
return
|
||||
# 事件数据
|
||||
mediainfo: schemas.MediaInfo = event.event_data.get("mediainfo")
|
||||
meta: MetaBase = event.event_data.get("meta")
|
||||
# self.log_error(f"{event.event_data}")
|
||||
if not mediainfo or not meta:
|
||||
return
|
||||
# 非TV类型不处理
|
||||
if mediainfo.type != schemas.MediaType.TV:
|
||||
self.log_warn(f"{mediainfo.title} 非TV类型, 无需处理")
|
||||
return
|
||||
# 没有tmdbID不处理
|
||||
if not mediainfo.tmdb_id:
|
||||
self.log_warn(f"{mediainfo.title} 没有tmdbID, 无需处理")
|
||||
return
|
||||
if len(self._allowlist) != 0 \
|
||||
and mediainfo.title not in self._allowlist:
|
||||
self.log_warn(f"{mediainfo.title} 不在白名单, 无需处理")
|
||||
return
|
||||
# 获取剧集组信息
|
||||
try:
|
||||
episode_groups = self.tv.episode_groups(mediainfo.tmdb_id)
|
||||
if not episode_groups:
|
||||
self.log_warn(f"{mediainfo.title} 没有剧集组, 无需处理")
|
||||
return
|
||||
self.log_info(f"{mediainfo.title_year} 剧集组数量: {len(episode_groups)} - {episode_groups}")
|
||||
# episodegroup = self.tv.group_episodes(episode_groups[0].get('id'))
|
||||
except Exception as e:
|
||||
self.log_error(f"{mediainfo.title} {str(e)}")
|
||||
return
|
||||
# 延迟
|
||||
if self._delay:
|
||||
self.log_warn(f"{mediainfo.title} 将在 {self._delay} 秒后开始处理..")
|
||||
time.sleep(int(self._delay))
|
||||
# 获取可用的媒体服务器
|
||||
_existsinfo = self.chain.media_exists(mediainfo=mediainfo)
|
||||
existsinfo: ExistMediaInfo = self.__media_exists(server=_existsinfo.server, mediainfo=mediainfo,
|
||||
existsinfo=_existsinfo)
|
||||
if not existsinfo or not existsinfo.itemid:
|
||||
self.log_warn(f"{mediainfo.title_year} 在媒体库中不存在")
|
||||
return
|
||||
# 新增需要的属性
|
||||
existsinfo.server = _existsinfo.server
|
||||
existsinfo.type = _existsinfo.type
|
||||
self.log_info(f"{mediainfo.title_year} 存在于媒体服务器: {_existsinfo.server}")
|
||||
# 获取全部剧集组信息
|
||||
copy_keys = ['Id', 'Name', 'ChannelNumber', 'OriginalTitle', 'ForcedSortName', 'SortName', 'CommunityRating',
|
||||
'CriticRating', 'IndexNumber', 'ParentIndexNumber', 'SortParentIndexNumber', 'SortIndexNumber',
|
||||
'DisplayOrder', 'Album', 'AlbumArtists', 'ArtistItems', 'Overview', 'Status', 'Genres', 'Tags',
|
||||
'TagItems', 'Studios', 'PremiereDate', 'DateCreated', 'ProductionYear', 'Video3DFormat',
|
||||
'OfficialRating', 'CustomRating', 'People', 'LockData', 'LockedFields', 'ProviderIds',
|
||||
'PreferredMetadataLanguage', 'PreferredMetadataCountryCode', 'Taglines']
|
||||
for episode_group in episode_groups:
|
||||
if not bool(existsinfo.groupep):
|
||||
break
|
||||
try:
|
||||
id = episode_group.get('id')
|
||||
name = episode_group.get('name')
|
||||
if not id:
|
||||
continue
|
||||
# 处理
|
||||
self.log_info(f"正在匹配剧集组: {id}")
|
||||
groups_meta = self.tv.group_episodes(id)
|
||||
if not groups_meta:
|
||||
continue
|
||||
for groups in groups_meta:
|
||||
if not bool(existsinfo.groupep):
|
||||
break
|
||||
# 剧集组中的季
|
||||
order = groups.get("order")
|
||||
# 剧集组中的集列表
|
||||
episodes = groups.get("episodes")
|
||||
if not order or not episodes or len(episodes) == 0:
|
||||
continue
|
||||
# 进行集数匹配, 确定剧集组信息
|
||||
ep = existsinfo.groupep.get(order)
|
||||
if not ep or len(ep) != len(episodes):
|
||||
continue
|
||||
self.log_info(f"已匹配剧集组: {name}, {id}, 第 {order} 季")
|
||||
# 遍历全部媒体项并更新
|
||||
for _index, _ids in enumerate(existsinfo.groupid.get(order)):
|
||||
# 提取出媒体库中集id对应的集数index
|
||||
ep_num = ep[_index]
|
||||
for _id in _ids:
|
||||
# 获取媒体服务器媒体项
|
||||
iteminfo = self.get_iteminfo(server=existsinfo.server, itemid=_id)
|
||||
if not iteminfo:
|
||||
self.log_info(f"未找到媒体项 - itemid: {_id}, 第 {order} 季, 第 {ep_num} 集")
|
||||
continue
|
||||
# 是否无视项目锁定
|
||||
if not self._ignorelock:
|
||||
if iteminfo.get("LockData") or (
|
||||
"Name" in iteminfo.get("LockedFields", [])
|
||||
and "Overview" in iteminfo.get("LockedFields", [])):
|
||||
self.log_warn(f"已锁定媒体项 - itemid: {_id}, 第 {order} 季, 第 {ep_num} 集")
|
||||
continue
|
||||
# 替换项目数据
|
||||
episode = episodes[ep_num - 1]
|
||||
new_dict = {}
|
||||
new_dict.update({k: v for k, v in iteminfo.items() if k in copy_keys})
|
||||
new_dict["Name"] = episode["name"]
|
||||
new_dict["Overview"] = episode["overview"]
|
||||
new_dict["ParentIndexNumber"] = str(order)
|
||||
new_dict["IndexNumber"] = str(ep_num)
|
||||
new_dict["LockData"] = True
|
||||
if episode.get("vote_average"):
|
||||
new_dict["CommunityRating"] = episode.get("vote_average")
|
||||
if not new_dict["LockedFields"]:
|
||||
new_dict["LockedFields"] = []
|
||||
self.__append_to_list(new_dict["LockedFields"], "Name")
|
||||
self.__append_to_list(new_dict["LockedFields"], "Overview")
|
||||
# 更新数据
|
||||
self.set_iteminfo(server=existsinfo.server, itemid=_id, iteminfo=new_dict)
|
||||
# still_path 图片
|
||||
if episode.get("still_path"):
|
||||
self.set_item_image(server=existsinfo.server, itemid=_id,
|
||||
imageurl=f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{episode['still_path']}")
|
||||
self.log_info(f"已修改剧集 - itemid: {_id}, 第 {order} 季, 第 {ep_num} 集")
|
||||
# 移除已经处理成功的季
|
||||
existsinfo.groupep.pop(order, 0)
|
||||
existsinfo.groupid.pop(order, 0)
|
||||
continue
|
||||
except Exception as e:
|
||||
self.log_warn(f"错误忽略: {str(e)}")
|
||||
continue
|
||||
|
||||
self.log_info(f"{mediainfo.title_year} 已经运行完毕了..")
|
||||
|
||||
@staticmethod
|
||||
def __append_to_list(list, item):
|
||||
if item not in list:
|
||||
list.append(item)
|
||||
|
||||
def __media_exists(self, server: str, mediainfo: schemas.MediaInfo,
|
||||
existsinfo: schemas.ExistMediaInfo) -> ExistMediaInfo:
|
||||
"""
|
||||
根据媒体信息,返回剧集列表与剧集ID列表
|
||||
:param mediainfo: 媒体信息
|
||||
:return: 剧集列表与剧集ID列表
|
||||
"""
|
||||
|
||||
def __emby_media_exists():
|
||||
# 获取系列id
|
||||
item_id = None
|
||||
try:
|
||||
res = self.emby.get_data(("[HOST]emby/Items?"
|
||||
"IncludeItemTypes=Series"
|
||||
"&Fields=ProductionYear"
|
||||
"&StartIndex=0"
|
||||
"&Recursive=true"
|
||||
"&SearchTerm=%s"
|
||||
"&Limit=10"
|
||||
"&IncludeSearchTypes=false"
|
||||
"&api_key=[APIKEY]") % mediainfo.title)
|
||||
res_items = res.json().get("Items")
|
||||
if res_items:
|
||||
for res_item in res_items:
|
||||
if res_item.get('Name') == mediainfo.title and (
|
||||
not mediainfo.year or str(res_item.get('ProductionYear')) == str(mediainfo.year)):
|
||||
item_id = res_item.get('Id')
|
||||
except Exception as e:
|
||||
self.log_error(f"连接Items出错:" + str(e))
|
||||
if not item_id:
|
||||
return None
|
||||
# 验证tmdbid是否相同
|
||||
item_info = self.emby.get_iteminfo(item_id)
|
||||
if item_info:
|
||||
if mediainfo.tmdb_id and item_info.tmdbid:
|
||||
if str(mediainfo.tmdb_id) != str(item_info.tmdbid):
|
||||
self.log_error(f"tmdbid不匹配或不存在")
|
||||
return None
|
||||
try:
|
||||
res_json = self.emby.get_data(
|
||||
"[HOST]emby/Shows/%s/Episodes?Season=&IsMissing=false&api_key=[APIKEY]" % item_id)
|
||||
if res_json:
|
||||
tv_item = res_json.json()
|
||||
res_items = tv_item.get("Items")
|
||||
group_ep = {}
|
||||
group_id = {}
|
||||
for res_item in res_items:
|
||||
season_index = res_item.get("ParentIndexNumber")
|
||||
if not season_index:
|
||||
continue
|
||||
episode_index = res_item.get("IndexNumber")
|
||||
if not episode_index:
|
||||
continue
|
||||
if season_index not in group_ep:
|
||||
group_ep[season_index] = []
|
||||
group_id[season_index] = []
|
||||
if episode_index not in group_ep[season_index]:
|
||||
group_ep[season_index].append(episode_index)
|
||||
group_id[season_index].append([])
|
||||
# 找到准确的插入索引
|
||||
_index = group_ep[season_index].index(episode_index)
|
||||
if res_item.get("Id") not in group_id[season_index][_index]:
|
||||
group_id[season_index][_index].append(res_item.get("Id"))
|
||||
# 返回
|
||||
return ExistMediaInfo(
|
||||
itemid=item_id,
|
||||
groupep=group_ep,
|
||||
groupid=group_id
|
||||
)
|
||||
except Exception as e:
|
||||
self.log_error(f"连接Shows/Id/Episodes出错:{str(e)}")
|
||||
return None
|
||||
|
||||
def __jellyfin_media_exists():
|
||||
# 获取系列id
|
||||
item_id = None
|
||||
try:
|
||||
res = self.jellyfin.get_data(url=f"[HOST]Users/[USER]/Items?api_key=[APIKEY]"
|
||||
f"&searchTerm={mediainfo.title}"
|
||||
f"&IncludeItemTypes=Series"
|
||||
f"&Limit=10&Recursive=true")
|
||||
res_items = res.json().get("Items")
|
||||
if res_items:
|
||||
for res_item in res_items:
|
||||
if res_item.get('Name') == mediainfo.title and (
|
||||
not mediainfo.year or str(res_item.get('ProductionYear')) == str(mediainfo.year)):
|
||||
item_id = res_item.get('Id')
|
||||
except Exception as e:
|
||||
self.log_error(f"连接Items出错:" + str(e))
|
||||
if not item_id:
|
||||
return None
|
||||
# 验证tmdbid是否相同
|
||||
item_info = self.jellyfin.get_iteminfo(item_id)
|
||||
if item_info:
|
||||
if mediainfo.tmdb_id and item_info.tmdbid:
|
||||
if str(mediainfo.tmdb_id) != str(item_info.tmdbid):
|
||||
self.log_error(f"tmdbid不匹配或不存在")
|
||||
return None
|
||||
try:
|
||||
res_json = self.jellyfin.get_data(
|
||||
"[HOST]emby/Shows/%s/Episodes?Season=&IsMissing=false&api_key=[APIKEY]" % item_id)
|
||||
if res_json:
|
||||
tv_item = res_json.json()
|
||||
res_items = tv_item.get("Items")
|
||||
group_ep = {}
|
||||
group_id = {}
|
||||
for res_item in res_items:
|
||||
season_index = res_item.get("ParentIndexNumber")
|
||||
if not season_index:
|
||||
continue
|
||||
episode_index = res_item.get("IndexNumber")
|
||||
if not episode_index:
|
||||
continue
|
||||
if season_index not in group_ep:
|
||||
group_ep[season_index] = []
|
||||
group_id[season_index] = []
|
||||
if episode_index not in group_ep[season_index]:
|
||||
group_ep[season_index].append(episode_index)
|
||||
group_id[season_index].append([])
|
||||
# 找到准确的插入索引
|
||||
_index = group_ep[season_index].index(episode_index)
|
||||
if res_item.get("Id") not in group_id[season_index][_index]:
|
||||
group_id[season_index][_index].append(res_item.get("Id"))
|
||||
# 返回
|
||||
return ExistMediaInfo(
|
||||
itemid=item_id,
|
||||
groupep=group_ep,
|
||||
groupid=group_id
|
||||
)
|
||||
except Exception as e:
|
||||
self.log_error(f"连接Shows/Id/Episodes出错:{str(e)}")
|
||||
return None
|
||||
|
||||
def __plex_media_exists():
|
||||
try:
|
||||
_plex = self.plex.get_plex()
|
||||
if not _plex:
|
||||
return None
|
||||
if existsinfo.itemid:
|
||||
videos = _plex.fetchItem(existsinfo.itemid)
|
||||
else:
|
||||
# 根据标题和年份模糊搜索,该结果不够准确
|
||||
videos = _plex.library.search(title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
libtype="show")
|
||||
if (not videos
|
||||
and mediainfo.original_title
|
||||
and str(mediainfo.original_title) != str(mediainfo.title)):
|
||||
videos = _plex.library.search(title=mediainfo.original_title,
|
||||
year=mediainfo.year,
|
||||
libtype="show")
|
||||
if not videos:
|
||||
return None
|
||||
if isinstance(videos, list):
|
||||
videos = videos[0]
|
||||
video_tmdbid = __get_ids(videos.guids).get('tmdb_id')
|
||||
if mediainfo.tmdb_id and video_tmdbid:
|
||||
if str(video_tmdbid) != str(mediainfo.tmdb_id):
|
||||
self.log_error(f"tmdbid不匹配或不存在")
|
||||
return None
|
||||
episodes = videos.episodes()
|
||||
group_ep = {}
|
||||
group_id = {}
|
||||
for episode in episodes:
|
||||
season_index = episode.seasonNumber
|
||||
if not season_index:
|
||||
continue
|
||||
episode_index = episode.index
|
||||
if not episode_index:
|
||||
continue
|
||||
episode_id = episode.key
|
||||
if not episode_id:
|
||||
continue
|
||||
if season_index not in group_ep:
|
||||
group_ep[season_index] = []
|
||||
group_id[season_index] = []
|
||||
if episode_index not in group_ep[season_index]:
|
||||
group_ep[season_index].append(episode_index)
|
||||
group_id[season_index].append([])
|
||||
# 找到准确的插入索引
|
||||
_index = group_ep[season_index].index(episode_index)
|
||||
if episode_id not in group_id[season_index][_index]:
|
||||
group_id[season_index][_index].append(episode_id)
|
||||
# 返回
|
||||
return ExistMediaInfo(
|
||||
itemid=videos.key,
|
||||
groupep=group_ep,
|
||||
groupid=group_id
|
||||
)
|
||||
except Exception as e:
|
||||
self.log_error(f"连接Shows/Id/Episodes出错:{str(e)}")
|
||||
return None
|
||||
|
||||
def __get_ids(guids: List[Any]) -> dict:
|
||||
guid_mapping = {
|
||||
"imdb://": "imdb_id",
|
||||
"tmdb://": "tmdb_id",
|
||||
"tvdb://": "tvdb_id"
|
||||
}
|
||||
ids = {}
|
||||
for prefix, varname in guid_mapping.items():
|
||||
ids[varname] = None
|
||||
for guid in guids:
|
||||
for prefix, varname in guid_mapping.items():
|
||||
if isinstance(guid, dict):
|
||||
if guid['id'].startswith(prefix):
|
||||
# 找到匹配的ID
|
||||
ids[varname] = guid['id'][len(prefix):]
|
||||
break
|
||||
else:
|
||||
if guid.id.startswith(prefix):
|
||||
# 找到匹配的ID
|
||||
ids[varname] = guid.id[len(prefix):]
|
||||
break
|
||||
return ids
|
||||
|
||||
if server == "emby":
|
||||
return __emby_media_exists()
|
||||
elif server == "jellyfin":
|
||||
return __jellyfin_media_exists()
|
||||
else:
|
||||
return __plex_media_exists()
|
||||
|
||||
def get_iteminfo(self, server: str, itemid: str) -> dict:
|
||||
"""
|
||||
获得媒体项详情
|
||||
"""
|
||||
|
||||
def __get_emby_iteminfo() -> dict:
|
||||
"""
|
||||
获得Emby媒体项详情
|
||||
"""
|
||||
try:
|
||||
url = f'[HOST]emby/Users/[USER]/Items/{itemid}?' \
|
||||
f'Fields=ChannelMappingInfo&api_key=[APIKEY]'
|
||||
res = self.emby.get_data(url=url)
|
||||
if res:
|
||||
return res.json()
|
||||
except Exception as err:
|
||||
self.log_error(f"获取Emby媒体项详情失败:{str(err)}")
|
||||
return {}
|
||||
|
||||
def __get_jellyfin_iteminfo() -> dict:
|
||||
"""
|
||||
获得Jellyfin媒体项详情
|
||||
"""
|
||||
try:
|
||||
url = f'[HOST]Users/[USER]/Items/{itemid}?Fields=ChannelMappingInfo&api_key=[APIKEY]'
|
||||
res = self.jellyfin.get_data(url=url)
|
||||
if res:
|
||||
result = res.json()
|
||||
if result:
|
||||
result['FileName'] = Path(result['Path']).name
|
||||
return result
|
||||
except Exception as err:
|
||||
self.log_error(f"获取Jellyfin媒体项详情失败:{str(err)}")
|
||||
return {}
|
||||
|
||||
def __get_plex_iteminfo() -> dict:
|
||||
"""
|
||||
获得Plex媒体项详情
|
||||
"""
|
||||
iteminfo = {}
|
||||
try:
|
||||
plexitem = self.plex.get_plex().library.fetchItem(ekey=itemid)
|
||||
if 'movie' in plexitem.METADATA_TYPE:
|
||||
iteminfo['Type'] = 'Movie'
|
||||
iteminfo['IsFolder'] = False
|
||||
elif 'episode' in plexitem.METADATA_TYPE:
|
||||
iteminfo['Type'] = 'Series'
|
||||
iteminfo['IsFolder'] = False
|
||||
if 'show' in plexitem.TYPE:
|
||||
iteminfo['ChildCount'] = plexitem.childCount
|
||||
iteminfo['Name'] = plexitem.title
|
||||
iteminfo['Id'] = plexitem.key
|
||||
iteminfo['ProductionYear'] = plexitem.year
|
||||
iteminfo['ProviderIds'] = {}
|
||||
for guid in plexitem.guids:
|
||||
idlist = str(guid.id).split(sep='://')
|
||||
if len(idlist) < 2:
|
||||
continue
|
||||
iteminfo['ProviderIds'][idlist[0]] = idlist[1]
|
||||
for location in plexitem.locations:
|
||||
iteminfo['Path'] = location
|
||||
iteminfo['FileName'] = Path(location).name
|
||||
iteminfo['Overview'] = plexitem.summary
|
||||
iteminfo['CommunityRating'] = plexitem.audienceRating
|
||||
# 增加锁定属性列表
|
||||
iteminfo['LockedFields'] = []
|
||||
try:
|
||||
if plexitem.title.locked:
|
||||
iteminfo['LockedFields'].append('Name')
|
||||
except Exception as err:
|
||||
logger.warn(f"获取Plex媒体项详情失败:{str(err)}")
|
||||
pass
|
||||
try:
|
||||
if plexitem.summary.locked:
|
||||
iteminfo['LockedFields'].append('Overview')
|
||||
except Exception as err:
|
||||
logger.warn(f"获取Plex媒体项详情失败:{str(err)}")
|
||||
pass
|
||||
return iteminfo
|
||||
except Exception as err:
|
||||
self.log_error(f"获取Plex媒体项详情失败:{str(err)}")
|
||||
return {}
|
||||
|
||||
if server == "emby":
|
||||
return __get_emby_iteminfo()
|
||||
elif server == "jellyfin":
|
||||
return __get_jellyfin_iteminfo()
|
||||
else:
|
||||
return __get_plex_iteminfo()
|
||||
|
||||
def set_iteminfo(self, server: str, itemid: str, iteminfo: dict):
|
||||
"""
|
||||
更新媒体项详情
|
||||
"""
|
||||
|
||||
def __set_emby_iteminfo():
|
||||
"""
|
||||
更新Emby媒体项详情
|
||||
"""
|
||||
try:
|
||||
res = self.emby.post_data(
|
||||
url=f'[HOST]emby/Items/{itemid}?api_key=[APIKEY]&reqformat=json',
|
||||
data=json.dumps(iteminfo),
|
||||
headers={
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
if res and res.status_code in [200, 204]:
|
||||
return True
|
||||
else:
|
||||
self.log_error(f"更新Emby媒体项详情失败,错误码:{res.status_code}")
|
||||
return False
|
||||
except Exception as err:
|
||||
self.log_error(f"更新Emby媒体项详情失败:{str(err)}")
|
||||
return False
|
||||
|
||||
def __set_jellyfin_iteminfo():
|
||||
"""
|
||||
更新Jellyfin媒体项详情
|
||||
"""
|
||||
try:
|
||||
res = self.jellyfin.post_data(
|
||||
url=f'[HOST]Items/{itemid}?api_key=[APIKEY]',
|
||||
data=json.dumps(iteminfo),
|
||||
headers={
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
if res and res.status_code in [200, 204]:
|
||||
return True
|
||||
else:
|
||||
self.log_error(f"更新Jellyfin媒体项详情失败,错误码:{res.status_code}")
|
||||
return False
|
||||
except Exception as err:
|
||||
self.log_error(f"更新Jellyfin媒体项详情失败:{str(err)}")
|
||||
return False
|
||||
|
||||
def __set_plex_iteminfo():
|
||||
"""
|
||||
更新Plex媒体项详情
|
||||
"""
|
||||
try:
|
||||
plexitem = self.plex.get_plex().library.fetchItem(ekey=itemid)
|
||||
if 'CommunityRating' in iteminfo and iteminfo['CommunityRating']:
|
||||
edits = {
|
||||
'audienceRating.value': iteminfo['CommunityRating'],
|
||||
'audienceRating.locked': 1
|
||||
}
|
||||
plexitem.edit(**edits)
|
||||
plexitem.editTitle(iteminfo['Name']).editSummary(iteminfo['Overview']).reload()
|
||||
return True
|
||||
except Exception as err:
|
||||
self.log_error(f"更新Plex媒体项详情失败:{str(err)}")
|
||||
return False
|
||||
|
||||
if server == "emby":
|
||||
return __set_emby_iteminfo()
|
||||
elif server == "jellyfin":
|
||||
return __set_jellyfin_iteminfo()
|
||||
else:
|
||||
return __set_plex_iteminfo()
|
||||
|
||||
@retry(RequestException, logger=logger)
|
||||
def set_item_image(self, server: str, itemid: str, imageurl: str):
|
||||
"""
|
||||
更新媒体项图片
|
||||
"""
|
||||
|
||||
def __download_image():
|
||||
"""
|
||||
下载图片
|
||||
"""
|
||||
try:
|
||||
if "doubanio.com" in imageurl:
|
||||
r = RequestUtils(headers={
|
||||
'Referer': "https://movie.douban.com/"
|
||||
}, ua=settings.USER_AGENT).get_res(url=imageurl, raise_exception=True)
|
||||
else:
|
||||
r = RequestUtils().get_res(url=imageurl, raise_exception=True)
|
||||
if r:
|
||||
return base64.b64encode(r.content).decode()
|
||||
else:
|
||||
self.log_error(f"{imageurl} 图片下载失败,请检查网络连通性")
|
||||
except Exception as err:
|
||||
self.log_error(f"下载图片失败:{str(err)}")
|
||||
return None
|
||||
|
||||
def __set_emby_item_image(_base64: str):
|
||||
"""
|
||||
更新Emby媒体项图片
|
||||
"""
|
||||
try:
|
||||
url = f'[HOST]emby/Items/{itemid}/Images/Primary?api_key=[APIKEY]'
|
||||
res = self.emby.post_data(
|
||||
url=url,
|
||||
data=_base64,
|
||||
headers={
|
||||
"Content-Type": "image/png"
|
||||
}
|
||||
)
|
||||
if res and res.status_code in [200, 204]:
|
||||
return True
|
||||
else:
|
||||
self.log_error(f"更新Emby媒体项图片失败,错误码:{res.status_code}")
|
||||
return False
|
||||
except Exception as result:
|
||||
self.log_error(f"更新Emby媒体项图片失败:{result}")
|
||||
return False
|
||||
|
||||
def __set_jellyfin_item_image():
|
||||
"""
|
||||
更新Jellyfin媒体项图片
|
||||
# FIXME 改为预下载图片
|
||||
"""
|
||||
try:
|
||||
url = f'[HOST]Items/{itemid}/RemoteImages/Download?' \
|
||||
f'Type=Primary&ImageUrl={imageurl}&ProviderName=TheMovieDb&api_key=[APIKEY]'
|
||||
res = self.jellyfin.post_data(url=url)
|
||||
if res and res.status_code in [200, 204]:
|
||||
return True
|
||||
else:
|
||||
self.log_error(f"更新Jellyfin媒体项图片失败,错误码:{res.status_code}")
|
||||
return False
|
||||
except Exception as err:
|
||||
self.log_error(f"更新Jellyfin媒体项图片失败:{err}")
|
||||
return False
|
||||
|
||||
def __set_plex_item_image():
|
||||
"""
|
||||
更新Plex媒体项图片
|
||||
# FIXME 改为预下载图片
|
||||
"""
|
||||
try:
|
||||
plexitem = self.plex.get_plex().library.fetchItem(ekey=itemid)
|
||||
plexitem.uploadPoster(url=imageurl)
|
||||
return True
|
||||
except Exception as err:
|
||||
self.log_error(f"更新Plex媒体项图片失败:{err}")
|
||||
return False
|
||||
|
||||
if server == "emby":
|
||||
# 下载图片获取base64
|
||||
image_base64 = __download_image()
|
||||
if image_base64:
|
||||
return __set_emby_item_image(image_base64)
|
||||
elif server == "jellyfin":
|
||||
return __set_jellyfin_item_image()
|
||||
else:
|
||||
return __set_plex_item_image()
|
||||
return None
|
||||
|
||||
def log_error(self, ss: str):
|
||||
logger.error(f"<{self.plugin_name}> {str(ss)}")
|
||||
|
||||
def log_warn(self, ss: str):
|
||||
logger.warn(f"<{self.plugin_name}> {str(ss)}")
|
||||
|
||||
def log_info(self, ss: str):
|
||||
logger.info(f"<{self.plugin_name}> {str(ss)}")
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
停止服务
|
||||
"""
|
||||
pass
|
||||
360
plugins.v2/ffmpegthumb/__init__.py
Normal file
360
plugins.v2/ffmpegthumb/__init__.py
Normal file
@@ -0,0 +1,360 @@
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from threading import Event as ThreadEvent
|
||||
from typing import List, Tuple, Dict, Any
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.log import logger
|
||||
from app.plugins import _PluginBase
|
||||
from app.plugins.ffmpegthumb.ffmpeg_helper import FfmpegHelper
|
||||
from app.schemas import TransferInfo
|
||||
from app.schemas.types import EventType
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
ffmpeg_lock = threading.Lock()
|
||||
|
||||
|
||||
class FFmpegThumb(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "FFmpeg缩略图"
|
||||
# 插件描述
|
||||
plugin_desc = "TheMovieDb没有背景图片时使用FFmpeg截取视频文件缩略图。"
|
||||
# 插件图标
|
||||
plugin_icon = "ffmpeg.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.2"
|
||||
# 插件作者
|
||||
plugin_author = "jxxghp"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/jxxghp"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "ffmpegthumb_"
|
||||
# 加载顺序
|
||||
plugin_order = 31
|
||||
# 可使用的用户级别
|
||||
user_level = 1
|
||||
|
||||
# 私有属性
|
||||
_scheduler = None
|
||||
_enabled = False
|
||||
_onlyonce = False
|
||||
_cron = None
|
||||
_timeline = "00:03:01"
|
||||
_scan_paths = ""
|
||||
_exclude_paths = ""
|
||||
# 退出事件
|
||||
_event = ThreadEvent()
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
# 读取配置
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._onlyonce = config.get("onlyonce")
|
||||
self._cron = config.get("cron")
|
||||
self._timeline = config.get("timeline")
|
||||
self._scan_paths = config.get("scan_paths") or ""
|
||||
self._exclude_paths = config.get("exclude_paths") or ""
|
||||
|
||||
# 停止现有任务
|
||||
self.stop_service()
|
||||
|
||||
# 启动定时任务 & 立即运行一次
|
||||
if self._enabled or self._onlyonce:
|
||||
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
if self._cron:
|
||||
logger.info(f"FFmpeg缩略图服务启动,周期:{self._cron}")
|
||||
try:
|
||||
self._scheduler.add_job(func=self.__libraryscan,
|
||||
trigger=CronTrigger.from_crontab(self._cron),
|
||||
name="FFmpeg缩略图")
|
||||
except Exception as e:
|
||||
logger.error(f"FFmpeg缩略图服务启动失败,原因:{str(e)}")
|
||||
self.systemmessage.put(f"FFmpeg缩略图服务启动失败,原因:{str(e)}", title="FFmpeg缩略图")
|
||||
if self._onlyonce:
|
||||
logger.info(f"FFmpeg缩略图服务,立即运行一次")
|
||||
self._scheduler.add_job(func=self.__libraryscan, trigger='date',
|
||||
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
|
||||
name="FFmpeg缩略图")
|
||||
# 关闭一次性开关
|
||||
self._onlyonce = False
|
||||
self.update_config({
|
||||
"onlyonce": False,
|
||||
"enabled": self._enabled,
|
||||
"cron": self._cron,
|
||||
"timeline": self._timeline,
|
||||
"scan_paths": self._scan_paths,
|
||||
"exclude_paths": self._exclude_paths
|
||||
})
|
||||
if self._scheduler.get_jobs():
|
||||
# 启动服务
|
||||
self._scheduler.print_jobs()
|
||||
self._scheduler.start()
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enabled',
|
||||
'label': '启用插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'onlyonce',
|
||||
'label': '立即运行一次',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'timeline',
|
||||
'label': '截取时间',
|
||||
'placeholder': '00:03:01'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'cron',
|
||||
'label': '定时扫描周期',
|
||||
'placeholder': '5位cron表达式,留空关闭'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextarea',
|
||||
'props': {
|
||||
'model': 'scan_paths',
|
||||
'label': '定时扫描路径',
|
||||
'rows': 5,
|
||||
'placeholder': '每一行一个目录'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextarea',
|
||||
'props': {
|
||||
'model': 'exclude_paths',
|
||||
'label': '定时扫描排除路径',
|
||||
'rows': 2,
|
||||
'placeholder': '每一行一个目录'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '开启插件后默认会实时处理增量整理的媒体文件,需要处理存量媒体文件时才需开启定时;需要提前安装FFmpeg:https://www.ffmpeg.org'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"cron": "",
|
||||
"timeline": "00:03:01",
|
||||
"scan_paths": "",
|
||||
"err_hosts": ""
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
@eventmanager.register(EventType.TransferComplete)
|
||||
def scan_rt(self, event: Event):
|
||||
"""
|
||||
根据事件实时扫描缩略图
|
||||
"""
|
||||
if not self._enabled:
|
||||
return
|
||||
# 事件数据
|
||||
transferinfo: TransferInfo = event.event_data.get("transferinfo")
|
||||
if not transferinfo:
|
||||
return
|
||||
file_list = transferinfo.file_list_new
|
||||
for file in file_list:
|
||||
logger.info(f"FFmpeg缩略图处理文件:{file}")
|
||||
file_path = Path(file)
|
||||
if not file_path.exists():
|
||||
logger.warn(f"{file_path} 不存在")
|
||||
continue
|
||||
if file_path.suffix not in settings.RMT_MEDIAEXT:
|
||||
logger.warn(f"{file_path} 不是支持的视频文件")
|
||||
continue
|
||||
self.gen_file_thumb(file_path)
|
||||
|
||||
def __libraryscan(self):
|
||||
"""
|
||||
开始扫描媒体库
|
||||
"""
|
||||
if not self._scan_paths:
|
||||
return
|
||||
# 排除目录
|
||||
exclude_paths = self._exclude_paths.split("\n")
|
||||
# 已选择的目录
|
||||
paths = self._scan_paths.split("\n")
|
||||
for path in paths:
|
||||
if not path:
|
||||
continue
|
||||
scan_path = Path(path)
|
||||
if not scan_path.exists():
|
||||
logger.warning(f"FFmpeg缩略图扫描路径不存在:{path}")
|
||||
continue
|
||||
logger.info(f"开始FFmpeg缩略图扫描:{path} ...")
|
||||
# 遍历目录下的所有文件
|
||||
for file_path in SystemUtils.list_files(scan_path, extensions=settings.RMT_MEDIAEXT):
|
||||
if self._event.is_set():
|
||||
logger.info(f"FFmpeg缩略图扫描服务停止")
|
||||
return
|
||||
# 排除目录
|
||||
exclude_flag = False
|
||||
for exclude_path in exclude_paths:
|
||||
try:
|
||||
if file_path.is_relative_to(Path(exclude_path)):
|
||||
exclude_flag = True
|
||||
break
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
if exclude_flag:
|
||||
logger.debug(f"{file_path} 在排除目录中,跳过 ...")
|
||||
continue
|
||||
# 开始处理文件
|
||||
self.gen_file_thumb(file_path)
|
||||
logger.info(f"目录 {path} 扫描完成")
|
||||
|
||||
def gen_file_thumb(self, file_path: Path):
|
||||
"""
|
||||
处理一个文件
|
||||
"""
|
||||
# 单线程处理
|
||||
with ffmpeg_lock:
|
||||
try:
|
||||
thumb_path = file_path.with_name(file_path.stem + "-thumb.jpg")
|
||||
if thumb_path.exists():
|
||||
logger.info(f"缩略图已存在:{thumb_path}")
|
||||
return
|
||||
if FfmpegHelper.get_thumb(video_path=str(file_path),
|
||||
image_path=str(thumb_path), frames=self._timeline):
|
||||
logger.info(f"{file_path} 缩略图已生成:{thumb_path}")
|
||||
except Exception as err:
|
||||
logger.error(f"FFmpeg处理文件 {file_path} 时发生错误:{str(err)}")
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
退出插件
|
||||
"""
|
||||
try:
|
||||
if self._scheduler:
|
||||
self._scheduler.remove_all_jobs()
|
||||
if self._scheduler.running:
|
||||
self._event.set()
|
||||
self._scheduler.shutdown()
|
||||
self._event.clear()
|
||||
self._scheduler = None
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
82
plugins.v2/ffmpegthumb/ffmpeg_helper.py
Normal file
82
plugins.v2/ffmpegthumb/ffmpeg_helper.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class FfmpegHelper:
|
||||
|
||||
@staticmethod
|
||||
def get_thumb(video_path: str, image_path: str, frames: str = None):
|
||||
"""
|
||||
使用ffmpeg从视频文件中截取缩略图
|
||||
"""
|
||||
if not frames:
|
||||
frames = "00:03:01"
|
||||
if not video_path or not image_path:
|
||||
return False
|
||||
cmd = 'ffmpeg -i "{video_path}" -ss {frames} -vframes 1 -f image2 "{image_path}"'.format(video_path=video_path,
|
||||
frames=frames,
|
||||
image_path=image_path)
|
||||
result = SystemUtils.execute(cmd)
|
||||
if result:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def extract_wav(video_path: str, audio_path: str, audio_index: str = None):
|
||||
"""
|
||||
使用ffmpeg从视频文件中提取16000hz, 16-bit的wav格式音频
|
||||
"""
|
||||
if not video_path or not audio_path:
|
||||
return False
|
||||
|
||||
# 提取指定音频流
|
||||
if audio_index:
|
||||
command = ['ffmpeg', "-hide_banner", "-loglevel", "warning", '-y', '-i', video_path,
|
||||
'-map', f'0:a:{audio_index}',
|
||||
'-acodec', 'pcm_s16le', '-ac', '1', '-ar', '16000', audio_path]
|
||||
else:
|
||||
command = ['ffmpeg', "-hide_banner", "-loglevel", "warning", '-y', '-i', video_path,
|
||||
'-acodec', 'pcm_s16le', '-ac', '1', '-ar', '16000', audio_path]
|
||||
|
||||
ret = subprocess.run(command).returncode
|
||||
if ret == 0:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_metadata(video_path: str):
|
||||
"""
|
||||
获取视频元数据
|
||||
"""
|
||||
if not video_path:
|
||||
return False
|
||||
|
||||
try:
|
||||
command = ['ffprobe', '-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', video_path]
|
||||
result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
if result.returncode == 0:
|
||||
return json.loads(result.stdout.decode("utf-8"))
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def extract_subtitle(video_path: str, subtitle_path: str, subtitle_index: str = None):
|
||||
"""
|
||||
从视频中提取字幕
|
||||
"""
|
||||
if not video_path or not subtitle_path:
|
||||
return False
|
||||
|
||||
if subtitle_index:
|
||||
command = ['ffmpeg', "-hide_banner", "-loglevel", "warning", '-y', '-i', video_path,
|
||||
'-map', f'0:s:{subtitle_index}',
|
||||
subtitle_path]
|
||||
else:
|
||||
command = ['ffmpeg', "-hide_banner", "-loglevel", "warning", '-y', '-i', video_path, subtitle_path]
|
||||
ret = subprocess.run(command).returncode
|
||||
if ret == 0:
|
||||
return True
|
||||
return False
|
||||
1246
plugins.v2/iyuuautoseed/__init__.py
Normal file
1246
plugins.v2/iyuuautoseed/__init__.py
Normal file
File diff suppressed because it is too large
Load Diff
115
plugins.v2/iyuuautoseed/iyuu_helper.py
Normal file
115
plugins.v2/iyuuautoseed/iyuu_helper.py
Normal file
@@ -0,0 +1,115 @@
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from typing import Tuple, Optional
|
||||
|
||||
from app.utils.http import RequestUtils
|
||||
|
||||
|
||||
class IyuuHelper(object):
|
||||
"""
|
||||
适配新版本IYUU开发版
|
||||
"""
|
||||
_version = "8.2.0"
|
||||
_api_base = "https://dev.iyuu.cn"
|
||||
_sites = {}
|
||||
_token = None
|
||||
_sid_sha1 = None
|
||||
|
||||
def __init__(self, token: str):
|
||||
self._token = token
|
||||
if self._token:
|
||||
self.init_config()
|
||||
|
||||
def init_config(self):
|
||||
pass
|
||||
|
||||
def __request_iyuu(self, url: str, method: str = "get", params: dict = None) -> Tuple[Optional[dict], str]:
|
||||
"""
|
||||
向IYUUApi发送请求
|
||||
"""
|
||||
if method == "post":
|
||||
ret = RequestUtils(
|
||||
accept_type="application/json",
|
||||
headers={'token': self._token}
|
||||
).post_res(f'{self._api_base + url}', json=params)
|
||||
else:
|
||||
ret = RequestUtils(
|
||||
accept_type="application/json",
|
||||
headers={'token': self._token}
|
||||
).get_res(f'{self._api_base + url}', params=params)
|
||||
if ret:
|
||||
result = ret.json()
|
||||
if result.get('code') == 0:
|
||||
return result.get('data'), ""
|
||||
else:
|
||||
return None, f'请求IYUU失败,状态码:{result.get("code")},返回信息:{result.get("msg")}'
|
||||
elif ret is not None:
|
||||
return None, f"请求IYUU失败,状态码:{ret.status_code},错误原因:{ret.reason}"
|
||||
else:
|
||||
return None, f"请求IYUU失败,未获取到返回信息"
|
||||
|
||||
def get_torrent_url(self, sid: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
if not sid:
|
||||
return None, None
|
||||
if not self._sites:
|
||||
self._sites = self.__get_sites()
|
||||
if not self._sites.get(sid):
|
||||
return None, None
|
||||
site = self._sites.get(sid)
|
||||
return site.get('base_url'), site.get('download_page')
|
||||
|
||||
def __get_sites(self) -> dict:
|
||||
"""
|
||||
返回支持辅种的全部站点
|
||||
:return: 站点列表、错误信息
|
||||
"""
|
||||
result, msg = self.__request_iyuu(url='/reseed/sites/index')
|
||||
if result:
|
||||
ret_sites = {}
|
||||
sites = result.get('sites')
|
||||
for site in sites:
|
||||
ret_sites[site.get('id')] = site
|
||||
return ret_sites
|
||||
else:
|
||||
print(msg)
|
||||
return {}
|
||||
|
||||
def __report_existing(self) -> Optional[str]:
|
||||
"""
|
||||
汇报辅种的站点
|
||||
:return:
|
||||
"""
|
||||
if not self._sites:
|
||||
self._sites = self.__get_sites()
|
||||
sid_list = list(self._sites.keys())
|
||||
result, msg = self.__request_iyuu(url='/reseed/sites/reportExisting',
|
||||
method='post',
|
||||
params={'sid_list': sid_list})
|
||||
if result:
|
||||
return result.get('sid_sha1')
|
||||
return None
|
||||
|
||||
def get_seed_info(self, info_hashs: list) -> Tuple[Optional[dict], str]:
|
||||
"""
|
||||
返回info_hash对应的站点id、种子id
|
||||
:param info_hashs:
|
||||
:return:
|
||||
"""
|
||||
if not self._sid_sha1:
|
||||
self._sid_sha1 = self.__report_existing()
|
||||
info_hashs.sort()
|
||||
json_data = json.dumps(info_hashs, separators=(',', ':'), ensure_ascii=False)
|
||||
sha1 = self.get_sha1(json_data)
|
||||
result, msg = self.__request_iyuu(url='/reseed/index/index', method='post', params={
|
||||
'hash': json_data,
|
||||
'sha1': sha1,
|
||||
'sid_sha1': self._sid_sha1,
|
||||
'timestamp': int(time.time()),
|
||||
'version': self._version
|
||||
})
|
||||
return result, msg
|
||||
|
||||
@staticmethod
|
||||
def get_sha1(json_str: str) -> str:
|
||||
return hashlib.sha1(json_str.encode('utf-8')).hexdigest()
|
||||
437
plugins.v2/libraryscraper/__init__.py
Normal file
437
plugins.v2/libraryscraper/__init__.py
Normal file
@@ -0,0 +1,437 @@
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
from typing import List, Tuple, Dict, Any
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.metainfo import MetaInfoPath
|
||||
from app.db.transferhistory_oper import TransferHistoryOper
|
||||
from app.helper.nfo import NfoReader
|
||||
from app.log import logger
|
||||
from app.plugins import _PluginBase
|
||||
from app.schemas import MediaType
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class LibraryScraper(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "媒体库刮削"
|
||||
# 插件描述
|
||||
plugin_desc = "定时对媒体库进行刮削,补齐缺失元数据和图片。"
|
||||
# 插件图标
|
||||
plugin_icon = "scraper.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.5"
|
||||
# 插件作者
|
||||
plugin_author = "jxxghp"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/jxxghp"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "libraryscraper_"
|
||||
# 加载顺序
|
||||
plugin_order = 7
|
||||
# 可使用的用户级别
|
||||
user_level = 1
|
||||
|
||||
# 私有属性
|
||||
transferhis = None
|
||||
_scheduler = None
|
||||
_scraper = None
|
||||
# 限速开关
|
||||
_enabled = False
|
||||
_onlyonce = False
|
||||
_cron = None
|
||||
_mode = ""
|
||||
_scraper_paths = ""
|
||||
_exclude_paths = ""
|
||||
# 退出事件
|
||||
_event = Event()
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
# 读取配置
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._onlyonce = config.get("onlyonce")
|
||||
self._cron = config.get("cron")
|
||||
self._mode = config.get("mode") or ""
|
||||
self._scraper_paths = config.get("scraper_paths") or ""
|
||||
self._exclude_paths = config.get("exclude_paths") or ""
|
||||
|
||||
# 停止现有任务
|
||||
self.stop_service()
|
||||
|
||||
# 启动定时任务 & 立即运行一次
|
||||
if self._enabled or self._onlyonce:
|
||||
self.transferhis = TransferHistoryOper()
|
||||
|
||||
if self._onlyonce:
|
||||
logger.info(f"媒体库刮削服务,立即运行一次")
|
||||
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
self._scheduler.add_job(func=self.__libraryscraper, trigger='date',
|
||||
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
|
||||
name="媒体库刮削")
|
||||
# 关闭一次性开关
|
||||
self._onlyonce = False
|
||||
self.update_config({
|
||||
"onlyonce": False,
|
||||
"enabled": self._enabled,
|
||||
"cron": self._cron,
|
||||
"mode": self._mode,
|
||||
"scraper_paths": self._scraper_paths,
|
||||
"exclude_paths": self._exclude_paths
|
||||
})
|
||||
if self._scheduler.get_jobs():
|
||||
# 启动服务
|
||||
self._scheduler.print_jobs()
|
||||
self._scheduler.start()
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_service(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
注册插件公共服务
|
||||
[{
|
||||
"id": "服务ID",
|
||||
"name": "服务名称",
|
||||
"trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()",
|
||||
"func": self.xxx,
|
||||
"kwargs": {} # 定时器参数
|
||||
}]
|
||||
"""
|
||||
if self._enabled and self._cron:
|
||||
return [{
|
||||
"id": "LibraryScraper",
|
||||
"name": "媒体库刮削",
|
||||
"trigger": CronTrigger.from_crontab(self._cron),
|
||||
"func": self.__libraryscraper,
|
||||
"kwargs": {}
|
||||
}]
|
||||
elif self._enabled:
|
||||
return [{
|
||||
"id": "LibraryScraper",
|
||||
"name": "媒体库刮削",
|
||||
"trigger": CronTrigger.from_crontab("0 0 */7 * *"),
|
||||
"func": self.__libraryscraper,
|
||||
"kwargs": {}
|
||||
}]
|
||||
return []
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enabled',
|
||||
'label': '启用插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'onlyonce',
|
||||
'label': '立即运行一次',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSelect',
|
||||
'props': {
|
||||
'model': 'mode',
|
||||
'label': '刮削模式',
|
||||
'items': [
|
||||
{'title': '仅刮削缺失元数据和图片', 'value': ''},
|
||||
{'title': '覆盖所有元数据和图片', 'value': 'force_all'},
|
||||
{'title': '覆盖所有元数据', 'value': 'force_nfo'},
|
||||
{'title': '覆盖所有图片', 'value': 'force_image'},
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'cron',
|
||||
'label': '执行周期',
|
||||
'placeholder': '5位cron表达式,留空自动'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextarea',
|
||||
'props': {
|
||||
'model': 'scraper_paths',
|
||||
'label': '削刮路径',
|
||||
'rows': 5,
|
||||
'placeholder': '每一行一个目录'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextarea',
|
||||
'props': {
|
||||
'model': 'exclude_paths',
|
||||
'label': '排除路径',
|
||||
'rows': 2,
|
||||
'placeholder': '每一行一个目录'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '刮削路径后拼接#电视剧/电影,强制指定该媒体路径媒体类型。'
|
||||
'不加默认根据文件名自动识别媒体类型。'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"cron": "0 0 */7 * *",
|
||||
"mode": "",
|
||||
"scraper_paths": "",
|
||||
"err_hosts": ""
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
def __libraryscraper(self):
|
||||
"""
|
||||
开始刮削媒体库
|
||||
"""
|
||||
if not self._scraper_paths:
|
||||
return
|
||||
# 排除目录
|
||||
exclude_paths = self._exclude_paths.split("\n")
|
||||
# 已选择的目录
|
||||
paths = self._scraper_paths.split("\n")
|
||||
for path in paths:
|
||||
if not path:
|
||||
continue
|
||||
# 强制指定该路径媒体类型
|
||||
mtype = None
|
||||
if str(path).count("#") == 1:
|
||||
mtype = next(
|
||||
(mediaType for mediaType in MediaType.__members__.values() if
|
||||
mediaType.value == str(str(path).split("#")[1])),
|
||||
None)
|
||||
path = str(path).split("#")[0]
|
||||
scraper_path = Path(path)
|
||||
if not scraper_path.exists():
|
||||
logger.warning(f"媒体库刮削路径不存在:{path}")
|
||||
continue
|
||||
logger.info(f"开始刮削媒体库:{path} {mtype} ...")
|
||||
# 遍历所有文件
|
||||
files = SystemUtils.list_files(scraper_path, settings.RMT_MEDIAEXT)
|
||||
for file_path in files:
|
||||
if self._event.is_set():
|
||||
logger.info(f"媒体库刮削服务停止")
|
||||
return
|
||||
# 排除目录
|
||||
exclude_flag = False
|
||||
for exclude_path in exclude_paths:
|
||||
try:
|
||||
if file_path.is_relative_to(Path(exclude_path)):
|
||||
exclude_flag = True
|
||||
break
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
if exclude_flag:
|
||||
logger.debug(f"{file_path} 在排除目录中,跳过 ...")
|
||||
continue
|
||||
# 开始刮削文件
|
||||
self.__scrape_file(file=file_path, mtype=mtype)
|
||||
logger.info(f"媒体库 {path} 刮削完成")
|
||||
|
||||
def __scrape_file(self, file: Path, mtype: MediaType = None):
|
||||
"""
|
||||
削刮一个目录,该目录必须是媒体文件目录
|
||||
"""
|
||||
# 识别元数据
|
||||
meta_info = MetaInfoPath(file)
|
||||
# 强制指定类型
|
||||
if mtype:
|
||||
meta_info.type = mtype
|
||||
|
||||
# 是否刮削
|
||||
force_nfo = self._mode in ["force_all", "force_nfo"]
|
||||
force_img = self._mode in ["force_all", "force_image"]
|
||||
|
||||
# 优先读取本地nfo文件
|
||||
tmdbid = None
|
||||
if meta_info.type == MediaType.MOVIE:
|
||||
# 电影
|
||||
movie_nfo = file.parent / "movie.nfo"
|
||||
if movie_nfo.exists():
|
||||
tmdbid = self.__get_tmdbid_from_nfo(movie_nfo)
|
||||
file_nfo = file.with_suffix(".nfo")
|
||||
if not tmdbid and file_nfo.exists():
|
||||
tmdbid = self.__get_tmdbid_from_nfo(file_nfo)
|
||||
else:
|
||||
# 电视剧
|
||||
tv_nfo = file.parent.parent / "tvshow.nfo"
|
||||
if tv_nfo.exists():
|
||||
tmdbid = self.__get_tmdbid_from_nfo(tv_nfo)
|
||||
if tmdbid:
|
||||
# 按TMDBID识别
|
||||
logger.info(f"读取到本地nfo文件的tmdbid:{tmdbid}")
|
||||
mediainfo = self.chain.recognize_media(tmdbid=tmdbid, mtype=meta_info.type)
|
||||
else:
|
||||
# 按名称识别
|
||||
mediainfo = self.chain.recognize_media(meta=meta_info)
|
||||
if not mediainfo:
|
||||
logger.warn(f"未识别到媒体信息:{file}")
|
||||
return
|
||||
|
||||
# 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title
|
||||
if not settings.SCRAP_FOLLOW_TMDB:
|
||||
transfer_history = self.transferhis.get_by_type_tmdbid(tmdbid=mediainfo.tmdb_id,
|
||||
mtype=mediainfo.type.value)
|
||||
if transfer_history:
|
||||
mediainfo.title = transfer_history.title
|
||||
# 获取图片
|
||||
self.chain.obtain_images(mediainfo)
|
||||
# 刮削
|
||||
self.chain.scrape_metadata(path=file,
|
||||
mediainfo=mediainfo,
|
||||
transfer_type=settings.TRANSFER_TYPE,
|
||||
force_nfo=force_nfo,
|
||||
force_img=force_img)
|
||||
|
||||
@staticmethod
|
||||
def __get_tmdbid_from_nfo(file_path: Path):
|
||||
"""
|
||||
从nfo文件中获取信息
|
||||
:param file_path:
|
||||
:return: tmdbid
|
||||
"""
|
||||
if not file_path:
|
||||
return None
|
||||
xpaths = [
|
||||
"uniqueid[@type='Tmdb']",
|
||||
"uniqueid[@type='tmdb']",
|
||||
"uniqueid[@type='TMDB']",
|
||||
"tmdbid"
|
||||
]
|
||||
try:
|
||||
reader = NfoReader(file_path)
|
||||
for xpath in xpaths:
|
||||
tmdbid = reader.get_element_value(xpath)
|
||||
if tmdbid:
|
||||
return tmdbid
|
||||
except Exception as err:
|
||||
logger.warn(f"从nfo文件中获取tmdbid失败:{str(err)}")
|
||||
return None
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
退出插件
|
||||
"""
|
||||
try:
|
||||
if self._scheduler:
|
||||
self._scheduler.remove_all_jobs()
|
||||
if self._scheduler.running:
|
||||
self._event.set()
|
||||
self._scheduler.shutdown()
|
||||
self._event.clear()
|
||||
self._scheduler = None
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
295
plugins.v2/mediaservermsg/__init__.py
Normal file
295
plugins.v2/mediaservermsg/__init__.py
Normal file
@@ -0,0 +1,295 @@
|
||||
import time
|
||||
from typing import Any, List, Dict, Tuple
|
||||
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.log import logger
|
||||
from app.modules.emby import Emby
|
||||
from app.modules.jellyfin import Jellyfin
|
||||
from app.modules.plex import Plex
|
||||
from app.plugins import _PluginBase
|
||||
from app.schemas import WebhookEventInfo
|
||||
from app.schemas.types import EventType, MediaType, MediaImageType, NotificationType
|
||||
from app.utils.web import WebUtils
|
||||
|
||||
|
||||
class MediaServerMsg(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "媒体库服务器通知"
|
||||
# 插件描述
|
||||
plugin_desc = "发送Emby/Jellyfin/Plex服务器的播放、入库等通知消息。"
|
||||
# 插件图标
|
||||
plugin_icon = "mediaplay.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.3"
|
||||
# 插件作者
|
||||
plugin_author = "jxxghp"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/jxxghp"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "mediaservermsg_"
|
||||
# 加载顺序
|
||||
plugin_order = 14
|
||||
# 可使用的用户级别
|
||||
auth_level = 1
|
||||
|
||||
# 对像
|
||||
plex = None
|
||||
emby = None
|
||||
jellyfin = None
|
||||
|
||||
# 私有属性
|
||||
_enabled = False
|
||||
_types = []
|
||||
_webhook_msg_keys = {}
|
||||
|
||||
# 拼装消息内容
|
||||
_webhook_actions = {
|
||||
"library.new": "新入库",
|
||||
"system.webhooktest": "测试",
|
||||
"playback.start": "开始播放",
|
||||
"playback.stop": "停止播放",
|
||||
"user.authenticated": "登录成功",
|
||||
"user.authenticationfailed": "登录失败",
|
||||
"media.play": "开始播放",
|
||||
"media.stop": "停止播放",
|
||||
"PlaybackStart": "开始播放",
|
||||
"PlaybackStop": "停止播放",
|
||||
"item.rate": "标记了"
|
||||
}
|
||||
_webhook_images = {
|
||||
"emby": "https://emby.media/notificationicon.png",
|
||||
"plex": "https://www.plex.tv/wp-content/uploads/2022/04/new-logo-process-lines-gray.png",
|
||||
"jellyfin": "https://play-lh.googleusercontent.com/SCsUK3hCCRqkJbmLDctNYCfehLxsS4ggD1ZPHIFrrAN1Tn9yhjmGMPep2D9lMaaa9eQi"
|
||||
}
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._types = config.get("types") or []
|
||||
if self._enabled:
|
||||
self.emby = Emby()
|
||||
self.plex = Plex()
|
||||
self.jellyfin = Jellyfin()
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
types_options = [
|
||||
{"title": "新入库", "value": "library.new"},
|
||||
{"title": "开始播放", "value": "playback.start|media.play|PlaybackStart"},
|
||||
{"title": "停止播放", "value": "playback.stop|media.stop|PlaybackStop"},
|
||||
{"title": "用户标记", "value": "item.rate"},
|
||||
{"title": "测试", "value": "system.webhooktest"},
|
||||
{"title": "登录成功", "value": "user.authenticated"},
|
||||
{"title": "登录失败", "value": "user.authenticationfailed"},
|
||||
]
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enabled',
|
||||
'label': '启用插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSelect',
|
||||
'props': {
|
||||
'chips': True,
|
||||
'multiple': True,
|
||||
'model': 'types',
|
||||
'label': '消息类型',
|
||||
'items': types_options
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '需要设置媒体服务器Webhook,回调相对路径为 /api/v1/webhook?token=moviepilot(3001端口),其中 moviepilot 为设置的 API_TOKEN。'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"types": []
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
@eventmanager.register(EventType.WebhookMessage)
|
||||
def send(self, event: Event):
|
||||
"""
|
||||
发送通知消息
|
||||
"""
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
event_info: WebhookEventInfo = event.event_data
|
||||
if not event_info:
|
||||
return
|
||||
|
||||
# 不在支持范围不处理
|
||||
if not self._webhook_actions.get(event_info.event):
|
||||
return
|
||||
|
||||
# 不在选中范围不处理
|
||||
msgflag = False
|
||||
for _type in self._types:
|
||||
if event_info.event in _type.split("|"):
|
||||
msgflag = True
|
||||
break
|
||||
if not msgflag:
|
||||
logger.info(f"未开启 {event_info.event} 类型的消息通知")
|
||||
return
|
||||
|
||||
expiring_key = f"{event_info.item_id}-{event_info.client}-{event_info.user_name}"
|
||||
# 过滤停止播放重复消息
|
||||
if str(event_info.event) == "playback.stop" and expiring_key in self._webhook_msg_keys.keys():
|
||||
# 刷新过期时间
|
||||
self.__add_element(expiring_key)
|
||||
return
|
||||
|
||||
# 消息标题
|
||||
if event_info.item_type in ["TV", "SHOW"]:
|
||||
message_title = f"{self._webhook_actions.get(event_info.event)}剧集 {event_info.item_name}"
|
||||
elif event_info.item_type == "MOV":
|
||||
message_title = f"{self._webhook_actions.get(event_info.event)}电影 {event_info.item_name}"
|
||||
elif event_info.item_type == "AUD":
|
||||
message_title = f"{self._webhook_actions.get(event_info.event)}有声书 {event_info.item_name}"
|
||||
else:
|
||||
message_title = f"{self._webhook_actions.get(event_info.event)}"
|
||||
|
||||
# 消息内容
|
||||
message_texts = []
|
||||
if event_info.user_name:
|
||||
message_texts.append(f"用户:{event_info.user_name}")
|
||||
if event_info.device_name:
|
||||
message_texts.append(f"设备:{event_info.client} {event_info.device_name}")
|
||||
if event_info.ip:
|
||||
message_texts.append(f"IP地址:{event_info.ip} {WebUtils.get_location(event_info.ip)}")
|
||||
if event_info.percentage:
|
||||
percentage = round(float(event_info.percentage), 2)
|
||||
message_texts.append(f"进度:{percentage}%")
|
||||
if event_info.overview:
|
||||
message_texts.append(f"剧情:{event_info.overview}")
|
||||
message_texts.append(f"时间:{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}")
|
||||
|
||||
# 消息内容
|
||||
message_content = "\n".join(message_texts)
|
||||
|
||||
# 消息图片
|
||||
image_url = event_info.image_url
|
||||
# 查询剧集图片
|
||||
if (event_info.tmdb_id
|
||||
and event_info.season_id
|
||||
and event_info.episode_id):
|
||||
specific_image = self.chain.obtain_specific_image(
|
||||
mediaid=event_info.tmdb_id,
|
||||
mtype=MediaType.TV,
|
||||
image_type=MediaImageType.Backdrop,
|
||||
season=event_info.season_id,
|
||||
episode=event_info.episode_id
|
||||
)
|
||||
if specific_image:
|
||||
image_url = specific_image
|
||||
# 使用默认图片
|
||||
if not image_url:
|
||||
image_url = self._webhook_images.get(event_info.channel)
|
||||
|
||||
# 获取链接地址
|
||||
if event_info.channel == "emby":
|
||||
play_link = self.emby.get_play_url(event_info.item_id)
|
||||
elif event_info.channel == "plex":
|
||||
play_link = self.plex.get_play_url(event_info.item_id)
|
||||
elif event_info.channel == "jellyfin":
|
||||
play_link = self.jellyfin.get_play_url(event_info.item_id)
|
||||
else:
|
||||
play_link = None
|
||||
|
||||
if str(event_info.event) == "playback.stop":
|
||||
# 停止播放消息,添加到过期字典
|
||||
self.__add_element(expiring_key)
|
||||
if str(event_info.event) == "playback.start":
|
||||
# 开始播放消息,删除过期字典
|
||||
self.__remove_element(expiring_key)
|
||||
|
||||
# 发送消息
|
||||
self.post_message(mtype=NotificationType.MediaServer,
|
||||
title=message_title, text=message_content, image=image_url, link=play_link)
|
||||
|
||||
def __add_element(self, key, duration=600):
|
||||
expiration_time = time.time() + duration
|
||||
# 如果元素已经存在,更新其过期时间
|
||||
self._webhook_msg_keys[key] = expiration_time
|
||||
|
||||
def __remove_element(self, key):
|
||||
self._webhook_msg_keys = {k: v for k, v in self._webhook_msg_keys.items() if k != key}
|
||||
|
||||
def __get_elements(self):
|
||||
current_time = time.time()
|
||||
# 过滤掉过期的元素
|
||||
self._webhook_msg_keys = {k: v for k, v in self._webhook_msg_keys.items() if v > current_time}
|
||||
return list(self._webhook_msg_keys.keys())
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
退出插件
|
||||
"""
|
||||
pass
|
||||
170
plugins.v2/mediaserverrefresh/__init__.py
Normal file
170
plugins.v2/mediaserverrefresh/__init__.py
Normal file
@@ -0,0 +1,170 @@
|
||||
import time
|
||||
from typing import Any, List, Dict, Tuple
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.modules.emby import Emby
|
||||
from app.modules.jellyfin import Jellyfin
|
||||
from app.modules.plex import Plex
|
||||
from app.plugins import _PluginBase
|
||||
from app.schemas import TransferInfo, RefreshMediaItem
|
||||
from app.schemas.types import EventType
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class MediaServerRefresh(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "媒体库服务器刷新"
|
||||
# 插件描述
|
||||
plugin_desc = "入库后自动刷新Emby/Jellyfin/Plex服务器海报墙。"
|
||||
# 插件图标
|
||||
plugin_icon = "refresh2.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.2"
|
||||
# 插件作者
|
||||
plugin_author = "jxxghp"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/jxxghp"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "mediaserverrefresh_"
|
||||
# 加载顺序
|
||||
plugin_order = 14
|
||||
# 可使用的用户级别
|
||||
auth_level = 1
|
||||
|
||||
# 私有属性
|
||||
_enabled = False
|
||||
_delay = 0
|
||||
_emby = None
|
||||
_jellyfin = None
|
||||
_plex = None
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
self._emby = Emby()
|
||||
self._jellyfin = Jellyfin()
|
||||
self._plex = Plex()
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._delay = config.get("delay") or 0
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enabled',
|
||||
'label': '启用插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'delay',
|
||||
'label': '延迟时间(秒)',
|
||||
'placeholder': '0'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"delay": 0
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
@eventmanager.register(EventType.TransferComplete)
|
||||
def refresh(self, event: Event):
|
||||
"""
|
||||
发送通知消息
|
||||
"""
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
event_info: dict = event.event_data
|
||||
if not event_info:
|
||||
return
|
||||
|
||||
# 刷新媒体库
|
||||
if not settings.MEDIASERVER:
|
||||
return
|
||||
|
||||
if self._delay:
|
||||
logger.info(f"延迟 {self._delay} 秒后刷新媒体库... ")
|
||||
time.sleep(float(self._delay))
|
||||
|
||||
# 入库数据
|
||||
transferinfo: TransferInfo = event_info.get("transferinfo")
|
||||
mediainfo: MediaInfo = event_info.get("mediainfo")
|
||||
items = [
|
||||
RefreshMediaItem(
|
||||
title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
type=mediainfo.type,
|
||||
category=mediainfo.category,
|
||||
target_path=transferinfo.target_path
|
||||
)
|
||||
]
|
||||
# Emby
|
||||
if "emby" in settings.MEDIASERVER:
|
||||
self._emby.refresh_library_by_items(items)
|
||||
|
||||
# Jeyllyfin
|
||||
if "jellyfin" in settings.MEDIASERVER:
|
||||
# FIXME Jellyfin未找到刷新单个项目的API
|
||||
self._jellyfin.refresh_root_library()
|
||||
|
||||
# Plex
|
||||
if "plex" in settings.MEDIASERVER:
|
||||
self._plex.refresh_library_by_items(items)
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
退出插件
|
||||
"""
|
||||
pass
|
||||
1589
plugins.v2/mediasyncdel/__init__.py
Normal file
1589
plugins.v2/mediasyncdel/__init__.py
Normal file
File diff suppressed because it is too large
Load Diff
430
plugins.v2/messageforward/__init__.py
Normal file
430
plugins.v2/messageforward/__init__.py
Normal file
@@ -0,0 +1,430 @@
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.config import settings
|
||||
from app.plugins import _PluginBase
|
||||
from app.core.event import eventmanager
|
||||
from app.schemas.types import EventType, MessageChannel
|
||||
from app.utils.http import RequestUtils
|
||||
from typing import Any, List, Dict, Tuple, Optional
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class MessageForward(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "消息转发"
|
||||
# 插件描述
|
||||
plugin_desc = "根据正则转发通知到其他WeChat应用。"
|
||||
# 插件图标
|
||||
plugin_icon = "forward.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.1"
|
||||
# 插件作者
|
||||
plugin_author = "thsrite"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/thsrite"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "messageforward_"
|
||||
# 加载顺序
|
||||
plugin_order = 16
|
||||
# 可使用的用户级别
|
||||
auth_level = 1
|
||||
|
||||
# 私有属性
|
||||
_enabled = False
|
||||
_wechat = None
|
||||
_pattern = None
|
||||
_pattern_token = {}
|
||||
|
||||
# 企业微信发送消息URL
|
||||
_send_msg_url = f"{settings.WECHAT_PROXY}/cgi-bin/message/send?access_token=%s"
|
||||
# 企业微信获取TokenURL
|
||||
_token_url = f"{settings.WECHAT_PROXY}/cgi-bin/gettoken?corpid=%s&corpsecret=%s"
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._wechat = config.get("wechat")
|
||||
self._pattern = config.get("pattern")
|
||||
|
||||
# 获取token存库
|
||||
if self._enabled and self._wechat:
|
||||
self.__save_wechat_token()
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enabled',
|
||||
'label': '开启转发'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextarea',
|
||||
'props': {
|
||||
'model': 'wechat',
|
||||
'rows': '5',
|
||||
'label': '应用配置',
|
||||
'placeholder': 'appid:corpid:appsecret(一行一个配置)'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextarea',
|
||||
'props': {
|
||||
'model': 'pattern',
|
||||
'rows': '6',
|
||||
'label': '正则配置',
|
||||
'placeholder': '对应上方应用配置,一行一个,一一对应'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '根据正则表达式,把MoviePilot的消息转发到多个微信应用。'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '应用配置可加注释:'
|
||||
'appid:corpid:appsecret#站点通知'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"wechat": "",
|
||||
"pattern": ""
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
@eventmanager.register(EventType.NoticeMessage)
|
||||
def send(self, event):
|
||||
"""
|
||||
消息转发
|
||||
"""
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
# 消息体
|
||||
data = event.event_data
|
||||
channel = data['channel']
|
||||
if channel and channel != MessageChannel.Wechat:
|
||||
return
|
||||
|
||||
title = data['title']
|
||||
text = data['text']
|
||||
image = data['image']
|
||||
userid = data['userid']
|
||||
|
||||
# 正则匹配
|
||||
patterns = self._pattern.split("\n")
|
||||
for index, pattern in enumerate(patterns):
|
||||
msg_match = re.search(pattern, title)
|
||||
if msg_match:
|
||||
access_token, appid = self.__flush_access_token(index)
|
||||
if not access_token:
|
||||
logger.error("未获取到有效token,请检查配置")
|
||||
continue
|
||||
|
||||
# 发送消息
|
||||
if image:
|
||||
self.__send_image_message(title, text, image, userid, access_token, appid, index)
|
||||
else:
|
||||
self.__send_message(title, text, userid, access_token, appid, index)
|
||||
|
||||
def __save_wechat_token(self):
|
||||
"""
|
||||
获取并存储wechat token
|
||||
"""
|
||||
# 解析配置
|
||||
wechats = self._wechat.split("\n")
|
||||
for index, wechat in enumerate(wechats):
|
||||
# 排除注释
|
||||
wechat = wechat.split("#")[0]
|
||||
wechat_config = wechat.split(":")
|
||||
if len(wechat_config) != 3:
|
||||
logger.error(f"{wechat} 应用配置不正确")
|
||||
continue
|
||||
appid = wechat_config[0]
|
||||
corpid = wechat_config[1]
|
||||
appsecret = wechat_config[2]
|
||||
|
||||
# 已过期,重新获取token
|
||||
access_token, expires_in, access_token_time = self.__get_access_token(corpid=corpid,
|
||||
appsecret=appsecret)
|
||||
if not access_token:
|
||||
# 没有token,获取token
|
||||
logger.error(f"wechat配置 appid = {appid} 获取token失败,请检查配置")
|
||||
continue
|
||||
|
||||
self._pattern_token[index] = {
|
||||
"appid": appid,
|
||||
"corpid": corpid,
|
||||
"appsecret": appsecret,
|
||||
"access_token": access_token,
|
||||
"expires_in": expires_in,
|
||||
"access_token_time": access_token_time,
|
||||
}
|
||||
|
||||
def __flush_access_token(self, index: int, force: bool = False):
|
||||
"""
|
||||
获取第i个配置wechat token
|
||||
"""
|
||||
wechat_token = self._pattern_token[index]
|
||||
if not wechat_token:
|
||||
logger.error(f"未获取到第 {index} 条正则对应的wechat应用token,请检查配置")
|
||||
return None
|
||||
access_token = wechat_token['access_token']
|
||||
expires_in = wechat_token['expires_in']
|
||||
access_token_time = wechat_token['access_token_time']
|
||||
appid = wechat_token['appid']
|
||||
corpid = wechat_token['corpid']
|
||||
appsecret = wechat_token['appsecret']
|
||||
|
||||
# 判断token有效期
|
||||
if force or (datetime.now() - access_token_time).seconds >= expires_in:
|
||||
# 重新获取token
|
||||
access_token, expires_in, access_token_time = self.__get_access_token(corpid=corpid,
|
||||
appsecret=appsecret)
|
||||
if not access_token:
|
||||
logger.error(f"wechat配置 appid = {appid} 获取token失败,请检查配置")
|
||||
return None, None
|
||||
|
||||
self._pattern_token[index] = {
|
||||
"appid": appid,
|
||||
"corpid": corpid,
|
||||
"appsecret": appsecret,
|
||||
"access_token": access_token,
|
||||
"expires_in": expires_in,
|
||||
"access_token_time": access_token_time,
|
||||
}
|
||||
return access_token, appid
|
||||
|
||||
def __send_message(self, title: str, text: str = None, userid: str = None, access_token: str = None,
|
||||
appid: str = None, index: int = None) -> Optional[bool]:
|
||||
"""
|
||||
发送文本消息
|
||||
:param title: 消息标题
|
||||
:param text: 消息内容
|
||||
:param userid: 消息发送对象的ID,为空则发给所有人
|
||||
:return: 发送状态,错误信息
|
||||
"""
|
||||
if text:
|
||||
conent = "%s\n%s" % (title, text.replace("\n\n", "\n"))
|
||||
else:
|
||||
conent = title
|
||||
|
||||
if not userid:
|
||||
userid = "@all"
|
||||
req_json = {
|
||||
"touser": userid,
|
||||
"msgtype": "text",
|
||||
"agentid": appid,
|
||||
"text": {
|
||||
"content": conent
|
||||
},
|
||||
"safe": 0,
|
||||
"enable_id_trans": 0,
|
||||
"enable_duplicate_check": 0
|
||||
}
|
||||
return self.__post_request(access_token=access_token, req_json=req_json, index=index, title=title)
|
||||
|
||||
def __send_image_message(self, title: str, text: str, image_url: str, userid: str = None,
|
||||
access_token: str = None, appid: str = None, index: int = None) -> Optional[bool]:
|
||||
"""
|
||||
发送图文消息
|
||||
:param title: 消息标题
|
||||
:param text: 消息内容
|
||||
:param image_url: 图片地址
|
||||
:param userid: 消息发送对象的ID,为空则发给所有人
|
||||
:return: 发送状态,错误信息
|
||||
"""
|
||||
if text:
|
||||
text = text.replace("\n\n", "\n")
|
||||
if not userid:
|
||||
userid = "@all"
|
||||
req_json = {
|
||||
"touser": userid,
|
||||
"msgtype": "news",
|
||||
"agentid": appid,
|
||||
"news": {
|
||||
"articles": [
|
||||
{
|
||||
"title": title,
|
||||
"description": text,
|
||||
"picurl": image_url,
|
||||
"url": ''
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
return self.__post_request(access_token=access_token, req_json=req_json, index=index, title=title)
|
||||
|
||||
def __post_request(self, access_token: str, req_json: dict, index: int, title: str, retry: int = 0) -> bool:
|
||||
message_url = self._send_msg_url % access_token
|
||||
"""
|
||||
向微信发送请求
|
||||
"""
|
||||
try:
|
||||
res = RequestUtils(content_type='application/json').post(
|
||||
message_url,
|
||||
data=json.dumps(req_json, ensure_ascii=False).encode('utf-8')
|
||||
)
|
||||
if res and res.status_code == 200:
|
||||
ret_json = res.json()
|
||||
if ret_json.get('errcode') == 0:
|
||||
logger.info(f"转发消息 {title} 成功")
|
||||
return True
|
||||
else:
|
||||
if ret_json.get('errcode') == 81013:
|
||||
return False
|
||||
|
||||
logger.error(f"转发消息 {title} 失败,错误信息:{ret_json}")
|
||||
if ret_json.get('errcode') == 42001 or ret_json.get('errcode') == 40014:
|
||||
logger.info("token已过期,正在重新刷新token重试")
|
||||
# 重新获取token
|
||||
access_token, appid = self.__flush_access_token(index=index,
|
||||
force=True)
|
||||
if access_token:
|
||||
retry += 1
|
||||
# 重发请求
|
||||
if retry <= 3:
|
||||
return self.__post_request(access_token=access_token,
|
||||
req_json=req_json,
|
||||
index=index,
|
||||
title=title,
|
||||
retry=retry)
|
||||
return False
|
||||
elif res is not None:
|
||||
logger.error(f"转发消息 {title} 失败,错误码:{res.status_code},错误原因:{res.reason}")
|
||||
return False
|
||||
else:
|
||||
logger.error(f"转发消息 {title} 失败,未获取到返回信息")
|
||||
return False
|
||||
except Exception as err:
|
||||
logger.error(f"转发消息 {title} 异常,错误信息:{str(err)}")
|
||||
return False
|
||||
|
||||
def __get_access_token(self, corpid: str, appsecret: str):
|
||||
"""
|
||||
获取微信Token
|
||||
:return: 微信Token
|
||||
"""
|
||||
try:
|
||||
token_url = self._token_url % (corpid, appsecret)
|
||||
res = RequestUtils().get_res(token_url)
|
||||
if res:
|
||||
ret_json = res.json()
|
||||
if ret_json.get('errcode') == 0:
|
||||
access_token = ret_json.get('access_token')
|
||||
expires_in = ret_json.get('expires_in')
|
||||
access_token_time = datetime.now()
|
||||
|
||||
return access_token, expires_in, access_token_time
|
||||
else:
|
||||
logger.error(f"{ret_json.get('errmsg')}")
|
||||
return None, None, None
|
||||
else:
|
||||
logger.error(f"{corpid} {appsecret} 获取token失败")
|
||||
return None, None, None
|
||||
except Exception as e:
|
||||
logger.error(f"获取微信access_token失败,错误信息:{str(e)}")
|
||||
return None, None, None
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
退出插件
|
||||
"""
|
||||
pass
|
||||
1026
plugins.v2/personmeta/__init__.py
Normal file
1026
plugins.v2/personmeta/__init__.py
Normal file
File diff suppressed because it is too large
Load Diff
1171
plugins.v2/qbcommand/__init__.py
Normal file
1171
plugins.v2/qbcommand/__init__.py
Normal file
File diff suppressed because it is too large
Load Diff
775
plugins.v2/rsssubscribe/__init__.py
Normal file
775
plugins.v2/rsssubscribe/__init__.py
Normal file
@@ -0,0 +1,775 @@
|
||||
import datetime
|
||||
import re
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Optional, Any, List, Dict, Tuple
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from app import schemas
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.search import SearchChain
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo, TorrentInfo, Context
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.helper.rss import RssHelper
|
||||
from app.log import logger
|
||||
from app.plugins import _PluginBase
|
||||
from app.schemas import ExistMediaInfo
|
||||
from app.schemas.types import SystemConfigKey, MediaType
|
||||
|
||||
lock = Lock()
|
||||
|
||||
|
||||
class RssSubscribe(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "自定义订阅"
|
||||
# 插件描述
|
||||
plugin_desc = "定时刷新RSS报文,识别内容后添加订阅或直接下载。"
|
||||
# 插件图标
|
||||
plugin_icon = "rss.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.5"
|
||||
# 插件作者
|
||||
plugin_author = "jxxghp"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/jxxghp"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "rsssubscribe_"
|
||||
# 加载顺序
|
||||
plugin_order = 19
|
||||
# 可使用的用户级别
|
||||
auth_level = 2
|
||||
|
||||
# 私有变量
|
||||
_scheduler: Optional[BackgroundScheduler] = None
|
||||
_cache_path: Optional[Path] = None
|
||||
rsshelper = None
|
||||
downloadchain = None
|
||||
searchchain = None
|
||||
subscribechain = None
|
||||
|
||||
# 配置属性
|
||||
_enabled: bool = False
|
||||
_cron: str = ""
|
||||
_notify: bool = False
|
||||
_onlyonce: bool = False
|
||||
_address: str = ""
|
||||
_include: str = ""
|
||||
_exclude: str = ""
|
||||
_proxy: bool = False
|
||||
_filter: bool = False
|
||||
_clear: bool = False
|
||||
_clearflag: bool = False
|
||||
_action: str = "subscribe"
|
||||
_save_path: str = ""
|
||||
_size_range: str = ""
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
self.rsshelper = RssHelper()
|
||||
self.downloadchain = DownloadChain()
|
||||
self.searchchain = SearchChain()
|
||||
self.subscribechain = SubscribeChain()
|
||||
|
||||
# 停止现有任务
|
||||
self.stop_service()
|
||||
|
||||
# 配置
|
||||
if config:
|
||||
self.__validate_and_fix_config(config=config)
|
||||
self._enabled = config.get("enabled")
|
||||
self._cron = config.get("cron")
|
||||
self._notify = config.get("notify")
|
||||
self._onlyonce = config.get("onlyonce")
|
||||
self._address = config.get("address")
|
||||
self._include = config.get("include")
|
||||
self._exclude = config.get("exclude")
|
||||
self._proxy = config.get("proxy")
|
||||
self._filter = config.get("filter")
|
||||
self._clear = config.get("clear")
|
||||
self._action = config.get("action")
|
||||
self._save_path = config.get("save_path")
|
||||
self._size_range = config.get("size_range")
|
||||
|
||||
if self._onlyonce:
|
||||
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
logger.info(f"自定义订阅服务启动,立即运行一次")
|
||||
self._scheduler.add_job(func=self.check, trigger='date',
|
||||
run_date=datetime.datetime.now(
|
||||
tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3)
|
||||
)
|
||||
|
||||
# 启动任务
|
||||
if self._scheduler.get_jobs():
|
||||
self._scheduler.print_jobs()
|
||||
self._scheduler.start()
|
||||
|
||||
if self._onlyonce or self._clear:
|
||||
# 关闭一次性开关
|
||||
self._onlyonce = False
|
||||
# 记录清理缓存设置
|
||||
self._clearflag = self._clear
|
||||
# 关闭清理缓存开关
|
||||
self._clear = False
|
||||
# 保存设置
|
||||
self.__update_config()
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
定义远程控制命令
|
||||
:return: 命令关键字、事件、描述、附带数据
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取插件API
|
||||
[{
|
||||
"path": "/xx",
|
||||
"endpoint": self.xxx,
|
||||
"methods": ["GET", "POST"],
|
||||
"summary": "API说明"
|
||||
}]
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"path": "/delete_history",
|
||||
"endpoint": self.delete_history,
|
||||
"methods": ["GET"],
|
||||
"summary": "删除自定义订阅历史记录"
|
||||
}
|
||||
]
|
||||
|
||||
def get_service(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
注册插件公共服务
|
||||
[{
|
||||
"id": "服务ID",
|
||||
"name": "服务名称",
|
||||
"trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()",
|
||||
"func": self.xxx,
|
||||
"kwargs": {} # 定时器参数
|
||||
}]
|
||||
"""
|
||||
if self._enabled and self._cron:
|
||||
return [{
|
||||
"id": "RssSubscribe",
|
||||
"name": "自定义订阅服务",
|
||||
"trigger": CronTrigger.from_crontab(self._cron),
|
||||
"func": self.check,
|
||||
"kwargs": {}
|
||||
}]
|
||||
elif self._enabled:
|
||||
return [{
|
||||
"id": "RssSubscribe",
|
||||
"name": "自定义订阅服务",
|
||||
"trigger": "interval",
|
||||
"func": self.check,
|
||||
"kwargs": {"minutes": 30}
|
||||
}]
|
||||
return []
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enabled',
|
||||
'label': '启用插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'notify',
|
||||
'label': '发送通知',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'onlyonce',
|
||||
'label': '立即运行一次',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'cron',
|
||||
'label': '执行周期',
|
||||
'placeholder': '5位cron表达式,留空自动'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSelect',
|
||||
'props': {
|
||||
'model': 'action',
|
||||
'label': '动作',
|
||||
'items': [
|
||||
{'title': '订阅', 'value': 'subscribe'},
|
||||
{'title': '下载', 'value': 'download'}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextarea',
|
||||
'props': {
|
||||
'model': 'address',
|
||||
'label': 'RSS地址',
|
||||
'rows': 3,
|
||||
'placeholder': '每行一个RSS地址'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'include',
|
||||
'label': '包含',
|
||||
'placeholder': '支持正则表达式'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'exclude',
|
||||
'label': '排除',
|
||||
'placeholder': '支持正则表达式'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'size_range',
|
||||
'label': '种子大小(GB)',
|
||||
'placeholder': '如:3 或 3-5'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'save_path',
|
||||
'label': '保存目录',
|
||||
'placeholder': '下载时有效,留空自动'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'proxy',
|
||||
'label': '使用代理服务器',
|
||||
}
|
||||
}
|
||||
]
|
||||
}, {
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'filter',
|
||||
'label': '使用过滤规则',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'clear',
|
||||
'label': '清理历史记录',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"notify": True,
|
||||
"onlyonce": False,
|
||||
"cron": "*/30 * * * *",
|
||||
"address": "",
|
||||
"include": "",
|
||||
"exclude": "",
|
||||
"proxy": False,
|
||||
"clear": False,
|
||||
"filter": False,
|
||||
"action": "subscribe",
|
||||
"save_path": "",
|
||||
"size_range": ""
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
"""
|
||||
拼装插件详情页面,需要返回页面配置,同时附带数据
|
||||
"""
|
||||
# 查询同步详情
|
||||
historys = self.get_data('history')
|
||||
if not historys:
|
||||
return [
|
||||
{
|
||||
'component': 'div',
|
||||
'text': '暂无数据',
|
||||
'props': {
|
||||
'class': 'text-center',
|
||||
}
|
||||
}
|
||||
]
|
||||
# 数据按时间降序排序
|
||||
historys = sorted(historys, key=lambda x: x.get('time'), reverse=True)
|
||||
# 拼装页面
|
||||
contents = []
|
||||
for history in historys:
|
||||
title = history.get("title")
|
||||
poster = history.get("poster")
|
||||
mtype = history.get("type")
|
||||
time_str = history.get("time")
|
||||
contents.append(
|
||||
{
|
||||
'component': 'VCard',
|
||||
'content': [
|
||||
{
|
||||
"component": "VDialogCloseBtn",
|
||||
"props": {
|
||||
'innerClass': 'absolute top-0 right-0',
|
||||
},
|
||||
'events': {
|
||||
'click': {
|
||||
'api': 'plugin/RssSubscribe/delete_history',
|
||||
'method': 'get',
|
||||
'params': {
|
||||
'key': title,
|
||||
'apikey': settings.API_TOKEN
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
'component': 'div',
|
||||
'props': {
|
||||
'class': 'd-flex justify-space-start flex-nowrap flex-row',
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'div',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VImg',
|
||||
'props': {
|
||||
'src': poster,
|
||||
'height': 120,
|
||||
'width': 80,
|
||||
'aspect-ratio': '2/3',
|
||||
'class': 'object-cover shadow ring-gray-500',
|
||||
'cover': True
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'div',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCardTitle',
|
||||
'props': {
|
||||
'class': 'pa-1 pe-5 break-words whitespace-break-spaces'
|
||||
},
|
||||
'text': title
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'类型:{mtype}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'时间:{time_str}'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
'component': 'div',
|
||||
'props': {
|
||||
'class': 'grid gap-3 grid-info-card',
|
||||
},
|
||||
'content': contents
|
||||
}
|
||||
]
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
退出插件
|
||||
"""
|
||||
try:
|
||||
if self._scheduler:
|
||||
self._scheduler.remove_all_jobs()
|
||||
if self._scheduler.running:
|
||||
self._scheduler.shutdown()
|
||||
self._scheduler = None
|
||||
except Exception as e:
|
||||
logger.error("退出插件失败:%s" % str(e))
|
||||
|
||||
def delete_history(self, key: str, apikey: str):
|
||||
"""
|
||||
删除同步历史记录
|
||||
"""
|
||||
if apikey != settings.API_TOKEN:
|
||||
return schemas.Response(success=False, message="API密钥错误")
|
||||
# 历史记录
|
||||
historys = self.get_data('history')
|
||||
if not historys:
|
||||
return schemas.Response(success=False, message="未找到历史记录")
|
||||
# 删除指定记录
|
||||
historys = [h for h in historys if h.get("title") != key]
|
||||
self.save_data('history', historys)
|
||||
return schemas.Response(success=True, message="删除成功")
|
||||
|
||||
def __update_config(self):
|
||||
"""
|
||||
更新设置
|
||||
"""
|
||||
self.update_config({
|
||||
"enabled": self._enabled,
|
||||
"notify": self._notify,
|
||||
"onlyonce": self._onlyonce,
|
||||
"cron": self._cron,
|
||||
"address": self._address,
|
||||
"include": self._include,
|
||||
"exclude": self._exclude,
|
||||
"proxy": self._proxy,
|
||||
"clear": self._clear,
|
||||
"filter": self._filter,
|
||||
"action": self._action,
|
||||
"save_path": self._save_path,
|
||||
"size_range": self._size_range
|
||||
})
|
||||
|
||||
def check(self):
|
||||
"""
|
||||
通过用户RSS同步豆瓣想看数据
|
||||
"""
|
||||
if not self._address:
|
||||
return
|
||||
# 读取历史记录
|
||||
if self._clearflag:
|
||||
history = []
|
||||
else:
|
||||
history: List[dict] = self.get_data('history') or []
|
||||
for url in self._address.split("\n"):
|
||||
# 处理每一个RSS链接
|
||||
if not url:
|
||||
continue
|
||||
logger.info(f"开始刷新RSS:{url} ...")
|
||||
results = self.rsshelper.parse(url, proxy=self._proxy)
|
||||
if not results:
|
||||
logger.error(f"未获取到RSS数据:{url}")
|
||||
return
|
||||
# 过滤规则
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.SubscribeFilterRules)
|
||||
# 解析数据
|
||||
for result in results:
|
||||
try:
|
||||
title = result.get("title")
|
||||
description = result.get("description")
|
||||
enclosure = result.get("enclosure")
|
||||
link = result.get("link")
|
||||
size = result.get("size")
|
||||
pubdate: datetime.datetime = result.get("pubdate")
|
||||
# 检查是否处理过
|
||||
if not title or title in [h.get("key") for h in history]:
|
||||
continue
|
||||
# 检查规则
|
||||
if self._include and not re.search(r"%s" % self._include,
|
||||
f"{title} {description}", re.IGNORECASE):
|
||||
logger.info(f"{title} - {description} 不符合包含规则")
|
||||
continue
|
||||
if self._exclude and re.search(r"%s" % self._exclude,
|
||||
f"{title} {description}", re.IGNORECASE):
|
||||
logger.info(f"{title} - {description} 不符合排除规则")
|
||||
continue
|
||||
if self._size_range:
|
||||
sizes = [float(_size) * 1024 ** 3 for _size in self._size_range.split("-")]
|
||||
if len(sizes) == 1 and float(size) < sizes[0]:
|
||||
logger.info(f"{title} - 种子大小不符合条件")
|
||||
continue
|
||||
elif len(sizes) > 1 and not sizes[0] <= float(size) <= sizes[1]:
|
||||
logger.info(f"{title} - 种子大小不在指定范围")
|
||||
continue
|
||||
# 识别媒体信息
|
||||
meta = MetaInfo(title=title, subtitle=description)
|
||||
if not meta.name:
|
||||
logger.warn(f"{title} 未识别到有效数据")
|
||||
continue
|
||||
mediainfo: MediaInfo = self.chain.recognize_media(meta=meta)
|
||||
if not mediainfo:
|
||||
logger.warn(f'未识别到媒体信息,标题:{title}')
|
||||
continue
|
||||
# 种子
|
||||
torrentinfo = TorrentInfo(
|
||||
title=title,
|
||||
description=description,
|
||||
enclosure=enclosure,
|
||||
page_url=link,
|
||||
size=size,
|
||||
pubdate=pubdate.strftime("%Y-%m-%d %H:%M:%S") if pubdate else None,
|
||||
site_proxy=self._proxy,
|
||||
)
|
||||
# 过滤种子
|
||||
if self._filter:
|
||||
result = self.chain.filter_torrents(
|
||||
rule_string=filter_rule,
|
||||
torrent_list=[torrentinfo],
|
||||
mediainfo=mediainfo
|
||||
)
|
||||
if not result:
|
||||
logger.info(f"{title} {description} 不匹配过滤规则")
|
||||
continue
|
||||
# 媒体库已存在的剧集
|
||||
exist_info: Optional[ExistMediaInfo] = self.chain.media_exists(mediainfo=mediainfo)
|
||||
if mediainfo.type == MediaType.TV:
|
||||
if exist_info:
|
||||
exist_season = exist_info.seasons
|
||||
if exist_season:
|
||||
exist_episodes = exist_season.get(meta.begin_season)
|
||||
if exist_episodes and set(meta.episode_list).issubset(set(exist_episodes)):
|
||||
logger.info(f'{mediainfo.title_year} {meta.season_episode} 己存在')
|
||||
continue
|
||||
elif exist_info:
|
||||
# 电影已存在
|
||||
logger.info(f'{mediainfo.title_year} 己存在')
|
||||
continue
|
||||
# 下载或订阅
|
||||
if self._action == "download":
|
||||
# 添加下载
|
||||
result = self.downloadchain.download_single(
|
||||
context=Context(
|
||||
meta_info=meta,
|
||||
media_info=mediainfo,
|
||||
torrent_info=torrentinfo,
|
||||
),
|
||||
save_path=self._save_path,
|
||||
username="RSS订阅"
|
||||
)
|
||||
if not result:
|
||||
logger.error(f'{title} 下载失败')
|
||||
continue
|
||||
else:
|
||||
# 检查是否在订阅中
|
||||
subflag = self.subscribechain.exists(mediainfo=mediainfo, meta=meta)
|
||||
if subflag:
|
||||
logger.info(f'{mediainfo.title_year} {meta.season} 正在订阅中')
|
||||
continue
|
||||
# 添加订阅
|
||||
self.subscribechain.add(title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
mtype=mediainfo.type,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
season=meta.begin_season,
|
||||
exist_ok=True,
|
||||
username="RSS订阅")
|
||||
# 存储历史记录
|
||||
history.append({
|
||||
"title": f"{mediainfo.title} {meta.season}",
|
||||
"key": f"{title}",
|
||||
"type": mediainfo.type.value,
|
||||
"year": mediainfo.year,
|
||||
"poster": mediainfo.get_poster_image(),
|
||||
"overview": mediainfo.overview,
|
||||
"tmdbid": mediainfo.tmdb_id,
|
||||
"time": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
})
|
||||
except Exception as err:
|
||||
logger.error(f'刷新RSS数据出错:{str(err)} - {traceback.format_exc()}')
|
||||
logger.info(f"RSS {url} 刷新完成")
|
||||
# 保存历史记录
|
||||
self.save_data('history', history)
|
||||
# 缓存只清理一次
|
||||
self._clearflag = False
|
||||
|
||||
def __log_and_notify_error(self, message):
|
||||
"""
|
||||
记录错误日志并发送系统通知
|
||||
"""
|
||||
logger.error(message)
|
||||
self.systemmessage.put(message, title="自定义订阅")
|
||||
|
||||
def __validate_and_fix_config(self, config: dict = None) -> bool:
|
||||
"""
|
||||
检查并修正配置值
|
||||
"""
|
||||
size_range = config.get("size_range")
|
||||
if size_range and not self.__is_number_or_range(str(size_range)):
|
||||
self.__log_and_notify_error(f"自定义订阅出错,种子大小设置错误:{size_range}")
|
||||
config["size_range"] = None
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def __is_number_or_range(value):
|
||||
"""
|
||||
检查字符串是否表示单个数字或数字范围(如'5', '5.5', '5-10' 或 '5.5-10.2')
|
||||
"""
|
||||
return bool(re.match(r"^\d+(\.\d+)?(-\d+(\.\d+)?)?$", value))
|
||||
660
plugins.v2/speedlimiter/__init__.py
Normal file
660
plugins.v2/speedlimiter/__init__.py
Normal file
@@ -0,0 +1,660 @@
|
||||
import ipaddress
|
||||
from typing import List, Tuple, Dict, Any
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.log import logger
|
||||
from app.modules.emby import Emby
|
||||
from app.modules.jellyfin import Jellyfin
|
||||
from app.modules.plex import Plex
|
||||
from app.modules.qbittorrent import Qbittorrent
|
||||
from app.modules.transmission import Transmission
|
||||
from app.plugins import _PluginBase
|
||||
from app.schemas import NotificationType, WebhookEventInfo
|
||||
from app.schemas.types import EventType
|
||||
from app.utils.ip import IpUtils
|
||||
|
||||
|
||||
class SpeedLimiter(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "播放限速"
|
||||
# 插件描述
|
||||
plugin_desc = "外网播放媒体库视频时,自动对下载器进行限速。"
|
||||
# 插件图标
|
||||
plugin_icon = "Librespeed_A.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.2"
|
||||
# 插件作者
|
||||
plugin_author = "Shurelol"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/Shurelol"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "speedlimit_"
|
||||
# 加载顺序
|
||||
plugin_order = 11
|
||||
# 可使用的用户级别
|
||||
auth_level = 1
|
||||
|
||||
# 私有属性
|
||||
_scheduler = None
|
||||
_qb = None
|
||||
_tr = None
|
||||
_enabled: bool = False
|
||||
_notify: bool = False
|
||||
_interval: int = 60
|
||||
_downloader: list = []
|
||||
_play_up_speed: float = 0
|
||||
_play_down_speed: float = 0
|
||||
_noplay_up_speed: float = 0
|
||||
_noplay_down_speed: float = 0
|
||||
_bandwidth: float = 0
|
||||
_allocation_ratio: str = ""
|
||||
_auto_limit: bool = False
|
||||
_limit_enabled: bool = False
|
||||
# 不限速地址
|
||||
_unlimited_ips = {}
|
||||
# 当前限速状态
|
||||
_current_state = ""
|
||||
_exclude_path = ""
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
# 读取配置
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._notify = config.get("notify")
|
||||
self._play_up_speed = float(config.get("play_up_speed")) if config.get("play_up_speed") else 0
|
||||
self._play_down_speed = float(config.get("play_down_speed")) if config.get("play_down_speed") else 0
|
||||
self._noplay_up_speed = float(config.get("noplay_up_speed")) if config.get("noplay_up_speed") else 0
|
||||
self._noplay_down_speed = float(config.get("noplay_down_speed")) if config.get("noplay_down_speed") else 0
|
||||
self._current_state = f"U:{self._noplay_up_speed},D:{self._noplay_down_speed}"
|
||||
self._exclude_path = config.get("exclude_path")
|
||||
|
||||
try:
|
||||
# 总带宽
|
||||
self._bandwidth = int(float(config.get("bandwidth") or 0)) * 1000000
|
||||
# 自动限速开关
|
||||
if self._bandwidth > 0:
|
||||
self._auto_limit = True
|
||||
else:
|
||||
self._auto_limit = False
|
||||
except Exception as e:
|
||||
logger.error(f"智能限速上行带宽设置错误:{str(e)}")
|
||||
self._bandwidth = 0
|
||||
|
||||
# 限速服务开关
|
||||
self._limit_enabled = True if (self._play_up_speed
|
||||
or self._play_down_speed
|
||||
or self._auto_limit) else False
|
||||
self._allocation_ratio = config.get("allocation_ratio") or ""
|
||||
# 不限速地址
|
||||
self._unlimited_ips["ipv4"] = config.get("ipv4") or ""
|
||||
self._unlimited_ips["ipv6"] = config.get("ipv6") or ""
|
||||
|
||||
self._downloader = config.get("downloader") or []
|
||||
if self._downloader:
|
||||
if 'qbittorrent' in self._downloader:
|
||||
self._qb = Qbittorrent()
|
||||
if 'transmission' in self._downloader:
|
||||
self._tr = Transmission()
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_service(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
注册插件公共服务
|
||||
[{
|
||||
"id": "服务ID",
|
||||
"name": "服务名称",
|
||||
"trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()",
|
||||
"func": self.xxx,
|
||||
"kwargs": {} # 定时器参数
|
||||
}]
|
||||
"""
|
||||
if self._enabled and self._limit_enabled and self._interval:
|
||||
return [
|
||||
{
|
||||
"id": "SpeedLimiter",
|
||||
"name": "播放限速检查服务",
|
||||
"trigger": "interval",
|
||||
"func": self.check_playing_sessions,
|
||||
"kwargs": {"seconds": self._interval}
|
||||
}
|
||||
]
|
||||
return []
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enabled',
|
||||
'label': '启用插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'notify',
|
||||
'label': '发送通知',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSelect',
|
||||
'props': {
|
||||
'chips': True,
|
||||
'multiple': True,
|
||||
'model': 'downloader',
|
||||
'label': '下载器',
|
||||
'items': [
|
||||
{'title': 'Qbittorrent', 'value': 'qbittorrent'},
|
||||
{'title': 'Transmission', 'value': 'transmission'},
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'play_up_speed',
|
||||
'label': '播放限速(上传)',
|
||||
'placeholder': 'KB/s'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'play_down_speed',
|
||||
'label': '播放限速(下载)',
|
||||
'placeholder': 'KB/s'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'noplay_up_speed',
|
||||
'label': '未播放限速(上传)',
|
||||
'placeholder': 'KB/s'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'noplay_down_speed',
|
||||
'label': '未播放限速(下载)',
|
||||
'placeholder': 'KB/s'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'bandwidth',
|
||||
'label': '智能限速上行带宽',
|
||||
'placeholder': 'Mbps'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSelect',
|
||||
'props': {
|
||||
'model': 'allocation_ratio',
|
||||
'label': '智能限速分配比例',
|
||||
'items': [
|
||||
{'title': '平均', 'value': ''},
|
||||
{'title': '1:9', 'value': '1:9'},
|
||||
{'title': '2:8', 'value': '2:8'},
|
||||
{'title': '3:7', 'value': '3:7'},
|
||||
{'title': '4:6', 'value': '4:6'},
|
||||
{'title': '6:4', 'value': '6:4'},
|
||||
{'title': '7:3', 'value': '7:3'},
|
||||
{'title': '8:2', 'value': '8:2'},
|
||||
{'title': '9:1', 'value': '9:1'},
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'ipv4',
|
||||
'label': '不限速地址范围(ipv4)',
|
||||
'placeholder': '留空默认不限速内网ipv4'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'ipv6',
|
||||
'label': '不限速地址范围(ipv6)',
|
||||
'placeholder': '留空默认不限速内网ipv6'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'exclude_path',
|
||||
'label': '不限速路径',
|
||||
'placeholder': '包含该路径的媒体不限速,多个请换行'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"notify": True,
|
||||
"downloader": [],
|
||||
"play_up_speed": None,
|
||||
"play_down_speed": None,
|
||||
"noplay_up_speed": None,
|
||||
"noplay_down_speed": None,
|
||||
"bandwidth": None,
|
||||
"allocation_ratio": "",
|
||||
"ipv4": "",
|
||||
"ipv6": "",
|
||||
"exclude_path": ""
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
@eventmanager.register(EventType.WebhookMessage)
|
||||
def check_playing_sessions(self, event: Event = None):
|
||||
"""
|
||||
检查播放会话
|
||||
"""
|
||||
if not self._qb and not self._tr:
|
||||
return
|
||||
if not self._enabled:
|
||||
return
|
||||
if event:
|
||||
event_data: WebhookEventInfo = event.event_data
|
||||
if event_data.event not in [
|
||||
"playback.start",
|
||||
"PlaybackStart",
|
||||
"media.play",
|
||||
"media.stop",
|
||||
"PlaybackStop",
|
||||
"playback.stop"
|
||||
]:
|
||||
return
|
||||
# 当前播放的总比特率
|
||||
total_bit_rate = 0
|
||||
# 媒体服务器类型,多个以,分隔
|
||||
if not settings.MEDIASERVER:
|
||||
return
|
||||
media_servers = settings.MEDIASERVER.split(',')
|
||||
# 查询所有媒体服务器状态
|
||||
for media_server in media_servers:
|
||||
# 查询播放中会话
|
||||
playing_sessions = []
|
||||
if media_server == "emby":
|
||||
req_url = "[HOST]emby/Sessions?api_key=[APIKEY]"
|
||||
try:
|
||||
res = Emby().get_data(req_url)
|
||||
if res and res.status_code == 200:
|
||||
sessions = res.json()
|
||||
for session in sessions:
|
||||
if session.get("NowPlayingItem") and not session.get("PlayState", {}).get("IsPaused"):
|
||||
if not self.__path_execluded(session.get("NowPlayingItem").get("Path")):
|
||||
playing_sessions.append(session)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取Emby播放会话失败:{str(e)}")
|
||||
continue
|
||||
# 计算有效比特率
|
||||
for session in playing_sessions:
|
||||
# 设置了不限速范围则判断session ip是否在不限速范围内
|
||||
if self._unlimited_ips["ipv4"] or self._unlimited_ips["ipv6"]:
|
||||
if not self.__allow_access(self._unlimited_ips, session.get("RemoteEndPoint")) \
|
||||
and session.get("NowPlayingItem", {}).get("MediaType") == "Video":
|
||||
total_bit_rate += int(session.get("NowPlayingItem", {}).get("Bitrate") or 0)
|
||||
# 未设置不限速范围,则默认不限速内网ip
|
||||
elif not IpUtils.is_private_ip(session.get("RemoteEndPoint")) \
|
||||
and session.get("NowPlayingItem", {}).get("MediaType") == "Video":
|
||||
total_bit_rate += int(session.get("NowPlayingItem", {}).get("Bitrate") or 0)
|
||||
elif media_server == "jellyfin":
|
||||
req_url = "[HOST]Sessions?api_key=[APIKEY]"
|
||||
try:
|
||||
res = Jellyfin().get_data(req_url)
|
||||
if res and res.status_code == 200:
|
||||
sessions = res.json()
|
||||
for session in sessions:
|
||||
if session.get("NowPlayingItem") and not session.get("PlayState", {}).get("IsPaused"):
|
||||
if not self.__path_execluded(session.get("NowPlayingItem").get("Path")):
|
||||
playing_sessions.append(session)
|
||||
except Exception as e:
|
||||
logger.error(f"获取Jellyfin播放会话失败:{str(e)}")
|
||||
continue
|
||||
# 计算有效比特率
|
||||
for session in playing_sessions:
|
||||
# 设置了不限速范围则判断session ip是否在不限速范围内
|
||||
if self._unlimited_ips["ipv4"] or self._unlimited_ips["ipv6"]:
|
||||
if not self.__allow_access(self._unlimited_ips, session.get("RemoteEndPoint")) \
|
||||
and session.get("NowPlayingItem", {}).get("MediaType") == "Video":
|
||||
media_streams = session.get("NowPlayingItem", {}).get("MediaStreams") or []
|
||||
for media_stream in media_streams:
|
||||
total_bit_rate += int(media_stream.get("BitRate") or 0)
|
||||
# 未设置不限速范围,则默认不限速内网ip
|
||||
elif not IpUtils.is_private_ip(session.get("RemoteEndPoint")) \
|
||||
and session.get("NowPlayingItem", {}).get("MediaType") == "Video":
|
||||
media_streams = session.get("NowPlayingItem", {}).get("MediaStreams") or []
|
||||
for media_stream in media_streams:
|
||||
total_bit_rate += int(media_stream.get("BitRate") or 0)
|
||||
elif media_server == "plex":
|
||||
_plex = Plex().get_plex()
|
||||
if _plex:
|
||||
sessions = _plex.sessions()
|
||||
for session in sessions:
|
||||
bitrate = sum([m.bitrate or 0 for m in session.media])
|
||||
playing_sessions.append({
|
||||
"type": session.TAG,
|
||||
"bitrate": bitrate,
|
||||
"address": session.player.address
|
||||
})
|
||||
# 计算有效比特率
|
||||
for session in playing_sessions:
|
||||
# 设置了不限速范围则判断session ip是否在不限速范围内
|
||||
if self._unlimited_ips["ipv4"] or self._unlimited_ips["ipv6"]:
|
||||
if not self.__allow_access(self._unlimited_ips, session.get("address")) \
|
||||
and session.get("type") == "Video":
|
||||
total_bit_rate += int(session.get("bitrate") or 0)
|
||||
# 未设置不限速范围,则默认不限速内网ip
|
||||
elif not IpUtils.is_private_ip(session.get("address")) \
|
||||
and session.get("type") == "Video":
|
||||
total_bit_rate += int(session.get("bitrate") or 0)
|
||||
|
||||
if total_bit_rate:
|
||||
# 开启智能限速计算上传限速
|
||||
if self._auto_limit:
|
||||
play_up_speed = self.__calc_limit(total_bit_rate)
|
||||
else:
|
||||
play_up_speed = self._play_up_speed
|
||||
|
||||
# 当前正在播放,开始限速
|
||||
self.__set_limiter(limit_type="播放", upload_limit=play_up_speed,
|
||||
download_limit=self._play_down_speed)
|
||||
else:
|
||||
# 当前没有播放,取消限速
|
||||
self.__set_limiter(limit_type="未播放", upload_limit=self._noplay_up_speed,
|
||||
download_limit=self._noplay_down_speed)
|
||||
|
||||
def __path_execluded(self, path: str) -> bool:
|
||||
"""
|
||||
判断是否在不限速路径内
|
||||
"""
|
||||
if self._exclude_path:
|
||||
exclude_paths = self._exclude_path.split("\n")
|
||||
for exclude_path in exclude_paths:
|
||||
if exclude_path in path:
|
||||
logger.info(f"{path} 在不限速路径:{exclude_path} 内,跳过限速")
|
||||
return True
|
||||
return False
|
||||
|
||||
def __calc_limit(self, total_bit_rate: float) -> float:
|
||||
"""
|
||||
计算智能上传限速
|
||||
"""
|
||||
if not self._bandwidth:
|
||||
return 10
|
||||
return round((self._bandwidth - total_bit_rate) / 8 / 1024, 2)
|
||||
|
||||
def __set_limiter(self, limit_type: str, upload_limit: float, download_limit: float):
|
||||
"""
|
||||
设置限速
|
||||
"""
|
||||
if not self._qb and not self._tr:
|
||||
return
|
||||
state = f"U:{upload_limit},D:{download_limit}"
|
||||
if self._current_state == state:
|
||||
# 限速状态没有改变
|
||||
return
|
||||
else:
|
||||
self._current_state = state
|
||||
|
||||
try:
|
||||
cnt = 0
|
||||
for download in self._downloader:
|
||||
if self._auto_limit and limit_type == "播放":
|
||||
# 开启了播放智能限速
|
||||
if len(self._downloader) == 1:
|
||||
# 只有一个下载器
|
||||
upload_limit = int(upload_limit)
|
||||
else:
|
||||
# 多个下载器
|
||||
if not self._allocation_ratio:
|
||||
# 平均
|
||||
upload_limit = int(upload_limit / len(self._downloader))
|
||||
else:
|
||||
# 按比例
|
||||
allocation_count = sum([int(i) for i in self._allocation_ratio.split(":")])
|
||||
upload_limit = int(upload_limit * int(self._allocation_ratio.split(":")[cnt]) / allocation_count)
|
||||
cnt += 1
|
||||
if upload_limit:
|
||||
text = f"上传:{upload_limit} KB/s"
|
||||
else:
|
||||
text = f"上传:未限速"
|
||||
if download_limit:
|
||||
text = f"{text}\n下载:{download_limit} KB/s"
|
||||
else:
|
||||
text = f"{text}\n下载:未限速"
|
||||
if str(download) == 'qbittorrent':
|
||||
if self._qb:
|
||||
self._qb.set_speed_limit(download_limit=download_limit, upload_limit=upload_limit)
|
||||
# 发送通知
|
||||
if self._notify:
|
||||
title = "【播放限速】"
|
||||
if upload_limit or download_limit:
|
||||
subtitle = f"Qbittorrent 开始{limit_type}限速"
|
||||
self.post_message(
|
||||
mtype=NotificationType.MediaServer,
|
||||
title=title,
|
||||
text=f"{subtitle}\n{text}"
|
||||
)
|
||||
else:
|
||||
self.post_message(
|
||||
mtype=NotificationType.MediaServer,
|
||||
title=title,
|
||||
text=f"Qbittorrent 已取消限速"
|
||||
)
|
||||
else:
|
||||
if self._tr:
|
||||
self._tr.set_speed_limit(download_limit=download_limit, upload_limit=upload_limit)
|
||||
# 发送通知
|
||||
if self._notify:
|
||||
title = "【播放限速】"
|
||||
if upload_limit or download_limit:
|
||||
subtitle = f"Transmission 开始{limit_type}限速"
|
||||
self.post_message(
|
||||
mtype=NotificationType.MediaServer,
|
||||
title=title,
|
||||
text=f"{subtitle}\n{text}"
|
||||
)
|
||||
else:
|
||||
self.post_message(
|
||||
mtype=NotificationType.MediaServer,
|
||||
title=title,
|
||||
text=f"Transmission 已取消限速"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"设置限速失败:{str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def __allow_access(allow_ips: dict, ip: str) -> bool:
|
||||
"""
|
||||
判断IP是否合法
|
||||
:param allow_ips: 充许的IP范围 {"ipv4":, "ipv6":}
|
||||
:param ip: 需要检查的ip
|
||||
"""
|
||||
if not allow_ips:
|
||||
return True
|
||||
try:
|
||||
ipaddr = ipaddress.ip_address(ip)
|
||||
if ipaddr.version == 4:
|
||||
if not allow_ips.get('ipv4'):
|
||||
return True
|
||||
allow_ipv4s = allow_ips.get('ipv4').split(",")
|
||||
for allow_ipv4 in allow_ipv4s:
|
||||
if ipaddr in ipaddress.ip_network(allow_ipv4, strict=False):
|
||||
return True
|
||||
elif ipaddr.ipv4_mapped:
|
||||
if not allow_ips.get('ipv4'):
|
||||
return True
|
||||
allow_ipv4s = allow_ips.get('ipv4').split(",")
|
||||
for allow_ipv4 in allow_ipv4s:
|
||||
if ipaddr.ipv4_mapped in ipaddress.ip_network(allow_ipv4, strict=False):
|
||||
return True
|
||||
else:
|
||||
if not allow_ips.get('ipv6'):
|
||||
return True
|
||||
allow_ipv6s = allow_ips.get('ipv6').split(",")
|
||||
for allow_ipv6 in allow_ipv6s:
|
||||
if ipaddr in ipaddress.ip_network(allow_ipv6, strict=False):
|
||||
return True
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
return False
|
||||
return False
|
||||
|
||||
def stop_service(self):
|
||||
pass
|
||||
579
plugins.v2/syncdownloadfiles/__init__.py
Normal file
579
plugins.v2/syncdownloadfiles/__init__.py
Normal file
@@ -0,0 +1,579 @@
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Dict, Tuple, Optional
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||
from app.db.transferhistory_oper import TransferHistoryOper
|
||||
from app.log import logger
|
||||
from app.modules.qbittorrent import Qbittorrent
|
||||
from app.modules.transmission import Transmission
|
||||
from app.plugins import _PluginBase
|
||||
|
||||
|
||||
class SyncDownloadFiles(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "下载器文件同步"
|
||||
# 插件描述
|
||||
plugin_desc = "同步下载器的文件信息到数据库,删除文件时联动删除下载任务。"
|
||||
# 插件图标
|
||||
plugin_icon = "Youtube-dl_A.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.1.1"
|
||||
# 插件作者
|
||||
plugin_author = "thsrite"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/thsrite"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "syncdownloadfiles_"
|
||||
# 加载顺序
|
||||
plugin_order = 20
|
||||
# 可使用的用户级别
|
||||
auth_level = 1
|
||||
|
||||
# 私有属性
|
||||
_enabled = False
|
||||
# 任务执行间隔
|
||||
_time = None
|
||||
qb = None
|
||||
tr = None
|
||||
_onlyonce = False
|
||||
_history = False
|
||||
_clear = False
|
||||
_downloaders = []
|
||||
_dirs = None
|
||||
downloadhis = None
|
||||
transferhis = None
|
||||
|
||||
# 定时器
|
||||
_scheduler: Optional[BackgroundScheduler] = None
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
# 停止现有任务
|
||||
self.stop_service()
|
||||
|
||||
self.qb = Qbittorrent()
|
||||
self.tr = Transmission()
|
||||
self.downloadhis = DownloadHistoryOper()
|
||||
self.transferhis = TransferHistoryOper()
|
||||
|
||||
if config:
|
||||
self._enabled = config.get('enabled')
|
||||
self._time = config.get('time') or 6
|
||||
self._history = config.get('history')
|
||||
self._clear = config.get('clear')
|
||||
self._onlyonce = config.get("onlyonce")
|
||||
self._downloaders = config.get('downloaders') or []
|
||||
self._dirs = config.get("dirs") or ""
|
||||
|
||||
if self._clear:
|
||||
# 清理下载器文件记录
|
||||
self.downloadhis.truncate_files()
|
||||
# 清理下载器最后处理记录
|
||||
for downloader in self._downloaders:
|
||||
# 获取最后同步时间
|
||||
self.del_data(f"last_sync_time_{downloader}")
|
||||
# 关闭clear
|
||||
self._clear = False
|
||||
self.__update_config()
|
||||
|
||||
if self._onlyonce:
|
||||
# 执行一次
|
||||
# 关闭onlyonce
|
||||
self._onlyonce = False
|
||||
self.__update_config()
|
||||
|
||||
self.sync()
|
||||
|
||||
def sync(self):
|
||||
"""
|
||||
同步所选下载器种子记录
|
||||
"""
|
||||
start_time = datetime.now()
|
||||
logger.info("开始同步下载器任务文件记录")
|
||||
|
||||
if not self._downloaders:
|
||||
logger.error("未选择同步下载器,停止运行")
|
||||
return
|
||||
|
||||
# 遍历下载器同步记录
|
||||
for downloader in self._downloaders:
|
||||
# 获取最后同步时间
|
||||
last_sync_time = self.get_data(f"last_sync_time_{downloader}")
|
||||
|
||||
logger.info(f"开始扫描下载器 {downloader} ...")
|
||||
downloader_obj = self.__get_downloader(downloader)
|
||||
# 获取下载器中已完成的种子
|
||||
torrents = downloader_obj.get_completed_torrents()
|
||||
if torrents:
|
||||
logger.info(f"下载器 {downloader} 已完成种子数:{len(torrents)}")
|
||||
else:
|
||||
logger.info(f"下载器 {downloader} 没有已完成种子")
|
||||
continue
|
||||
|
||||
# 把种子按照名称和种子大小分组,获取添加时间最早的一个,认定为是源种子,其余为辅种
|
||||
torrents = self.__get_origin_torrents(torrents, downloader)
|
||||
logger.info(f"下载器 {downloader} 去除辅种,获取到源种子数:{len(torrents)}")
|
||||
|
||||
for torrent in torrents:
|
||||
# 返回false,标识后续种子已被同步
|
||||
sync_flag = self.__compare_time(torrent, downloader, last_sync_time)
|
||||
|
||||
if not sync_flag:
|
||||
logger.info(f"最后同步时间{last_sync_time}, 之前种子已被同步,结束当前下载器 {downloader} 任务")
|
||||
break
|
||||
|
||||
# 获取种子hash
|
||||
hash_str = self.__get_hash(torrent, downloader)
|
||||
|
||||
# 判断是否是mp下载,判断download_hash是否在downloadhistory表中,是则不处理
|
||||
downloadhis = self.downloadhis.get_by_hash(hash_str)
|
||||
if downloadhis:
|
||||
downlod_files = self.downloadhis.get_files_by_hash(hash_str)
|
||||
if downlod_files:
|
||||
logger.info(f"种子 {hash_str} 通过MoviePilot下载,跳过处理")
|
||||
continue
|
||||
|
||||
# 获取种子download_dir
|
||||
download_dir = self.__get_download_dir(torrent, downloader)
|
||||
|
||||
# 处理路径映射
|
||||
if self._dirs:
|
||||
paths = self._dirs.split("\n")
|
||||
for path in paths:
|
||||
sub_paths = path.split(":")
|
||||
download_dir = download_dir.replace(sub_paths[0], sub_paths[1]).replace('\\', '/')
|
||||
|
||||
# 获取种子name
|
||||
torrent_name = self.__get_torrent_name(torrent, downloader)
|
||||
# 种子保存目录
|
||||
save_path = Path(download_dir).joinpath(torrent_name)
|
||||
# 获取种子文件
|
||||
torrent_files = self.__get_torrent_files(torrent, downloader, downloader_obj)
|
||||
logger.info(f"开始同步种子 {hash_str}, 文件数 {len(torrent_files)}")
|
||||
|
||||
download_files = []
|
||||
for file in torrent_files:
|
||||
# 过滤掉没下载的文件
|
||||
if not self.__is_download(file, downloader):
|
||||
continue
|
||||
# 种子文件路径
|
||||
file_path_str = self.__get_file_path(file, downloader)
|
||||
file_path = Path(file_path_str)
|
||||
# 只处理视频格式
|
||||
if not file_path.suffix \
|
||||
or file_path.suffix not in settings.RMT_MEDIAEXT:
|
||||
continue
|
||||
# 种子文件根路程
|
||||
root_path = file_path.parts[0]
|
||||
# 不含种子名称的种子文件相对路径
|
||||
if root_path == torrent_name:
|
||||
rel_path = str(file_path.relative_to(root_path))
|
||||
else:
|
||||
rel_path = str(file_path)
|
||||
# 完整路径
|
||||
full_path = save_path.joinpath(rel_path)
|
||||
if self._history:
|
||||
transferhis = self.transferhis.get_by_src(str(full_path))
|
||||
if transferhis and not transferhis.download_hash:
|
||||
logger.info(f"开始补充转移记录:{transferhis.id} download_hash {hash_str}")
|
||||
self.transferhis.update_download_hash(historyid=transferhis.id,
|
||||
download_hash=hash_str)
|
||||
|
||||
# 种子文件记录
|
||||
download_files.append(
|
||||
{
|
||||
"download_hash": hash_str,
|
||||
"downloader": downloader,
|
||||
"fullpath": str(full_path),
|
||||
"savepath": str(save_path),
|
||||
"filepath": rel_path,
|
||||
"torrentname": torrent_name,
|
||||
}
|
||||
)
|
||||
|
||||
if download_files:
|
||||
# 登记下载文件
|
||||
self.downloadhis.add_files(download_files)
|
||||
logger.info(f"种子 {hash_str} 同步完成")
|
||||
|
||||
logger.info(f"下载器种子文件同步完成!")
|
||||
self.save_data(f"last_sync_time_{downloader}",
|
||||
time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time())))
|
||||
|
||||
# 计算耗时
|
||||
end_time = datetime.now()
|
||||
|
||||
logger.info(f"下载器任务文件记录已同步完成。总耗时 {(end_time - start_time).seconds} 秒")
|
||||
|
||||
def __update_config(self):
|
||||
self.update_config({
|
||||
"enabled": self._enabled,
|
||||
"time": self._time,
|
||||
"history": self._history,
|
||||
"clear": self._clear,
|
||||
"onlyonce": self._onlyonce,
|
||||
"downloaders": self._downloaders,
|
||||
"dirs": self._dirs
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def __get_origin_torrents(torrents: Any, dl_tpe: str):
|
||||
# 把种子按照名称和种子大小分组,获取添加时间最早的一个,认定为是源种子,其余为辅种
|
||||
grouped_data = {}
|
||||
|
||||
# 排序种子,根据种子添加时间倒序
|
||||
if dl_tpe == "qbittorrent":
|
||||
torrents = sorted(torrents, key=lambda x: x.get("added_on"), reverse=True)
|
||||
# 遍历原始数组,按照size和name进行分组
|
||||
for torrent in torrents:
|
||||
size = torrent.get('size')
|
||||
name = torrent.get('name')
|
||||
key = (size, name) # 使用元组作为字典的键
|
||||
|
||||
# 如果分组键不存在,则将当前元素作为最小元素添加到字典中
|
||||
if key not in grouped_data:
|
||||
grouped_data[key] = torrent
|
||||
else:
|
||||
# 如果分组键已存在,则比较当前元素的time是否更小,如果更小则更新字典中的元素
|
||||
if torrent.get('added_on') < grouped_data[key].get('added_on'):
|
||||
grouped_data[key] = torrent
|
||||
else:
|
||||
torrents = sorted(torrents, key=lambda x: x.added_date, reverse=True)
|
||||
# 遍历原始数组,按照size和name进行分组
|
||||
for torrent in torrents:
|
||||
size = torrent.total_size
|
||||
name = torrent.name
|
||||
key = (size, name) # 使用元组作为字典的键
|
||||
|
||||
# 如果分组键不存在,则将当前元素作为最小元素添加到字典中
|
||||
if key not in grouped_data:
|
||||
grouped_data[key] = torrent
|
||||
else:
|
||||
# 如果分组键已存在,则比较当前元素的time是否更小,如果更小则更新字典中的元素
|
||||
if torrent.added_date < grouped_data[key].added_date:
|
||||
grouped_data[key] = torrent
|
||||
|
||||
# 新的数组
|
||||
return list(grouped_data.values())
|
||||
|
||||
@staticmethod
|
||||
def __compare_time(torrent: Any, dl_tpe: str, last_sync_time: str = None):
|
||||
if last_sync_time:
|
||||
# 获取种子时间
|
||||
if dl_tpe == "qbittorrent":
|
||||
torrent_date = time.localtime(torrent.get("added_on")) # 将时间戳转换为时间元组
|
||||
torrent_date = time.strftime("%Y-%m-%d %H:%M:%S", torrent_date) # 格式化时间
|
||||
else:
|
||||
torrent_date = torrent.added_date
|
||||
|
||||
# 之后的种子已经同步了
|
||||
if last_sync_time > str(torrent_date):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def __is_download(file: Any, dl_type: str):
|
||||
"""
|
||||
判断文件是否被下载
|
||||
"""
|
||||
try:
|
||||
if dl_type == "qbittorrent":
|
||||
return True
|
||||
else:
|
||||
return file.completed and file.completed > 0
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def __get_file_path(file: Any, dl_type: str):
|
||||
"""
|
||||
获取文件路径
|
||||
"""
|
||||
try:
|
||||
return file.get("name") if dl_type == "qbittorrent" else file.name
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def __get_torrent_files(torrent: Any, dl_type: str, downloader_obj):
|
||||
"""
|
||||
获取种子文件
|
||||
"""
|
||||
try:
|
||||
return torrent.files if dl_type == "qbittorrent" else downloader_obj.get_files(tid=torrent.id)
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def __get_torrent_name(torrent: Any, dl_type: str):
|
||||
"""
|
||||
获取种子name
|
||||
"""
|
||||
try:
|
||||
return torrent.get("name") if dl_type == "qbittorrent" else torrent.name
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def __get_download_dir(torrent: Any, dl_type: str):
|
||||
"""
|
||||
获取种子download_dir
|
||||
"""
|
||||
try:
|
||||
return torrent.get("save_path") if dl_type == "qbittorrent" else torrent.download_dir
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def __get_hash(torrent: Any, dl_type: str):
|
||||
"""
|
||||
获取种子hash
|
||||
"""
|
||||
try:
|
||||
return torrent.get("hash") if dl_type == "qbittorrent" else torrent.hashString
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return ""
|
||||
|
||||
def __get_downloader(self, dtype: str):
|
||||
"""
|
||||
根据类型返回下载器实例
|
||||
"""
|
||||
if dtype == "qbittorrent":
|
||||
return self.qb
|
||||
elif dtype == "transmission":
|
||||
return self.tr
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return True if self._enabled and self._time else False
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_service(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
注册插件公共服务
|
||||
[{
|
||||
"id": "服务ID",
|
||||
"name": "服务名称",
|
||||
"trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()",
|
||||
"func": self.xxx,
|
||||
"kwargs": {} # 定时器参数
|
||||
}]
|
||||
"""
|
||||
if self.get_state():
|
||||
return [{
|
||||
"id": "SyncDownloadFiles",
|
||||
"name": "同步下载器文件记录服务",
|
||||
"trigger": "interval",
|
||||
"func": self.sync,
|
||||
"kwargs": {"seconds": float(str(self._time).strip()) * 3600}
|
||||
}]
|
||||
return []
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enabled',
|
||||
'label': '开启插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'onlyonce',
|
||||
'label': '立即运行一次',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'history',
|
||||
'label': '补充整理历史记录',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'clear',
|
||||
'label': '清理数据',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'time',
|
||||
'label': '同步时间间隔(小时)'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSelect',
|
||||
'props': {
|
||||
'chips': True,
|
||||
'multiple': True,
|
||||
'model': 'downloaders',
|
||||
'label': '同步下载器',
|
||||
'items': [
|
||||
{'title': 'Qbittorrent', 'value': 'qbittorrent'},
|
||||
{'title': 'Transmission', 'value': 'transmission'}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextarea',
|
||||
'props': {
|
||||
'model': 'dirs',
|
||||
'label': '目录映射',
|
||||
'rows': 5,
|
||||
'placeholder': '每一行一个目录,下载器保存目录:MoviePilot映射目录'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '适用于非MoviePilot下载的任务;下载器种子数据较多时,同步时间将会较长,请耐心等候,可查看实时日志了解同步进度;时间间隔建议最少每6小时执行一次,防止上次任务没处理完。'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"onlyonce": False,
|
||||
"history": False,
|
||||
"clear": False,
|
||||
"time": 6,
|
||||
"dirs": "",
|
||||
"downloaders": []
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
退出插件
|
||||
"""
|
||||
try:
|
||||
if self._scheduler:
|
||||
self._scheduler.remove_all_jobs()
|
||||
if self._scheduler.running:
|
||||
self._scheduler.shutdown()
|
||||
self._scheduler = None
|
||||
except Exception as e:
|
||||
logger.error("退出插件失败:%s" % str(e))
|
||||
816
plugins.v2/torrentremover/__init__.py
Normal file
816
plugins.v2/torrentremover/__init__.py
Normal file
@@ -0,0 +1,816 @@
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Tuple, Dict, Any, Optional
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
from app.modules.qbittorrent import Qbittorrent
|
||||
from app.modules.transmission import Transmission
|
||||
from app.plugins import _PluginBase
|
||||
from app.schemas import NotificationType
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
lock = threading.Lock()
|
||||
|
||||
|
||||
class TorrentRemover(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "自动删种"
|
||||
# 插件描述
|
||||
plugin_desc = "自动删除下载器中的下载任务。"
|
||||
# 插件图标
|
||||
plugin_icon = "delete.jpg"
|
||||
# 插件版本
|
||||
plugin_version = "1.2.2"
|
||||
# 插件作者
|
||||
plugin_author = "jxxghp"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/jxxghp"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "torrentremover_"
|
||||
# 加载顺序
|
||||
plugin_order = 8
|
||||
# 可使用的用户级别
|
||||
auth_level = 2
|
||||
|
||||
# 私有属性
|
||||
qb = None
|
||||
tr = None
|
||||
_event = threading.Event()
|
||||
_scheduler = None
|
||||
_enabled = False
|
||||
_onlyonce = False
|
||||
_notify = False
|
||||
# pause/delete
|
||||
_downloaders = []
|
||||
_action = "pause"
|
||||
_cron = None
|
||||
_samedata = False
|
||||
_mponly = False
|
||||
_size = None
|
||||
_ratio = None
|
||||
_time = None
|
||||
_upspeed = None
|
||||
_labels = None
|
||||
_pathkeywords = None
|
||||
_trackerkeywords = None
|
||||
_errorkeywords = None
|
||||
_torrentstates = None
|
||||
_torrentcategorys = None
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._onlyonce = config.get("onlyonce")
|
||||
self._notify = config.get("notify")
|
||||
self._downloaders = config.get("downloaders") or []
|
||||
self._action = config.get("action")
|
||||
self._cron = config.get("cron")
|
||||
self._samedata = config.get("samedata")
|
||||
self._mponly = config.get("mponly")
|
||||
self._size = config.get("size") or ""
|
||||
self._ratio = config.get("ratio")
|
||||
self._time = config.get("time")
|
||||
self._upspeed = config.get("upspeed")
|
||||
self._labels = config.get("labels") or ""
|
||||
self._pathkeywords = config.get("pathkeywords") or ""
|
||||
self._trackerkeywords = config.get("trackerkeywords") or ""
|
||||
self._errorkeywords = config.get("errorkeywords") or ""
|
||||
self._torrentstates = config.get("torrentstates") or ""
|
||||
self._torrentcategorys = config.get("torrentcategorys") or ""
|
||||
|
||||
self.stop_service()
|
||||
|
||||
if self.get_state() or self._onlyonce:
|
||||
self.qb = Qbittorrent()
|
||||
self.tr = Transmission()
|
||||
if self._onlyonce:
|
||||
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
logger.info(f"自动删种服务启动,立即运行一次")
|
||||
self._scheduler.add_job(func=self.delete_torrents, trigger='date',
|
||||
run_date=datetime.now(
|
||||
tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3)
|
||||
)
|
||||
# 关闭一次性开关
|
||||
self._onlyonce = False
|
||||
# 保存设置
|
||||
self.update_config({
|
||||
"enabled": self._enabled,
|
||||
"notify": self._notify,
|
||||
"onlyonce": self._onlyonce,
|
||||
"action": self._action,
|
||||
"cron": self._cron,
|
||||
"downloaders": self._downloaders,
|
||||
"samedata": self._samedata,
|
||||
"mponly": self._mponly,
|
||||
"size": self._size,
|
||||
"ratio": self._ratio,
|
||||
"time": self._time,
|
||||
"upspeed": self._upspeed,
|
||||
"labels": self._labels,
|
||||
"pathkeywords": self._pathkeywords,
|
||||
"trackerkeywords": self._trackerkeywords,
|
||||
"errorkeywords": self._errorkeywords,
|
||||
"torrentstates": self._torrentstates,
|
||||
"torrentcategorys": self._torrentcategorys
|
||||
|
||||
})
|
||||
if self._scheduler.get_jobs():
|
||||
# 启动服务
|
||||
self._scheduler.print_jobs()
|
||||
self._scheduler.start()
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return True if self._enabled and self._cron and self._downloaders else False
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_service(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
注册插件公共服务
|
||||
[{
|
||||
"id": "服务ID",
|
||||
"name": "服务名称",
|
||||
"trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()",
|
||||
"func": self.xxx,
|
||||
"kwargs": {} # 定时器参数
|
||||
}]
|
||||
"""
|
||||
if self.get_state():
|
||||
return [{
|
||||
"id": "TorrentRemover",
|
||||
"name": "自动删种服务",
|
||||
"trigger": CronTrigger.from_crontab(self._cron),
|
||||
"func": self.delete_torrents,
|
||||
"kwargs": {}
|
||||
}]
|
||||
return []
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enabled',
|
||||
'label': '启用插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'notify',
|
||||
'label': '发送通知',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'cron',
|
||||
'label': '执行周期',
|
||||
'placeholder': '0 */12 * * *'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSelect',
|
||||
'props': {
|
||||
'model': 'action',
|
||||
'label': '动作',
|
||||
'items': [
|
||||
{'title': '暂停', 'value': 'pause'},
|
||||
{'title': '删除种子', 'value': 'delete'},
|
||||
{'title': '删除种子和文件', 'value': 'deletefile'}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSelect',
|
||||
'props': {
|
||||
'chips': True,
|
||||
'multiple': True,
|
||||
'model': 'downloaders',
|
||||
'label': '下载器',
|
||||
'items': [
|
||||
{'title': 'Qbittorrent', 'value': 'qbittorrent'},
|
||||
{'title': 'Transmission', 'value': 'transmission'}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'size',
|
||||
'label': '种子大小(GB)',
|
||||
'placeholder': '例如1-10'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'ratio',
|
||||
'label': '分享率',
|
||||
'placeholder': ''
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'time',
|
||||
'label': '做种时间(小时)',
|
||||
'placeholder': ''
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'upspeed',
|
||||
'label': '平均上传速度',
|
||||
'placeholder': ''
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'labels',
|
||||
'label': '标签',
|
||||
'placeholder': '用,分隔多个标签'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'pathkeywords',
|
||||
'label': '保存路径关键词',
|
||||
'placeholder': '支持正式表达式'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'trackerkeywords',
|
||||
'label': 'Tracker关键词',
|
||||
'placeholder': '支持正式表达式'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'errorkeywords',
|
||||
'label': '错误信息关键词(TR)',
|
||||
'placeholder': '支持正式表达式,仅适用于TR'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'torrentstates',
|
||||
'label': '任务状态(QB)',
|
||||
'placeholder': '用,分隔多个状态,仅适用于QB'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'torrentcategorys',
|
||||
'label': '任务分类',
|
||||
'placeholder': '用,分隔多个分类'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'samedata',
|
||||
'label': '处理辅种',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'mponly',
|
||||
'label': '仅MoviePilot任务',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'onlyonce',
|
||||
'label': '立即运行一次',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '自动删种存在风险,如设置不当可能导致数据丢失!建议动作先选择暂停,确定条件正确后再改成删除。'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '任务状态(QB)字典:'
|
||||
'downloading:正在下载-传输数据,'
|
||||
'stalledDL:正在下载_未建立连接,'
|
||||
'uploading:正在上传-传输数据,'
|
||||
'stalledUP:正在上传-未建立连接,'
|
||||
'error:暂停-发生错误,'
|
||||
'pausedDL:暂停-下载未完成,'
|
||||
'pausedUP:暂停-下载完成,'
|
||||
'missingFiles:暂停-文件丢失,'
|
||||
'checkingDL:检查中-下载未完成,'
|
||||
'checkingUP:检查中-下载完成,'
|
||||
'checkingResumeData:检查中-启动时恢复数据,'
|
||||
'forcedDL:强制下载-忽略队列,'
|
||||
'queuedDL:等待下载-排队,'
|
||||
'forcedUP:强制上传-忽略队列,'
|
||||
'queuedUP:等待上传-排队,'
|
||||
'allocating:分配磁盘空间,'
|
||||
'metaDL:获取元数据,'
|
||||
'moving:移动文件,'
|
||||
'unknown:未知状态'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"notify": False,
|
||||
"onlyonce": False,
|
||||
"action": 'pause',
|
||||
'downloaders': [],
|
||||
"cron": '0 */12 * * *',
|
||||
"samedata": False,
|
||||
"mponly": False,
|
||||
"size": "",
|
||||
"ratio": "",
|
||||
"time": "",
|
||||
"upspeed": "",
|
||||
"labels": "",
|
||||
"pathkeywords": "",
|
||||
"trackerkeywords": "",
|
||||
"errorkeywords": "",
|
||||
"torrentstates": "",
|
||||
"torrentcategorys": ""
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
退出插件
|
||||
"""
|
||||
try:
|
||||
if self._scheduler:
|
||||
self._scheduler.remove_all_jobs()
|
||||
if self._scheduler.running:
|
||||
self._event.set()
|
||||
self._scheduler.shutdown()
|
||||
self._event.clear()
|
||||
self._scheduler = None
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
|
||||
def __get_downloader(self, dtype: str):
|
||||
"""
|
||||
根据类型返回下载器实例
|
||||
"""
|
||||
if dtype == "qbittorrent":
|
||||
return self.qb
|
||||
elif dtype == "transmission":
|
||||
return self.tr
|
||||
else:
|
||||
return None
|
||||
|
||||
def delete_torrents(self):
|
||||
"""
|
||||
定时删除下载器中的下载任务
|
||||
"""
|
||||
for downloader in self._downloaders:
|
||||
try:
|
||||
with lock:
|
||||
# 获取需删除种子列表
|
||||
torrents = self.get_remove_torrents(downloader)
|
||||
logger.info(f"自动删种任务 获取符合处理条件种子数 {len(torrents)}")
|
||||
# 下载器
|
||||
downlader_obj = self.__get_downloader(downloader)
|
||||
if self._action == "pause":
|
||||
message_text = f"{downloader.title()} 共暂停{len(torrents)}个种子"
|
||||
for torrent in torrents:
|
||||
if self._event.is_set():
|
||||
logger.info(f"自动删种服务停止")
|
||||
return
|
||||
text_item = f"{torrent.get('name')} " \
|
||||
f"来自站点:{torrent.get('site')} " \
|
||||
f"大小:{StringUtils.str_filesize(torrent.get('size'))}"
|
||||
# 暂停种子
|
||||
downlader_obj.stop_torrents(ids=[torrent.get("id")])
|
||||
logger.info(f"自动删种任务 暂停种子:{text_item}")
|
||||
message_text = f"{message_text}\n{text_item}"
|
||||
elif self._action == "delete":
|
||||
message_text = f"{downloader.title()} 共删除{len(torrents)}个种子"
|
||||
for torrent in torrents:
|
||||
if self._event.is_set():
|
||||
logger.info(f"自动删种服务停止")
|
||||
return
|
||||
text_item = f"{torrent.get('name')} " \
|
||||
f"来自站点:{torrent.get('site')} " \
|
||||
f"大小:{StringUtils.str_filesize(torrent.get('size'))}"
|
||||
# 删除种子
|
||||
downlader_obj.delete_torrents(delete_file=False,
|
||||
ids=[torrent.get("id")])
|
||||
logger.info(f"自动删种任务 删除种子:{text_item}")
|
||||
message_text = f"{message_text}\n{text_item}"
|
||||
elif self._action == "deletefile":
|
||||
message_text = f"{downloader.title()} 共删除{len(torrents)}个种子及文件"
|
||||
for torrent in torrents:
|
||||
if self._event.is_set():
|
||||
logger.info(f"自动删种服务停止")
|
||||
return
|
||||
text_item = f"{torrent.get('name')} " \
|
||||
f"来自站点:{torrent.get('site')} " \
|
||||
f"大小:{StringUtils.str_filesize(torrent.get('size'))}"
|
||||
# 删除种子
|
||||
downlader_obj.delete_torrents(delete_file=True,
|
||||
ids=[torrent.get("id")])
|
||||
logger.info(f"自动删种任务 删除种子及文件:{text_item}")
|
||||
message_text = f"{message_text}\n{text_item}"
|
||||
else:
|
||||
continue
|
||||
if torrents and message_text and self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【自动删种任务完成】",
|
||||
text=message_text
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"自动删种任务异常:{str(e)}")
|
||||
|
||||
def __get_qb_torrent(self, torrent: Any) -> Optional[dict]:
|
||||
"""
|
||||
检查QB下载任务是否符合条件
|
||||
"""
|
||||
# 完成时间
|
||||
date_done = torrent.completion_on if torrent.completion_on > 0 else torrent.added_on
|
||||
# 现在时间
|
||||
date_now = int(time.mktime(datetime.now().timetuple()))
|
||||
# 做种时间
|
||||
torrent_seeding_time = date_now - date_done if date_done else 0
|
||||
# 平均上传速度
|
||||
torrent_upload_avs = torrent.uploaded / torrent_seeding_time if torrent_seeding_time else 0
|
||||
# 大小 单位:GB
|
||||
sizes = self._size.split('-') if self._size else []
|
||||
minsize = float(sizes[0]) * 1024 * 1024 * 1024 if sizes else 0
|
||||
maxsize = float(sizes[-1]) * 1024 * 1024 * 1024 if sizes else 0
|
||||
# 分享率
|
||||
if self._ratio and torrent.ratio <= float(self._ratio):
|
||||
return None
|
||||
# 做种时间 单位:小时
|
||||
if self._time and torrent_seeding_time <= float(self._time) * 3600:
|
||||
return None
|
||||
# 文件大小
|
||||
if self._size and (torrent.size >= int(maxsize) or torrent.size <= int(minsize)):
|
||||
return None
|
||||
if self._upspeed and torrent_upload_avs >= float(self._upspeed) * 1024:
|
||||
return None
|
||||
if self._pathkeywords and not re.findall(self._pathkeywords, torrent.save_path, re.I):
|
||||
return None
|
||||
if self._trackerkeywords and not re.findall(self._trackerkeywords, torrent.tracker, re.I):
|
||||
return None
|
||||
if self._torrentstates and torrent.state not in self._torrentstates:
|
||||
return None
|
||||
if self._torrentcategorys and (not torrent.category or torrent.category not in self._torrentcategorys):
|
||||
return None
|
||||
return {
|
||||
"id": torrent.hash,
|
||||
"name": torrent.name,
|
||||
"site": StringUtils.get_url_sld(torrent.tracker),
|
||||
"size": torrent.size
|
||||
}
|
||||
|
||||
def __get_tr_torrent(self, torrent: Any) -> Optional[dict]:
|
||||
"""
|
||||
检查TR下载任务是否符合条件
|
||||
"""
|
||||
# 完成时间
|
||||
date_done = torrent.date_done or torrent.date_added
|
||||
# 现在时间
|
||||
date_now = int(time.mktime(datetime.now().timetuple()))
|
||||
# 做种时间
|
||||
torrent_seeding_time = date_now - int(time.mktime(date_done.timetuple())) if date_done else 0
|
||||
# 上传量
|
||||
torrent_uploaded = torrent.ratio * torrent.total_size
|
||||
# 平均上传速茺
|
||||
torrent_upload_avs = torrent_uploaded / torrent_seeding_time if torrent_seeding_time else 0
|
||||
# 大小 单位:GB
|
||||
sizes = self._size.split('-') if self._size else []
|
||||
minsize = float(sizes[0]) * 1024 * 1024 * 1024 if sizes else 0
|
||||
maxsize = float(sizes[-1]) * 1024 * 1024 * 1024 if sizes else 0
|
||||
# 分享率
|
||||
if self._ratio and torrent.ratio <= float(self._ratio):
|
||||
return None
|
||||
if self._time and torrent_seeding_time <= float(self._time) * 3600:
|
||||
return None
|
||||
if self._size and (torrent.total_size >= int(maxsize) or torrent.total_size <= int(minsize)):
|
||||
return None
|
||||
if self._upspeed and torrent_upload_avs >= float(self._upspeed) * 1024:
|
||||
return None
|
||||
if self._pathkeywords and not re.findall(self._pathkeywords, torrent.download_dir, re.I):
|
||||
return None
|
||||
if self._trackerkeywords:
|
||||
if not torrent.trackers:
|
||||
return None
|
||||
else:
|
||||
tacker_key_flag = False
|
||||
for tracker in torrent.trackers:
|
||||
if re.findall(self._trackerkeywords, tracker.get("announce", ""), re.I):
|
||||
tacker_key_flag = True
|
||||
break
|
||||
if not tacker_key_flag:
|
||||
return None
|
||||
if self._errorkeywords and not re.findall(self._errorkeywords, torrent.error_string, re.I):
|
||||
return None
|
||||
return {
|
||||
"id": torrent.hashString,
|
||||
"name": torrent.name,
|
||||
"site": torrent.trackers[0].get("sitename") if torrent.trackers else "",
|
||||
"size": torrent.total_size
|
||||
}
|
||||
|
||||
def get_remove_torrents(self, downloader: str):
|
||||
"""
|
||||
获取自动删种任务种子
|
||||
"""
|
||||
remove_torrents = []
|
||||
# 下载器对象
|
||||
downloader_obj = self.__get_downloader(downloader)
|
||||
# 标题
|
||||
if self._labels:
|
||||
tags = self._labels.split(',')
|
||||
else:
|
||||
tags = []
|
||||
if self._mponly:
|
||||
tags.append(settings.TORRENT_TAG)
|
||||
# 查询种子
|
||||
torrents, error_flag = downloader_obj.get_torrents(tags=tags or None)
|
||||
if error_flag:
|
||||
return []
|
||||
# 处理种子
|
||||
for torrent in torrents:
|
||||
if downloader == "qbittorrent":
|
||||
item = self.__get_qb_torrent(torrent)
|
||||
else:
|
||||
item = self.__get_tr_torrent(torrent)
|
||||
if not item:
|
||||
continue
|
||||
remove_torrents.append(item)
|
||||
# 处理辅种
|
||||
if self._samedata and remove_torrents:
|
||||
remove_ids = [t.get("id") for t in remove_torrents]
|
||||
remove_torrents_plus = []
|
||||
for remove_torrent in remove_torrents:
|
||||
name = remove_torrent.get("name")
|
||||
size = remove_torrent.get("size")
|
||||
for torrent in torrents:
|
||||
if downloader == "qbittorrent":
|
||||
plus_id = torrent.hash
|
||||
plus_name = torrent.name
|
||||
plus_size = torrent.size
|
||||
plus_site = StringUtils.get_url_sld(torrent.tracker)
|
||||
else:
|
||||
plus_id = torrent.hashString
|
||||
plus_name = torrent.name
|
||||
plus_size = torrent.total_size
|
||||
plus_site = torrent.trackers[0].get("sitename") if torrent.trackers else ""
|
||||
# 比对名称和大小
|
||||
if plus_name == name \
|
||||
and plus_size == size \
|
||||
and plus_id not in remove_ids:
|
||||
remove_torrents_plus.append(
|
||||
{
|
||||
"id": plus_id,
|
||||
"name": plus_name,
|
||||
"site": plus_site,
|
||||
"size": plus_size
|
||||
}
|
||||
)
|
||||
if remove_torrents_plus:
|
||||
remove_torrents.extend(remove_torrents_plus)
|
||||
return remove_torrents
|
||||
932
plugins.v2/torrenttransfer/__init__.py
Normal file
932
plugins.v2/torrenttransfer/__init__.py
Normal file
@@ -0,0 +1,932 @@
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
from typing import Any, List, Dict, Tuple, Optional
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from bencode import bdecode, bencode
|
||||
|
||||
from app.core.config import settings
|
||||
from app.helper.torrent import TorrentHelper
|
||||
from app.log import logger
|
||||
from app.modules.qbittorrent import Qbittorrent
|
||||
from app.modules.transmission import Transmission
|
||||
from app.plugins import _PluginBase
|
||||
from app.schemas import NotificationType
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
class TorrentTransfer(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "自动转移做种"
|
||||
# 插件描述
|
||||
plugin_desc = "定期转移下载器中的做种任务到另一个下载器。"
|
||||
# 插件图标
|
||||
plugin_icon = "seed.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.5"
|
||||
# 插件作者
|
||||
plugin_author = "jxxghp"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/jxxghp"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "torrenttransfer_"
|
||||
# 加载顺序
|
||||
plugin_order = 18
|
||||
# 可使用的用户级别
|
||||
auth_level = 2
|
||||
|
||||
# 私有属性
|
||||
_scheduler = None
|
||||
qb = None
|
||||
tr = None
|
||||
torrent = None
|
||||
# 开关
|
||||
_enabled = False
|
||||
_cron = None
|
||||
_onlyonce = False
|
||||
_fromdownloader = None
|
||||
_todownloader = None
|
||||
_frompath = None
|
||||
_topath = None
|
||||
_notify = False
|
||||
_nolabels = None
|
||||
_includelabels = None
|
||||
_nopaths = None
|
||||
_deletesource = False
|
||||
_deleteduplicate = False
|
||||
_fromtorrentpath = None
|
||||
_autostart = False
|
||||
_transferemptylabel = False
|
||||
# 退出事件
|
||||
_event = Event()
|
||||
# 待检查种子清单
|
||||
_recheck_torrents = {}
|
||||
_is_recheck_running = False
|
||||
# 任务标签
|
||||
_torrent_tags = ["已整理", "转移做种"]
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
self.torrent = TorrentHelper()
|
||||
# 读取配置
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._onlyonce = config.get("onlyonce")
|
||||
self._cron = config.get("cron")
|
||||
self._notify = config.get("notify")
|
||||
self._nolabels = config.get("nolabels")
|
||||
self._includelabels = config.get("includelabels")
|
||||
self._frompath = config.get("frompath")
|
||||
self._topath = config.get("topath")
|
||||
self._fromdownloader = config.get("fromdownloader")
|
||||
self._todownloader = config.get("todownloader")
|
||||
self._deletesource = config.get("deletesource")
|
||||
self._deleteduplicate = config.get("deleteduplicate")
|
||||
self._fromtorrentpath = config.get("fromtorrentpath")
|
||||
self._nopaths = config.get("nopaths")
|
||||
self._autostart = config.get("autostart")
|
||||
self._transferemptylabel = config.get("transferemptylabel")
|
||||
|
||||
# 停止现有任务
|
||||
self.stop_service()
|
||||
|
||||
# 启动定时任务 & 立即运行一次
|
||||
if self.get_state() or self._onlyonce:
|
||||
self.qb = Qbittorrent()
|
||||
self.tr = Transmission()
|
||||
# 检查配置
|
||||
if self._fromtorrentpath and not Path(self._fromtorrentpath).exists():
|
||||
logger.error(f"源下载器种子文件保存路径不存在:{self._fromtorrentpath}")
|
||||
self.systemmessage.put(f"源下载器种子文件保存路径不存在:{self._fromtorrentpath}", title="自动转移做种")
|
||||
return
|
||||
if self._fromdownloader == self._todownloader:
|
||||
logger.error(f"源下载器和目的下载器不能相同")
|
||||
self.systemmessage.put(f"源下载器和目的下载器不能相同", title="自动转移做种")
|
||||
return
|
||||
|
||||
# 定时服务
|
||||
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
|
||||
if self._autostart:
|
||||
# 追加种子校验服务
|
||||
self._scheduler.add_job(self.check_recheck, 'interval', minutes=3)
|
||||
|
||||
if self._onlyonce:
|
||||
logger.info(f"转移做种服务启动,立即运行一次")
|
||||
self._scheduler.add_job(self.transfer, 'date',
|
||||
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(
|
||||
seconds=3))
|
||||
# 关闭一次性开关
|
||||
self._onlyonce = False
|
||||
self.update_config({
|
||||
"enabled": self._enabled,
|
||||
"onlyonce": self._onlyonce,
|
||||
"cron": self._cron,
|
||||
"notify": self._notify,
|
||||
"nolabels": self._nolabels,
|
||||
"includelabels": self._includelabels,
|
||||
"frompath": self._frompath,
|
||||
"topath": self._topath,
|
||||
"fromdownloader": self._fromdownloader,
|
||||
"todownloader": self._todownloader,
|
||||
"deletesource": self._deletesource,
|
||||
"deleteduplicate": self._deleteduplicate,
|
||||
"fromtorrentpath": self._fromtorrentpath,
|
||||
"nopaths": self._nopaths,
|
||||
"autostart": self._autostart,
|
||||
"transferemptylabel": self._transferemptylabel
|
||||
})
|
||||
|
||||
# 启动服务
|
||||
if self._scheduler.get_jobs():
|
||||
self._scheduler.print_jobs()
|
||||
self._scheduler.start()
|
||||
|
||||
def get_state(self):
|
||||
return True if self._enabled \
|
||||
and self._cron \
|
||||
and self._fromdownloader \
|
||||
and self._todownloader \
|
||||
and self._fromtorrentpath else False
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_service(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
注册插件公共服务
|
||||
[{
|
||||
"id": "服务ID",
|
||||
"name": "服务名称",
|
||||
"trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()",
|
||||
"func": self.xxx,
|
||||
"kwargs": {} # 定时器参数
|
||||
}]
|
||||
"""
|
||||
if self.get_state():
|
||||
return [
|
||||
{
|
||||
"id": "TorrentTransfer",
|
||||
"name": "转移做种服务",
|
||||
"trigger": CronTrigger.from_crontab(self._cron),
|
||||
"func": self.transfer,
|
||||
"kwargs": {}
|
||||
}
|
||||
]
|
||||
return []
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enabled',
|
||||
'label': '启用插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'notify',
|
||||
'label': '发送通知',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'transferemptylabel',
|
||||
'label': '转移无标签种子',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'cron',
|
||||
'label': '执行周期',
|
||||
'placeholder': '0 0 0 ? *'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'nolabels',
|
||||
'label': '不转移种子标签',
|
||||
}
|
||||
}
|
||||
]
|
||||
}, {
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'includelabels',
|
||||
'label': '转移种子标签',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSelect',
|
||||
'props': {
|
||||
'model': 'fromdownloader',
|
||||
'label': '源下载器',
|
||||
'items': [
|
||||
{'title': 'Qbittorrent', 'value': 'qbittorrent'},
|
||||
{'title': 'Transmission', 'value': 'transmission'}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'fromtorrentpath',
|
||||
'label': '源下载器种子文件路径',
|
||||
'placeholder': 'BT_backup、torrents'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'frompath',
|
||||
'label': '源数据文件根路径',
|
||||
'placeholder': '根路径,留空不进行路径转换'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSelect',
|
||||
'props': {
|
||||
'model': 'todownloader',
|
||||
'label': '目的下载器',
|
||||
'items': [
|
||||
{'title': 'Qbittorrent', 'value': 'qbittorrent'},
|
||||
{'title': 'Transmission', 'value': 'transmission'}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'topath',
|
||||
'label': '目的数据文件根路径',
|
||||
'placeholder': '根路径,留空不进行路径转换'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextarea',
|
||||
'props': {
|
||||
'model': 'nopaths',
|
||||
'label': '不转移数据文件目录',
|
||||
'rows': 3,
|
||||
'placeholder': '每一行一个目录'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 3
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'autostart',
|
||||
'label': '校验完成后自动开始',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 3
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'deletesource',
|
||||
'label': '删除源种子',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 3
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'deleteduplicate',
|
||||
'label': '删除重复种子',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 3
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'onlyonce',
|
||||
'label': '立即运行一次',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"notify": False,
|
||||
"onlyonce": False,
|
||||
"cron": "",
|
||||
"nolabels": "",
|
||||
"includelabels": "",
|
||||
"frompath": "",
|
||||
"topath": "",
|
||||
"fromdownloader": "",
|
||||
"todownloader": "",
|
||||
"deletesource": False,
|
||||
"deleteduplicate": False,
|
||||
"fromtorrentpath": "",
|
||||
"nopaths": "",
|
||||
"autostart": True,
|
||||
"transferemptylabel": False
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
def __get_downloader(self, dtype: str):
|
||||
"""
|
||||
根据类型返回下载器实例
|
||||
"""
|
||||
if dtype == "qbittorrent":
|
||||
return self.qb
|
||||
elif dtype == "transmission":
|
||||
return self.tr
|
||||
else:
|
||||
return None
|
||||
|
||||
def __download(self, downloader: str, content: bytes,
|
||||
save_path: str) -> Optional[str]:
|
||||
"""
|
||||
添加下载任务
|
||||
"""
|
||||
if downloader == "qbittorrent":
|
||||
# 生成随机Tag
|
||||
tag = StringUtils.generate_random_str(10)
|
||||
state = self.qb.add_torrent(content=content,
|
||||
download_dir=save_path,
|
||||
is_paused=True,
|
||||
tag=["已整理", "转移做种", tag])
|
||||
if not state:
|
||||
return None
|
||||
else:
|
||||
# 获取种子Hash
|
||||
torrent_hash = self.qb.get_torrent_id_by_tag(tags=tag)
|
||||
if not torrent_hash:
|
||||
logger.error(f"{downloader} 下载任务添加成功,但获取任务信息失败!")
|
||||
return None
|
||||
return torrent_hash
|
||||
elif downloader == "transmission":
|
||||
# 添加任务
|
||||
torrent = self.tr.add_torrent(content=content,
|
||||
download_dir=save_path,
|
||||
is_paused=True,
|
||||
labels=["已整理", "转移做种"])
|
||||
if not torrent:
|
||||
return None
|
||||
else:
|
||||
return torrent.hashString
|
||||
|
||||
logger.error(f"不支持的下载器:{downloader}")
|
||||
return None
|
||||
|
||||
def transfer(self):
|
||||
"""
|
||||
开始转移做种
|
||||
"""
|
||||
logger.info("开始转移做种任务 ...")
|
||||
|
||||
# 源下载器
|
||||
downloader = self._fromdownloader
|
||||
# 目的下载器
|
||||
todownloader = self._todownloader
|
||||
|
||||
# 获取下载器中已完成的种子
|
||||
downloader_obj = self.__get_downloader(downloader)
|
||||
torrents = downloader_obj.get_completed_torrents()
|
||||
if torrents:
|
||||
logger.info(f"下载器 {downloader} 已完成种子数:{len(torrents)}")
|
||||
else:
|
||||
logger.info(f"下载器 {downloader} 没有已完成种子")
|
||||
return
|
||||
|
||||
# 过滤种子,记录保存目录
|
||||
trans_torrents = []
|
||||
for torrent in torrents:
|
||||
if self._event.is_set():
|
||||
logger.info(f"转移服务停止")
|
||||
return
|
||||
|
||||
# 获取种子hash
|
||||
hash_str = self.__get_hash(torrent, downloader)
|
||||
# 获取保存路径
|
||||
save_path = self.__get_save_path(torrent, downloader)
|
||||
|
||||
if self._nopaths and save_path:
|
||||
# 过滤不需要转移的路径
|
||||
nopath_skip = False
|
||||
for nopath in self._nopaths.split('\n'):
|
||||
if os.path.normpath(save_path).startswith(os.path.normpath(nopath)):
|
||||
logger.info(f"种子 {hash_str} 保存路径 {save_path} 不需要转移,跳过 ...")
|
||||
nopath_skip = True
|
||||
break
|
||||
if nopath_skip:
|
||||
continue
|
||||
|
||||
# 获取种子标签
|
||||
torrent_labels = self.__get_label(torrent, downloader)
|
||||
|
||||
# 种子为无标签,则进行规范化
|
||||
is_torrent_labels_empty = torrent_labels == [''] or torrent_labels == [] or torrent_labels is None
|
||||
if is_torrent_labels_empty:
|
||||
torrent_labels = []
|
||||
|
||||
#根据设置决定是否转移无标签的种子
|
||||
if is_torrent_labels_empty:
|
||||
if not self._transferemptylabel:
|
||||
continue
|
||||
else:
|
||||
# 排除含有不转移的标签
|
||||
if self._nolabels:
|
||||
is_skip = False
|
||||
for label in self._nolabels.split(','):
|
||||
if label in torrent_labels:
|
||||
logger.info(f"种子 {hash_str} 含有不转移标签 {label},跳过 ...")
|
||||
is_skip = True
|
||||
break
|
||||
if is_skip:
|
||||
continue
|
||||
# 排除不含有转移标签的种子
|
||||
if self._includelabels:
|
||||
is_skip = False
|
||||
for label in self._includelabels.split(','):
|
||||
if label not in torrent_labels:
|
||||
logger.info(f"种子 {hash_str} 不含有转移标签 {label},跳过 ...")
|
||||
is_skip = True
|
||||
break
|
||||
if is_skip:
|
||||
continue
|
||||
|
||||
# 添加转移数据
|
||||
trans_torrents.append({
|
||||
"hash": hash_str,
|
||||
"save_path": save_path,
|
||||
"torrent": torrent
|
||||
})
|
||||
|
||||
# 开始转移任务
|
||||
if trans_torrents:
|
||||
logger.info(f"需要转移的种子数:{len(trans_torrents)}")
|
||||
# 记数
|
||||
total = len(trans_torrents)
|
||||
# 总成功数
|
||||
success = 0
|
||||
# 总失败数
|
||||
fail = 0
|
||||
# 跳过数
|
||||
skip = 0
|
||||
# 删除重复数
|
||||
del_dup = 0
|
||||
|
||||
for torrent_item in trans_torrents:
|
||||
# 检查种子文件是否存在
|
||||
torrent_file = Path(self._fromtorrentpath) / f"{torrent_item.get('hash')}.torrent"
|
||||
if not torrent_file.exists():
|
||||
logger.error(f"种子文件不存在:{torrent_file}")
|
||||
# 失败计数
|
||||
fail += 1
|
||||
continue
|
||||
|
||||
# 查询hash值是否已经在目的下载器中
|
||||
todownloader_obj = self.__get_downloader(todownloader)
|
||||
torrent_info, _ = todownloader_obj.get_torrents(ids=[torrent_item.get('hash')])
|
||||
if torrent_info:
|
||||
# 删除重复的源种子,不能删除文件!
|
||||
if self._deleteduplicate:
|
||||
logger.info(f"删除重复的源下载器任务(不含文件):{torrent_item.get('hash')} ...")
|
||||
downloader_obj.delete_torrents(delete_file=False, ids=[torrent_item.get('hash')])
|
||||
del_dup += 1
|
||||
else:
|
||||
logger.info(f"{torrent_item.get('hash')} 已在目的下载器中,跳过 ...")
|
||||
# 跳过计数
|
||||
skip += 1
|
||||
continue
|
||||
|
||||
# 转换保存路径
|
||||
download_dir = self.__convert_save_path(torrent_item.get('save_path'),
|
||||
self._frompath,
|
||||
self._topath)
|
||||
if not download_dir:
|
||||
logger.error(f"转换保存路径失败:{torrent_item.get('save_path')}")
|
||||
# 失败计数
|
||||
fail += 1
|
||||
continue
|
||||
|
||||
# 如果源下载器是QB检查是否有Tracker,没有的话额外获取
|
||||
if downloader == "qbittorrent":
|
||||
# 读取种子内容、解析种子文件
|
||||
content = torrent_file.read_bytes()
|
||||
if not content:
|
||||
logger.warn(f"读取种子文件失败:{torrent_file}")
|
||||
fail += 1
|
||||
continue
|
||||
# 读取trackers
|
||||
try:
|
||||
torrent_main = bdecode(content)
|
||||
main_announce = torrent_main.get('announce')
|
||||
except Exception as err:
|
||||
logger.warn(f"解析种子文件 {torrent_file} 失败:{str(err)}")
|
||||
fail += 1
|
||||
continue
|
||||
|
||||
if not main_announce:
|
||||
logger.info(f"{torrent_item.get('hash')} 未发现tracker信息,尝试补充tracker信息...")
|
||||
# 读取fastresume文件
|
||||
fastresume_file = Path(self._fromtorrentpath) / f"{torrent_item.get('hash')}.fastresume"
|
||||
if not fastresume_file.exists():
|
||||
logger.warn(f"fastresume文件不存在:{fastresume_file}")
|
||||
fail += 1
|
||||
continue
|
||||
# 尝试补充trackers
|
||||
try:
|
||||
# 解析fastresume文件
|
||||
fastresume = fastresume_file.read_bytes()
|
||||
torrent_fastresume = bdecode(fastresume)
|
||||
# 读取trackers
|
||||
fastresume_trackers = torrent_fastresume.get('trackers')
|
||||
if isinstance(fastresume_trackers, list) \
|
||||
and len(fastresume_trackers) > 0 \
|
||||
and fastresume_trackers[0]:
|
||||
# 重新赋值
|
||||
torrent_main['announce'] = fastresume_trackers[0][0]
|
||||
# 保留其他tracker,避免单一tracker无法连接
|
||||
if len(fastresume_trackers) > 1 or len(fastresume_trackers[0]) > 1:
|
||||
torrent_main['announce-list'] = fastresume_trackers
|
||||
# 替换种子文件路径
|
||||
torrent_file = settings.TEMP_PATH / f"{torrent_item.get('hash')}.torrent"
|
||||
# 编码并保存到临时文件
|
||||
torrent_file.write_bytes(bencode(torrent_main))
|
||||
except Exception as err:
|
||||
logger.error(f"解析fastresume文件 {fastresume_file} 出错:{str(err)}")
|
||||
fail += 1
|
||||
continue
|
||||
|
||||
# 发送到另一个下载器中下载:默认暂停、传输下载路径、关闭自动管理模式
|
||||
logger.info(f"添加转移做种任务到下载器 {todownloader}:{torrent_file}")
|
||||
download_id = self.__download(downloader=todownloader,
|
||||
content=torrent_file.read_bytes(),
|
||||
save_path=download_dir)
|
||||
if not download_id:
|
||||
# 下载失败
|
||||
fail += 1
|
||||
logger.error(f"添加下载任务失败:{torrent_file}")
|
||||
continue
|
||||
else:
|
||||
# 下载成功
|
||||
logger.info(f"成功添加转移做种任务,种子文件:{torrent_file}")
|
||||
|
||||
# TR会自动校验,QB需要手动校验
|
||||
if todownloader == "qbittorrent":
|
||||
logger.info(f"qbittorrent 开始校验 {download_id} ...")
|
||||
todownloader_obj.recheck_torrents(ids=[download_id])
|
||||
|
||||
# 追加校验任务
|
||||
logger.info(f"添加校验检查任务:{download_id} ...")
|
||||
if not self._recheck_torrents.get(todownloader):
|
||||
self._recheck_torrents[todownloader] = []
|
||||
self._recheck_torrents[todownloader].append(download_id)
|
||||
|
||||
# 删除源种子,不能删除文件!
|
||||
if self._deletesource:
|
||||
logger.info(f"删除源下载器任务(不含文件):{torrent_item.get('hash')} ...")
|
||||
downloader_obj.delete_torrents(delete_file=False, ids=[torrent_item.get('hash')])
|
||||
|
||||
# 成功计数
|
||||
success += 1
|
||||
# 插入转种记录
|
||||
history_key = "%s-%s" % (self._fromdownloader, torrent_item.get('hash'))
|
||||
self.save_data(key=history_key,
|
||||
value={
|
||||
"to_download": self._todownloader,
|
||||
"to_download_id": download_id,
|
||||
"delete_source": self._deletesource,
|
||||
"delete_duplicate": self._deleteduplicate,
|
||||
})
|
||||
# 触发校验任务
|
||||
if success > 0 and self._autostart:
|
||||
self.check_recheck()
|
||||
|
||||
# 发送通知
|
||||
if self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title="【转移做种任务执行完成】",
|
||||
text=f"总数:{total},成功:{success},失败:{fail},跳过:{skip},删除重复:{del_dup}"
|
||||
)
|
||||
else:
|
||||
logger.info(f"没有需要转移的种子")
|
||||
logger.info("转移做种任务执行完成")
|
||||
|
||||
def check_recheck(self):
|
||||
"""
|
||||
定时检查下载器中种子是否校验完成,校验完成且完整的自动开始辅种
|
||||
"""
|
||||
if not self._recheck_torrents:
|
||||
return
|
||||
if not self._todownloader:
|
||||
return
|
||||
if self._is_recheck_running:
|
||||
return
|
||||
|
||||
# 校验下载器
|
||||
downloader = self._todownloader
|
||||
|
||||
# 需要检查的种子
|
||||
recheck_torrents = self._recheck_torrents.get(downloader, [])
|
||||
if not recheck_torrents:
|
||||
return
|
||||
|
||||
logger.info(f"开始检查下载器 {downloader} 的校验任务 ...")
|
||||
|
||||
# 运行状态
|
||||
self._is_recheck_running = True
|
||||
|
||||
# 获取任务
|
||||
downloader_obj = self.__get_downloader(downloader)
|
||||
torrents, _ = downloader_obj.get_torrents(ids=recheck_torrents)
|
||||
if torrents:
|
||||
# 可做种的种子
|
||||
can_seeding_torrents = []
|
||||
for torrent in torrents:
|
||||
# 获取种子hash
|
||||
hash_str = self.__get_hash(torrent, downloader)
|
||||
# 判断是否可做种
|
||||
if self.__can_seeding(torrent, downloader):
|
||||
can_seeding_torrents.append(hash_str)
|
||||
|
||||
if can_seeding_torrents:
|
||||
logger.info(f"共 {len(can_seeding_torrents)} 个任务校验完成,开始做种")
|
||||
# 开始做种
|
||||
downloader_obj.start_torrents(ids=can_seeding_torrents)
|
||||
# 去除已经处理过的种子
|
||||
self._recheck_torrents[downloader] = list(
|
||||
set(recheck_torrents).difference(set(can_seeding_torrents)))
|
||||
else:
|
||||
logger.info(f"没有新的任务校验完成,将在下次个周期继续检查 ...")
|
||||
|
||||
elif torrents is None:
|
||||
logger.info(f"下载器 {downloader} 查询校验任务失败,将在下次继续查询 ...")
|
||||
else:
|
||||
logger.info(f"下载器 {downloader} 中没有需要检查的校验任务,清空待处理列表")
|
||||
self._recheck_torrents[downloader] = []
|
||||
|
||||
self._is_recheck_running = False
|
||||
|
||||
@staticmethod
|
||||
def __get_hash(torrent: Any, dl_type: str):
|
||||
"""
|
||||
获取种子hash
|
||||
"""
|
||||
try:
|
||||
return torrent.get("hash") if dl_type == "qbittorrent" else torrent.hashString
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def __get_label(torrent: Any, dl_type: str):
|
||||
"""
|
||||
获取种子标签
|
||||
"""
|
||||
try:
|
||||
return [str(tag).strip() for tag in torrent.get("tags").split(',')] \
|
||||
if dl_type == "qbittorrent" else torrent.labels or []
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def __get_save_path(torrent: Any, dl_type: str):
|
||||
"""
|
||||
获取种子保存路径
|
||||
"""
|
||||
try:
|
||||
return torrent.get("save_path") if dl_type == "qbittorrent" else torrent.download_dir
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def __can_seeding(torrent: Any, dl_type: str):
|
||||
"""
|
||||
判断种子是否可以做种并处于暂停状态
|
||||
"""
|
||||
try:
|
||||
return (torrent.get("state") == "pausedUP") if dl_type == "qbittorrent" \
|
||||
else (torrent.status.stopped and torrent.percent_done == 1)
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def __convert_save_path(save_path: str, from_root: str, to_root: str):
|
||||
"""
|
||||
转换保存路径
|
||||
"""
|
||||
try:
|
||||
# 没有保存目录,以目的根目录为准
|
||||
if not save_path:
|
||||
return to_root
|
||||
# 没有设置根目录时返回save_path
|
||||
if not to_root or not from_root:
|
||||
return save_path
|
||||
# 统一目录格式
|
||||
save_path = os.path.normpath(save_path).replace("\\", "/")
|
||||
from_root = os.path.normpath(from_root).replace("\\", "/")
|
||||
to_root = os.path.normpath(to_root).replace("\\", "/")
|
||||
# 替换根目录
|
||||
if save_path.startswith(from_root):
|
||||
return save_path.replace(from_root, to_root, 1)
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return None
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
退出插件
|
||||
"""
|
||||
try:
|
||||
if self._scheduler:
|
||||
self._scheduler.remove_all_jobs()
|
||||
if self._scheduler.running:
|
||||
self._event.set()
|
||||
self._scheduler.shutdown()
|
||||
self._event.clear()
|
||||
self._scheduler = None
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
454
plugins.v2/trackereditor/__init__.py
Normal file
454
plugins.v2/trackereditor/__init__.py
Normal file
@@ -0,0 +1,454 @@
|
||||
from typing import List, Tuple, Dict, Any, Union, Optional
|
||||
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from app.log import logger
|
||||
from app.modules.qbittorrent import Qbittorrent
|
||||
from qbittorrentapi.torrents import TorrentInfoList
|
||||
from app.modules.transmission import Transmission
|
||||
from transmission_rpc.torrent import Torrent
|
||||
from app.plugins import _PluginBase
|
||||
from app.schemas import NotificationType
|
||||
|
||||
|
||||
class TrackerEditor(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "Tracker替换"
|
||||
# 插件描述
|
||||
plugin_desc = "批量替换种子tracker,支持周期性巡检(如为TR,仅支持4.0以上版本)"
|
||||
# 插件图标
|
||||
plugin_icon = "trackereditor_A.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.5"
|
||||
# 插件作者
|
||||
plugin_author = "honue"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/honue"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "trackereditor_"
|
||||
# 加载顺序
|
||||
plugin_order = 30
|
||||
# 可使用的用户级别
|
||||
auth_level = 1
|
||||
|
||||
_downloader_type: str = None
|
||||
_username: str = None
|
||||
_password: str = None
|
||||
_host: str = None
|
||||
_port: int = None
|
||||
_target_domain: str = None
|
||||
_replace_domain: str = None
|
||||
|
||||
_onlyonce: bool = False
|
||||
_downloader: Union[Qbittorrent, Transmission] = None
|
||||
|
||||
_run_con_enable: bool = False
|
||||
_run_con: Optional[str] = None
|
||||
_notify: bool = False
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
if config:
|
||||
self._onlyonce = config.get("onlyonce")
|
||||
self._downloader_type = config.get("downloader_type")
|
||||
self._host = config.get("host")
|
||||
self._port = config.get("port")
|
||||
self._username = config.get("username")
|
||||
self._password = config.get("password")
|
||||
self._target_domain = config.get("target_domain")
|
||||
self._replace_domain = config.get("replace_domain")
|
||||
self._run_con_enable = config.get("run_con_enable")
|
||||
self._run_con = config.get("run_con")
|
||||
self._notify = config.get("notify")
|
||||
|
||||
if self._onlyonce:
|
||||
# 执行替换
|
||||
self.task()
|
||||
self._onlyonce = False
|
||||
# 更新onlyonce属性
|
||||
self.__update_config()
|
||||
|
||||
def task(self):
|
||||
logger.info(f"{'*' * 30}TrackerEditor: 开始执行Tracker替换{'*' * 30}")
|
||||
torrent_total_cnt: int = 0
|
||||
torrent_update_cnt: int = 0
|
||||
if self._downloader_type == "qbittorrent":
|
||||
self._downloader = Qbittorrent(self._host, self._port, self._username, self._password)
|
||||
torrent_info_list: TorrentInfoList
|
||||
torrent_info_list, error = self._downloader.get_torrents()
|
||||
torrent_total_cnt = len(torrent_info_list)
|
||||
if error:
|
||||
return
|
||||
for torrent in torrent_info_list:
|
||||
for tracker in torrent.trackers:
|
||||
if self._target_domain in tracker.url:
|
||||
original_url = tracker.url
|
||||
new_url = tracker.url.replace(self._target_domain, self._replace_domain)
|
||||
logger.info(f"{original_url} 替换为\n {new_url}")
|
||||
torrent.edit_tracker(orig_url=original_url, new_url=new_url)
|
||||
torrent_update_cnt += 1
|
||||
|
||||
elif self._downloader_type == "transmission":
|
||||
self._downloader = Transmission(self._host, self._port, self._username, self._password)
|
||||
tr_version = self._downloader.get_session().get('version')
|
||||
# "4.0.3 (6b0e49bbb2)" "3.00 (bb6b5a062e)"
|
||||
torrent_list: List[Torrent]
|
||||
torrent_list, error = self._downloader.get_torrents()
|
||||
torrent_total_cnt = len(torrent_list)
|
||||
if error:
|
||||
return
|
||||
for torrent in torrent_list:
|
||||
new_tracker_list = []
|
||||
for tracker in torrent.tracker_list:
|
||||
if self._target_domain in tracker:
|
||||
new_url = tracker.replace(self._target_domain, self._replace_domain)
|
||||
new_tracker_list.append(new_url)
|
||||
logger.info(f"{tracker} 替换为\n {new_url}")
|
||||
torrent_update_cnt += 1
|
||||
else:
|
||||
new_tracker_list.append(tracker)
|
||||
if int(tr_version[0]) >= 4:
|
||||
# 版本大于等于4.x
|
||||
__tracker_list = [new_tracker_list]
|
||||
else:
|
||||
__tracker_list = new_tracker_list
|
||||
if torrent_update_cnt > 0:
|
||||
update_result = self._downloader.update_tracker(hash_string=torrent.hashString, tracker_list=__tracker_list)
|
||||
if not update_result:
|
||||
logger.error(f"执行tracker修改出错,中止本次执行")
|
||||
torrent_update_cnt = 0
|
||||
break
|
||||
if torrent_update_cnt == 0:
|
||||
logger.info(f"tracker修改条数为0")
|
||||
logger.info(f"{'*' * 30}TrackerEditor: Tracker替换完成{'*' * 30}")
|
||||
if (self._run_con_enable and self._notify) or (self._onlyonce and self._notify):
|
||||
title = '【Tracker替换】'
|
||||
msg = f'''扫描下载器{self._downloader_type}\n总的种子数: {torrent_total_cnt}\n已修改种子数: {torrent_update_cnt}'''
|
||||
self.send_site_message(title, msg)
|
||||
|
||||
def __update_config(self):
|
||||
self.update_config({
|
||||
"onlyonce": self._onlyonce,
|
||||
"downloader_type": self._downloader_type,
|
||||
"username": self._username,
|
||||
"password": self._password,
|
||||
"host": self._host,
|
||||
"port": self._port,
|
||||
"target_domain": self._target_domain,
|
||||
"replace_domain": self._replace_domain,
|
||||
"run_cron_enable": self._run_con_enable,
|
||||
"run_cron": self._run_con,
|
||||
"notify": self._notify
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'run_con_enable',
|
||||
'label': '启用周期性巡检 (注: 请开启时,务必填写cron表达式)',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'onlyonce',
|
||||
'label': '立即运行一次',
|
||||
}
|
||||
}
|
||||
]
|
||||
}]
|
||||
}, {
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'notify',
|
||||
'label': '发送通知',
|
||||
}
|
||||
}
|
||||
]
|
||||
}]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'run_con',
|
||||
'label': 'cron表达式',
|
||||
'placeholder': '* * * * *'
|
||||
}
|
||||
}
|
||||
]
|
||||
}, {
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSelect',
|
||||
'props': {
|
||||
'model': 'downloader_type',
|
||||
'label': '下载器类型',
|
||||
'items': [
|
||||
{'title': 'Qbittorrent', 'value': 'qbittorrent'},
|
||||
{'title': 'Transmission', 'value': 'transmission'}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}]
|
||||
}, {
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'host',
|
||||
'label': 'host主机ip',
|
||||
'placeholder': '192.168.2.100'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'port',
|
||||
'label': 'qb/tr端口',
|
||||
'placeholder': '8989'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'username',
|
||||
'label': '用户名',
|
||||
'placeholder': 'username'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'password',
|
||||
'label': '密码',
|
||||
'placeholder': 'password'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}, {
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'target_domain',
|
||||
'label': '待替换文本',
|
||||
'placeholder': 'target.com'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'replace_domain',
|
||||
'label': '替换的文本',
|
||||
'placeholder': 'replace.net'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}, {
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '对下载器中所有符合代替换文本的tacker进行字符串replace替换' + '\n' +
|
||||
'现有tracker: https://baidu.com/announce.php?passkey=xxxx' + '\n' +
|
||||
'待替换 baidu.com 或 https://baidu.com' + '\n' +
|
||||
'用于替换的文本 qq.com 或 https://qq.com' + '\n' +
|
||||
'结果为 https://qq.com/announce.php?passkey=xxxx',
|
||||
'style': 'white-space: pre-line;'
|
||||
}
|
||||
},
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '强烈建议自己先添加一个tracker测试替换是否符合预期,程序是否正常运行',
|
||||
'style': 'white-space: pre-line;'
|
||||
}
|
||||
},
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '周期性巡检时指的是允许设置间隔一段进行巡检下载器中的种子Tracker' + '\n'
|
||||
'当匹配到等待替换的tracker时,进行替换,其中cron表达式是5位,例如:* * * * * 指的是每过一分钟轮训一次',
|
||||
'style': 'white-space: pre-line;'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"onlyonce": False,
|
||||
"downloader_type": "qbittorrent",
|
||||
"host": "192.168.2.100",
|
||||
"port": 8989,
|
||||
"username": "username",
|
||||
"password": "password",
|
||||
"target_domain": "",
|
||||
"replace_domain": "",
|
||||
"run_con_enable": False,
|
||||
"run_con": "",
|
||||
"notify": True
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return True
|
||||
|
||||
def stop_service(self):
|
||||
pass
|
||||
|
||||
def get_service(self) -> List[Dict[str, Any]]:
|
||||
if self._run_con_enable and self._run_con:
|
||||
logger.info(f"{'*' * 30}TrackerEditor: 注册公共调度服务{'*' * 30}")
|
||||
return [
|
||||
{
|
||||
"id": "TrackerChangeRun",
|
||||
"name": "启用周期性Tracker替换",
|
||||
"trigger": CronTrigger.from_crontab(self._run_con),
|
||||
"func": self.task,
|
||||
"kwargs": {}
|
||||
}]
|
||||
|
||||
return []
|
||||
|
||||
def send_site_message(self, title, message):
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=title,
|
||||
text=message
|
||||
)
|
||||
732
plugins.v2/trcommand/__init__.py
Normal file
732
plugins.v2/trcommand/__init__.py
Normal file
@@ -0,0 +1,732 @@
|
||||
from typing import List, Tuple, Dict, Any
|
||||
|
||||
from app.log import logger
|
||||
from app.modules.transmission import Transmission
|
||||
from app.plugins import _PluginBase
|
||||
from app.schemas import NotificationType
|
||||
from app.schemas.types import EventType
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from app.core.event import eventmanager, Event
|
||||
import time
|
||||
|
||||
|
||||
class TrCommand(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "TR远程操作"
|
||||
# 插件描述
|
||||
plugin_desc = "通过定时任务或交互命令远程操作TR暂停/开始/限速等。"
|
||||
# 插件图标
|
||||
plugin_icon = "Transmission_A.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.1"
|
||||
# 插件作者
|
||||
plugin_author = "Hoey"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/hoey94"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "trcommand_"
|
||||
# 加载顺序
|
||||
plugin_order = 1
|
||||
# 可使用的用户级别
|
||||
auth_level = 1
|
||||
|
||||
# 私有属性
|
||||
_tr = None
|
||||
_enabled: bool = False
|
||||
_notify: bool = False
|
||||
_pause_cron = None
|
||||
_resume_cron = None
|
||||
_only_pause_once = False
|
||||
_only_resume_once = False
|
||||
_upload_limit = 0
|
||||
_enable_upload_limit = False
|
||||
_download_limit = 0
|
||||
_enable_download_limit = False
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
# 停止现有任务
|
||||
self.stop_service()
|
||||
# 读取配置
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._notify = config.get("notify")
|
||||
self._pause_cron = config.get("pause_cron")
|
||||
self._resume_cron = config.get("resume_cron")
|
||||
self._only_pause_once = config.get("onlypauseonce")
|
||||
self._only_resume_once = config.get("onlyresumeonce")
|
||||
self._download_limit = config.get("download_limit")
|
||||
self._upload_limit = config.get("upload_limit")
|
||||
self._enable_download_limit = config.get("enable_download_limit")
|
||||
self._enable_upload_limit = config.get("enable_upload_limit")
|
||||
self._tr = Transmission()
|
||||
|
||||
if self._only_pause_once or self._only_resume_once:
|
||||
if self._only_pause_once and self._only_resume_once:
|
||||
logger.warning("只能选择一个: 立即暂停或立即开始所有任务")
|
||||
elif self._only_pause_once:
|
||||
self.pause_torrent()
|
||||
elif self._only_resume_once:
|
||||
self.resume_torrent()
|
||||
|
||||
self._only_resume_once = False
|
||||
self._only_pause_once = False
|
||||
self.update_config(
|
||||
{
|
||||
"onlypauseonce": False,
|
||||
"onlyresumeonce": False,
|
||||
"enabled": self._enabled,
|
||||
"notify": self._notify,
|
||||
"pause_cron": self._pause_cron,
|
||||
"resume_cron": self._resume_cron,
|
||||
}
|
||||
)
|
||||
|
||||
# 限速
|
||||
self.set_limit(self._upload_limit, self._download_limit)
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
定义远程控制命令
|
||||
:return: 命令关键字、事件、描述、附带数据
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"cmd": "/pause_torrents",
|
||||
"event": EventType.PluginAction,
|
||||
"desc": "暂停TR种子",
|
||||
"category": "TR",
|
||||
"data": {"action": "pause_torrents"},
|
||||
},
|
||||
{
|
||||
"cmd": "/resume_torrents",
|
||||
"event": EventType.PluginAction,
|
||||
"desc": "开始TR种子",
|
||||
"category": "TR",
|
||||
"data": {"action": "resume_torrents"},
|
||||
},
|
||||
{
|
||||
"cmd": "/toggle_upload_limit",
|
||||
"event": EventType.PluginAction,
|
||||
"desc": "TR切换上传限速状态",
|
||||
"category": "TR",
|
||||
"data": {"action": "toggle_upload_limit"},
|
||||
},
|
||||
{
|
||||
"cmd": "/toggle_download_limit",
|
||||
"event": EventType.PluginAction,
|
||||
"desc": "TR切换下载限速状态",
|
||||
"category": "TR",
|
||||
"data": {"action": "toggle_download_limit"},
|
||||
},
|
||||
]
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_service(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
注册插件公共服务
|
||||
[{
|
||||
"id": "服务ID",
|
||||
"name": "服务名称",
|
||||
"trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()",
|
||||
"func": self.xxx,
|
||||
"kwargs": {} # 定时器参数
|
||||
}]
|
||||
"""
|
||||
if self._enabled and self._pause_cron and self._resume_cron:
|
||||
return [
|
||||
{
|
||||
"id": "TrPause",
|
||||
"name": "暂停TR所有任务",
|
||||
"trigger": CronTrigger.from_crontab(self._pause_cron),
|
||||
"func": self.pause_torrent,
|
||||
"kwargs": {},
|
||||
},
|
||||
{
|
||||
"id": "TrResume",
|
||||
"name": "开始TR所有任务",
|
||||
"trigger": CronTrigger.from_crontab(self._resume_cron),
|
||||
"func": self.resume_torrent,
|
||||
"kwargs": {},
|
||||
},
|
||||
]
|
||||
if self._enabled and self._pause_cron:
|
||||
return [
|
||||
{
|
||||
"id": "TrPause",
|
||||
"name": "暂停TR所有任务",
|
||||
"trigger": CronTrigger.from_crontab(self._pause_cron),
|
||||
"func": self.pause_torrent,
|
||||
"kwargs": {},
|
||||
}
|
||||
]
|
||||
if self._enabled and self._resume_cron:
|
||||
return [
|
||||
{
|
||||
"id": "TrResume",
|
||||
"name": "开始TR所有任务",
|
||||
"trigger": CronTrigger.from_crontab(self._resume_cron),
|
||||
"func": self.resume_torrent,
|
||||
"kwargs": {},
|
||||
}
|
||||
]
|
||||
return []
|
||||
|
||||
def get_all_torrents(self):
|
||||
all_torrents, error = self._tr.get_torrents()
|
||||
if error:
|
||||
logger.error(f"获取TR种子失败: {error}")
|
||||
if self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【TR远程操作】",
|
||||
text=f"获取TR种子失败,请检查TR配置",
|
||||
)
|
||||
return []
|
||||
|
||||
if not all_torrents:
|
||||
logger.warning("TR没有种子")
|
||||
if self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【TR远程操作】",
|
||||
text=f"TR中没有种子",
|
||||
)
|
||||
return []
|
||||
return all_torrents
|
||||
|
||||
@staticmethod
|
||||
def get_torrents_status(torrents):
|
||||
downloading_torrents = []
|
||||
uploading_torrents = []
|
||||
paused_torrents = []
|
||||
checking_torrents = []
|
||||
error_torrents = []
|
||||
for torrent in torrents:
|
||||
match torrent.status.lower():
|
||||
case 'stopped':
|
||||
paused_torrents.append(torrent.id)
|
||||
case 'check_pending':
|
||||
checking_torrents.append(torrent.id)
|
||||
case 'checking':
|
||||
checking_torrents.append(torrent.id)
|
||||
case 'download_pending':
|
||||
downloading_torrents.append(torrent.id)
|
||||
case 'downloading':
|
||||
downloading_torrents.append(torrent.id)
|
||||
case 'seed_pending':
|
||||
uploading_torrents.append(torrent.id)
|
||||
case 'seeding':
|
||||
uploading_torrents.append(torrent.id)
|
||||
|
||||
return (
|
||||
downloading_torrents,
|
||||
uploading_torrents,
|
||||
paused_torrents,
|
||||
checking_torrents,
|
||||
error_torrents,
|
||||
)
|
||||
|
||||
@eventmanager.register(EventType.PluginAction)
|
||||
def handle_pause_torrent(self, event: Event):
|
||||
if not self._enabled:
|
||||
return
|
||||
if event:
|
||||
event_data = event.event_data
|
||||
if not event_data or event_data.get("action") != "pause_torrents":
|
||||
return
|
||||
self.pause_torrent()
|
||||
|
||||
def pause_torrent(self):
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
all_torrents = self.get_all_torrents()
|
||||
hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = (
|
||||
self.get_torrents_status(all_torrents)
|
||||
)
|
||||
to_be_paused = hash_downloading + hash_uploading + hash_checking
|
||||
logger.info(
|
||||
f"暂定任务启动 \n"
|
||||
f"种子总数: {len(all_torrents)} \n"
|
||||
f"做种数量: {len(hash_uploading)}\n"
|
||||
f"下载数量: {len(hash_downloading)}\n"
|
||||
f"检查数量: {len(hash_checking)}\n"
|
||||
f"暂停数量: {len(hash_paused)}\n"
|
||||
f"错误数量: {len(hash_error)}\n"
|
||||
f"暂停操作中请稍等...\n",
|
||||
)
|
||||
if self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【TR暂停任务启动】",
|
||||
text=f"种子总数: {len(all_torrents)} \n"
|
||||
f"做种数量: {len(hash_uploading)}\n"
|
||||
f"下载数量: {len(hash_downloading)}\n"
|
||||
f"检查数量: {len(hash_checking)}\n"
|
||||
f"暂停数量: {len(hash_paused)}\n"
|
||||
f"错误数量: {len(hash_error)}\n"
|
||||
f"暂停操作中请稍等...\n",
|
||||
)
|
||||
if len(to_be_paused) > 0:
|
||||
if self._tr.stop_torrents(ids=to_be_paused):
|
||||
logger.info(f"暂停了{len(to_be_paused)}个种子")
|
||||
else:
|
||||
logger.error(f"暂停种子失败")
|
||||
if self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【TR远程操作】",
|
||||
text=f"暂停种子失败",
|
||||
)
|
||||
# 每个种子等待1ms以让状态切换成功,至少等待1S
|
||||
wait_time = 0.001 * len(to_be_paused) + 1
|
||||
time.sleep(wait_time)
|
||||
|
||||
all_torrents = self.get_all_torrents()
|
||||
hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = (
|
||||
self.get_torrents_status(all_torrents)
|
||||
)
|
||||
logger.info(
|
||||
f"暂定任务完成 \n"
|
||||
f"种子总数: {len(all_torrents)} \n"
|
||||
f"做种数量: {len(hash_uploading)}\n"
|
||||
f"下载数量: {len(hash_downloading)}\n"
|
||||
f"检查数量: {len(hash_checking)}\n"
|
||||
f"暂停数量: {len(hash_paused)}\n"
|
||||
f"错误数量: {len(hash_error)}\n"
|
||||
)
|
||||
if self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【TR暂停任务完成】",
|
||||
text=f"种子总数: {len(all_torrents)} \n"
|
||||
f"做种数量: {len(hash_uploading)}\n"
|
||||
f"下载数量: {len(hash_downloading)}\n"
|
||||
f"检查数量: {len(hash_checking)}\n"
|
||||
f"暂停数量: {len(hash_paused)}\n"
|
||||
f"错误数量: {len(hash_error)}\n",
|
||||
)
|
||||
|
||||
@eventmanager.register(EventType.PluginAction)
|
||||
def handle_resume_torrent(self, event: Event):
|
||||
if not self._enabled:
|
||||
return
|
||||
if event:
|
||||
event_data = event.event_data
|
||||
if not event_data or event_data.get("action") != "resume_torrents":
|
||||
return
|
||||
self.resume_torrent()
|
||||
|
||||
def resume_torrent(self):
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
all_torrents = self.get_all_torrents()
|
||||
hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = (
|
||||
self.get_torrents_status(all_torrents)
|
||||
)
|
||||
logger.info(
|
||||
f"TR开始任务启动 \n"
|
||||
f"种子总数: {len(all_torrents)} \n"
|
||||
f"做种数量: {len(hash_uploading)}\n"
|
||||
f"下载数量: {len(hash_downloading)}\n"
|
||||
f"检查数量: {len(hash_checking)}\n"
|
||||
f"暂停数量: {len(hash_paused)}\n"
|
||||
f"错误数量: {len(hash_error)}\n"
|
||||
f"开始操作中请稍等...\n",
|
||||
)
|
||||
if self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【TR开始任务启动】",
|
||||
text=f"种子总数: {len(all_torrents)} \n"
|
||||
f"做种数量: {len(hash_uploading)}\n"
|
||||
f"下载数量: {len(hash_downloading)}\n"
|
||||
f"检查数量: {len(hash_checking)}\n"
|
||||
f"暂停数量: {len(hash_paused)}\n"
|
||||
f"错误数量: {len(hash_error)}\n"
|
||||
f"开始操作中请稍等...\n",
|
||||
)
|
||||
if not self._tr.start_torrents(ids=hash_paused):
|
||||
logger.error(f"开始种子失败")
|
||||
if self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【TR远程操作】",
|
||||
text=f"开始种子失败",
|
||||
)
|
||||
# 每个种子等待1ms以让状态切换成功,至少等待1S
|
||||
wait_time = 0.001 * len(hash_paused) + 1
|
||||
time.sleep(wait_time)
|
||||
|
||||
all_torrents = self.get_all_torrents()
|
||||
hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = (
|
||||
self.get_torrents_status(all_torrents)
|
||||
)
|
||||
logger.info(
|
||||
f"开始任务完成 \n"
|
||||
f"种子总数: {len(all_torrents)} \n"
|
||||
f"做种数量: {len(hash_uploading)}\n"
|
||||
f"下载数量: {len(hash_downloading)}\n"
|
||||
f"检查数量: {len(hash_checking)}\n"
|
||||
f"暂停数量: {len(hash_paused)}\n"
|
||||
f"错误数量: {len(hash_error)}\n"
|
||||
)
|
||||
if self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【TR开始任务完成】",
|
||||
text=f"种子总数: {len(all_torrents)} \n"
|
||||
f"做种数量: {len(hash_uploading)}\n"
|
||||
f"下载数量: {len(hash_downloading)}\n"
|
||||
f"检查数量: {len(hash_checking)}\n"
|
||||
f"暂停数量: {len(hash_paused)}\n"
|
||||
f"错误数量: {len(hash_error)}\n",
|
||||
)
|
||||
|
||||
@eventmanager.register(EventType.PluginAction)
|
||||
def handle_toggle_upload_limit(self, event: Event):
|
||||
if not self._enabled:
|
||||
return
|
||||
if event:
|
||||
event_data = event.event_data
|
||||
if not event_data or event_data.get("action") != "toggle_upload_limit":
|
||||
return
|
||||
|
||||
self.set_limit(self._upload_limit, self._download_limit)
|
||||
|
||||
@eventmanager.register(EventType.PluginAction)
|
||||
def handle_toggle_download_limit(self, event: Event):
|
||||
if not self._enabled:
|
||||
return
|
||||
if event:
|
||||
event_data = event.event_data
|
||||
if not event_data or event_data.get("action") != "toggle_download_limit":
|
||||
return
|
||||
self.set_limit(self._upload_limit, self._download_limit)
|
||||
|
||||
def set_both_limit(self, upload_limit, download_limit):
|
||||
if not self._enable_upload_limit or not self._enable_upload_limit:
|
||||
return True
|
||||
|
||||
if not upload_limit or not upload_limit.isdigit() or not download_limit or not download_limit.isdigit():
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【TR远程操作】",
|
||||
text=f"设置TR限速失败,download_limit或upload_limit不是一个数值",
|
||||
)
|
||||
return False
|
||||
|
||||
return self._tr.set_speed_limit(
|
||||
download_limit=int(download_limit), upload_limit=int(upload_limit)
|
||||
)
|
||||
|
||||
def set_upload_limit(self, upload_limit):
|
||||
if not self._enable_upload_limit:
|
||||
return True
|
||||
|
||||
if not upload_limit or not upload_limit.isdigit():
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【TR远程操作】",
|
||||
text=f"设置TR限速失败,upload_limit不是一个数值",
|
||||
)
|
||||
return False
|
||||
|
||||
download_limit_current_val, _ = self._tr.get_speed_limit()
|
||||
return self._tr.set_speed_limit(
|
||||
download_limit=int(download_limit_current_val), upload_limit=int(upload_limit)
|
||||
)
|
||||
|
||||
def set_download_limit(self, download_limit):
|
||||
if not self._enable_download_limit:
|
||||
return True
|
||||
|
||||
if not download_limit or not download_limit.isdigit():
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【TR远程操作】",
|
||||
text=f"设置TR限速失败,download_limit不是一个数值",
|
||||
)
|
||||
return False
|
||||
|
||||
_, upload_limit_current_val = self._tr.get_speed_limit()
|
||||
return self._tr.set_speed_limit(
|
||||
download_limit=int(download_limit), upload_limit=int(upload_limit_current_val)
|
||||
)
|
||||
|
||||
def set_limit(self, upload_limit, download_limit):
|
||||
# 限速,满足以下三种情况设置限速
|
||||
# 1. 插件启用 && download_limit启用
|
||||
# 2. 插件启用 && upload_limit启用
|
||||
# 3. 插件启用 && download_limit启用 && upload_limit启用
|
||||
|
||||
flag = None
|
||||
if self._enabled and self._enable_download_limit and self._enable_upload_limit:
|
||||
flag = self.set_both_limit(upload_limit, download_limit)
|
||||
|
||||
elif flag is None and self._enabled and self._enable_download_limit:
|
||||
flag = self.set_download_limit(download_limit)
|
||||
|
||||
elif flag is None and self._enabled and self._enable_upload_limit:
|
||||
flag = self.set_upload_limit(upload_limit)
|
||||
|
||||
if flag:
|
||||
logger.info(f"设置TR限速成功")
|
||||
if self._notify:
|
||||
if upload_limit == 0:
|
||||
text = f"上传无限速"
|
||||
else:
|
||||
text = f"上传限速:{upload_limit} KB/s"
|
||||
if download_limit == 0:
|
||||
text += f"\n下载无限速"
|
||||
else:
|
||||
text += f"\n下载限速:{download_limit} KB/s"
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【TR远程操作】",
|
||||
text=text,
|
||||
)
|
||||
elif not flag:
|
||||
logger.error(f"TR设置限速失败")
|
||||
if self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【TR远程操作】",
|
||||
text=f"设置TR限速失败",
|
||||
)
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"component": "VForm",
|
||||
"content": [
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 6},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "enabled",
|
||||
"label": "启用插件",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 6},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "notify",
|
||||
"label": "发送通知",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 6},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "onlypauseonce",
|
||||
"label": "立即暂停所有任务",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 6},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "onlyresumeonce",
|
||||
"label": "立即开始所有任务",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 6},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextField",
|
||||
"props": {
|
||||
"model": "pause_cron",
|
||||
"label": "暂停周期",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 6},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextField",
|
||||
"props": {
|
||||
"model": "resume_cron",
|
||||
"label": "开始周期",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 6},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "enable_upload_limit",
|
||||
"label": "上传限速",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 6},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "enable_download_limit",
|
||||
"label": "下载限速",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 6},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextField",
|
||||
"props": {
|
||||
"model": "upload_limit",
|
||||
"label": "上传限速 KB/s",
|
||||
"placeholder": "KB/s",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 6},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextField",
|
||||
"props": {
|
||||
"model": "download_limit",
|
||||
"label": "下载限速 KB/s",
|
||||
"placeholder": "KB/s",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VAlert",
|
||||
"props": {
|
||||
"type": "info",
|
||||
"variant": "tonal",
|
||||
"text": "开始周期和暂停周期使用Cron表达式,如:0 0 0 * *",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VAlert",
|
||||
"props": {
|
||||
"type": "info",
|
||||
"variant": "tonal",
|
||||
"text": "交互命令有暂停TR种子、开始TR种子、TR切换上传限速状态、TR切换下载限速状态",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VAlert",
|
||||
"props": {
|
||||
"type": "info",
|
||||
"variant": "tonal",
|
||||
"text": "PT精神重在分享,请勿恶意限速,因此导致账号被封禁作者概不负责",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"notify": True,
|
||||
"onlypauseonce": False,
|
||||
"onlyresumeonce": False,
|
||||
"upload_limit": 0,
|
||||
"download_limit": 0,
|
||||
"enable_upload_limit": False,
|
||||
"enable_download_limit": False,
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
def stop_service(self):
|
||||
pass
|
||||
1124
plugins.v2/vcbanimemonitor/__init__.py
Normal file
1124
plugins.v2/vcbanimemonitor/__init__.py
Normal file
File diff suppressed because it is too large
Load Diff
284
plugins.v2/vcbanimemonitor/remeta.py
Normal file
284
plugins.v2/vcbanimemonitor/remeta.py
Normal file
@@ -0,0 +1,284 @@
|
||||
import concurrent
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.core.metainfo import MetaInfoPath
|
||||
from app.log import logger
|
||||
from app.schemas import MediaType
|
||||
|
||||
season_patterns = [
|
||||
{"pattern": re.compile(r"S(\d+)$", re.IGNORECASE), "group": 1},
|
||||
{"pattern": re.compile(r"(\d+)$", re.IGNORECASE), "group": 1},
|
||||
{"pattern": re.compile(r"(\d+)(st|nd|rd|th)?\s*season", re.IGNORECASE), "group": 1},
|
||||
{"pattern": re.compile(r"(.*) ?\s*season (\d+)", re.IGNORECASE), "group": 2},
|
||||
{"pattern": re.compile(r"\s(II|III|IV|V|VI|VII|VIII|IX|X)$", re.IGNORECASE), "group": "1"}
|
||||
]
|
||||
episode_patterns = [
|
||||
{"pattern": re.compile(r"(\d+)\((\d+)\)", re.IGNORECASE), "group": 2},
|
||||
{"pattern": re.compile(r"(\d+)", re.IGNORECASE), "group": 1},
|
||||
{"pattern": re.compile(r'(\d+)v\d+', re.IGNORECASE), "group": 1},
|
||||
]
|
||||
|
||||
ova_patterns = [
|
||||
re.compile(r".*?(OVA|OAD).*?", re.IGNORECASE),
|
||||
re.compile(r"\d+\.5"),
|
||||
re.compile(r"00")
|
||||
]
|
||||
|
||||
final_season_patterns = [
|
||||
re.compile('final season', re.IGNORECASE),
|
||||
re.compile('The Final', re.IGNORECASE),
|
||||
re.compile(r'\sFinal')
|
||||
]
|
||||
|
||||
movie_patterns = [
|
||||
re.compile("Movie", re.IGNORECASE),
|
||||
re.compile("the Movie", re.IGNORECASE),
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class VCBMetaBase:
|
||||
# 转化为小写后的原始文件名称 (不含后缀)
|
||||
original_title: str = ""
|
||||
# 解析后不包含季度和集数的标题
|
||||
title: str = ""
|
||||
# 类型:TV / Movie (默认TV)
|
||||
type: str = "TV"
|
||||
# 可能含有季度的标题,一级解析后的标题
|
||||
season_title: str = ""
|
||||
# 可能含有集数的字符串列表
|
||||
ep_title: List[str] = None
|
||||
# 识别出来的季度
|
||||
season: int = None
|
||||
# 识别出来的集数
|
||||
ep: int = None
|
||||
# 是否是OVA/OAD
|
||||
is_ova: bool = False
|
||||
# TMDB ID
|
||||
tmdb_id: int = None
|
||||
|
||||
|
||||
blocked_words = ["vcb-studio", "360p", "480p", "720p", "1080p", "2160p", "hdr", "x265", "x264", "aac", "flac"]
|
||||
|
||||
|
||||
class ReMeta:
|
||||
|
||||
def __init__(self, ova_switch: bool = False, custom_season_patterns: list[dict] = None):
|
||||
self.meta = None
|
||||
# TODO:自定义季度匹配规则
|
||||
self.custom_season_patterns = custom_season_patterns
|
||||
self.season_patterns = season_patterns
|
||||
self.ova_switch = ova_switch
|
||||
self.vcb_meta = VCBMetaBase()
|
||||
self.is_ova = False
|
||||
|
||||
def is_tv(self, title: str) -> bool:
|
||||
"""
|
||||
判断是否是TV
|
||||
"""
|
||||
if title.count("[") != 4 and title.count("]") != 4:
|
||||
self.vcb_meta.type = "Movie"
|
||||
self.vcb_meta.title = re.sub(r'\[.*?\]', '', title).strip()
|
||||
return False
|
||||
return True
|
||||
|
||||
def handel_file(self, file_path: Path):
|
||||
file_name = file_path.stem.strip().lower()
|
||||
self.vcb_meta.original_title = file_name
|
||||
if not self.is_tv(file_name):
|
||||
logger.warn(
|
||||
"不符合VCB-Studio的剧集命名规范,归类为电影,跳过剧集模块处理。注意:年份较为久远的作品可能在此会判断错误")
|
||||
self.parse_movie()
|
||||
else:
|
||||
self.tv_mode()
|
||||
self.is_ova = self.vcb_meta.is_ova
|
||||
meta = MetaInfoPath(file_path)
|
||||
meta.title = self.vcb_meta.title
|
||||
meta.en_name = self.vcb_meta.title
|
||||
if self.vcb_meta.type == "Movie":
|
||||
meta.type = MediaType.MOVIE
|
||||
else:
|
||||
meta.type = MediaType.TV
|
||||
if self.vcb_meta.ep is not None:
|
||||
meta.begin_episode = self.vcb_meta.ep
|
||||
if self.vcb_meta.season is not None:
|
||||
meta.begin_season = self.vcb_meta.season
|
||||
if self.vcb_meta.tmdb_id is not None:
|
||||
meta.tmdbid = self.vcb_meta.tmdb_id
|
||||
return meta
|
||||
|
||||
def split_season_ep(self):
|
||||
# 把所有的[] 里面的内容获取出来,不需要[]本身
|
||||
self.vcb_meta.ep_title = re.findall(r'\[(.*?)\]', self.vcb_meta.original_title)
|
||||
# 去除所有[]后只剩下剧名
|
||||
self.vcb_meta.season_title = re.sub(r"\[.*?\]", "", self.vcb_meta.original_title).strip()
|
||||
if self.vcb_meta.ep_title:
|
||||
self.culling_blocked_words()
|
||||
logger.info(
|
||||
f"分离出包含可能季度的内容部分:{self.vcb_meta.season_title} | 可能包含集数的内容部分: {self.vcb_meta.ep_title}")
|
||||
self.vcb_meta.title = self.vcb_meta.season_title
|
||||
if not self.vcb_meta.ep_title:
|
||||
self.vcb_meta.title = self.vcb_meta.season_title
|
||||
logger.warn("未识别出可能存在集数位置的信息,跳过剩余识别步骤!")
|
||||
|
||||
def tv_mode(self):
|
||||
logger.info("开始分离季度和集数部分")
|
||||
self.split_season_ep()
|
||||
if not self.vcb_meta.ep_title:
|
||||
return
|
||||
self.parse_season()
|
||||
self.parse_episode()
|
||||
|
||||
def parse_season(self):
|
||||
"""
|
||||
从标题中解析季度
|
||||
"""
|
||||
flag = False
|
||||
for pattern in season_patterns:
|
||||
match = pattern["pattern"].search(self.vcb_meta.season_title)
|
||||
if match:
|
||||
if isinstance(pattern["group"], int):
|
||||
self.vcb_meta.season = int(match.group(pattern["group"]))
|
||||
else:
|
||||
self.vcb_meta.season = self.roman_to_int(match.group(pattern["group"]))
|
||||
# 匹配成功后,标题中去除季度信息
|
||||
self.vcb_meta.title = pattern["pattern"].sub("", self.vcb_meta.season_title).strip
|
||||
logger.info(f"识别出季度为{self.vcb_meta.season}")
|
||||
return
|
||||
logger.info(f"正常匹配季度失败,开始匹配ova/oad/最终季度")
|
||||
if not flag:
|
||||
# 匹配是否为最终季
|
||||
for pattern in final_season_patterns:
|
||||
if pattern.search(self.vcb_meta.season_title):
|
||||
logger.info("命中到最终季匹配规则")
|
||||
self.vcb_meta.title = pattern.sub("", self.vcb_meta.season_title).strip()
|
||||
self.handle_final_season()
|
||||
return
|
||||
logger.info("未识别出最终季度,开始匹配OVA/OAD")
|
||||
# 匹配是否为OVA/OAD
|
||||
if "ova" in self.vcb_meta.season_title or "oad" in self.vcb_meta.season_title:
|
||||
logger.info("季度部分命中到OVA/OAD匹配规则")
|
||||
if self.ova_switch:
|
||||
logger.info("开启OVA/OAD处理逻辑")
|
||||
self.vcb_meta.is_ova = True
|
||||
for pattern in ova_patterns:
|
||||
if pattern.search(self.vcb_meta.season_title):
|
||||
self.vcb_meta.title = pattern.sub("", self.vcb_meta.season_title).strip()
|
||||
self.vcb_meta.title = re.sub("ova|oad", "", self.vcb_meta.season_title).strip()
|
||||
self.vcb_meta.season = 0
|
||||
return
|
||||
logger.warn("未识别出季度,默认处理逻辑返回第一季")
|
||||
self.vcb_meta.title = self.vcb_meta.season_title
|
||||
self.vcb_meta.season = 1
|
||||
|
||||
def parse_episode(self):
|
||||
"""
|
||||
从标题中解析集数
|
||||
"""
|
||||
# 从ep_title中剔除不相关的内容之后只剩下存在集数的字符串
|
||||
ep = self.vcb_meta.ep_title[0]
|
||||
for pattern in episode_patterns:
|
||||
match = pattern["pattern"].search(ep)
|
||||
if match:
|
||||
self.vcb_meta.ep = int(match.group(pattern["group"]))
|
||||
logger.info(f"识别出集数为{self.vcb_meta.ep}")
|
||||
return
|
||||
# 直接进入判断是否为OVA/OAD
|
||||
for pattern in ova_patterns:
|
||||
if pattern.search(ep):
|
||||
self.vcb_meta.is_ova = True
|
||||
# 直接获取数字
|
||||
self.vcb_meta.ep = int(re.search(r"\d+", ep).group()) or 1
|
||||
logger.info(f"OVA模式下识别出集数为{self.vcb_meta.ep}")
|
||||
self.vcb_meta.season = 0
|
||||
return
|
||||
|
||||
def culling_blocked_words(self):
|
||||
"""
|
||||
从ep_title中剔除不相关的内容
|
||||
"""
|
||||
blocked_set = set(blocked_words) # 将阻止词列表转换为集合
|
||||
result = [ep for ep in self.vcb_meta.ep_title if not any(word in ep for word in blocked_set)]
|
||||
self.vcb_meta.ep_title = result
|
||||
|
||||
def handle_final_season(self):
|
||||
|
||||
_, medias = MediaChain().search(title=self.vcb_meta.title)
|
||||
if not medias:
|
||||
logger.warning("匹配到最终季时无法找到对应的媒体信息!季度返回默认值:1")
|
||||
self.vcb_meta.season = 1
|
||||
return
|
||||
|
||||
filter_medias = [media for media in medias if media.type == MediaType.TV]
|
||||
if not filter_medias:
|
||||
logger.warning("匹配到最终季时无法找到对应的媒体信息!季度返回默认值:1")
|
||||
self.vcb_meta.season = 1
|
||||
return
|
||||
medias = [media for media in filter_medias if media.popularity or media.vote_average]
|
||||
if not medias:
|
||||
logger.warning("匹配到最终季时无法找到对应的媒体信息!季度返回默认值:1")
|
||||
self.vcb_meta.season = 1
|
||||
return
|
||||
# 获取欢迎度最高或者评分最高的媒体
|
||||
medias_sorted = sorted(medias, key=lambda x: x.popularity or x.vote_average, reverse=True)[0]
|
||||
self.vcb_meta.tmdb_id = medias_sorted.tmdb_id
|
||||
if medias_sorted.tmdb_id:
|
||||
seasons_info = TmdbChain().tmdb_seasons(tmdbid=medias_sorted.tmdb_id)
|
||||
if seasons_info:
|
||||
self.vcb_meta.season = len(seasons_info)
|
||||
logger.info(f"获取到最终季度,季度为{self.vcb_meta.season}")
|
||||
return
|
||||
logger.warning("无法获取到最终季度信息,季度返回默认值:1")
|
||||
self.vcb_meta.season = 1
|
||||
|
||||
|
||||
|
||||
def parse_movie(self):
|
||||
logger.info("开始尝试剧场版模式解析")
|
||||
for pattern in movie_patterns:
|
||||
if pattern.search(self.vcb_meta.title):
|
||||
logger.info("命中剧场版匹配规则,加上剧场版标识辅助识别")
|
||||
self.vcb_meta.type = "Movie"
|
||||
self.vcb_meta.title = pattern.sub("", self.vcb_meta.title).strip()
|
||||
self.vcb_meta.title = self.vcb_meta.title
|
||||
return
|
||||
|
||||
def find_ova_episode(self):
|
||||
"""
|
||||
搜索OVA的集数
|
||||
TODO:模糊匹配OVA的集数
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@staticmethod
|
||||
def roman_to_int(s) -> int:
|
||||
"""
|
||||
:param s: 罗马数字字符串
|
||||
罗马数字转整数
|
||||
"""
|
||||
roman_dict = {'I': 1, 'V': 5, 'X': 10, 'L': 50, 'C': 100, 'D': 500, 'M': 1000}
|
||||
total = 0
|
||||
prev_value = 0
|
||||
|
||||
for char in reversed(s): # 反向遍历罗马数字字符串
|
||||
current_value = roman_dict[char]
|
||||
if current_value >= prev_value:
|
||||
total += current_value # 如果当前值大于等于前一个值,加上当前值
|
||||
else:
|
||||
total -= current_value # 如果当前值小于前一个值,减去当前值
|
||||
prev_value = current_value
|
||||
|
||||
return total
|
||||
|
||||
|
||||
|
||||
# if __name__ == '__main__':
|
||||
# ReMeta(
|
||||
# ova_switch=True,
|
||||
# ).handel_file(Path(
|
||||
# r"[Airota&Nekomoe kissaten&VCB-Studio] Yuru Camp [Heya Camp EP00][Ma10p_1080p][x265_flac].mkv"))
|
||||
@@ -497,7 +497,6 @@ class RemoveLink(_PluginBase):
|
||||
self._transferhistory.delete(transfer_history.id)
|
||||
logger.info(f"删除历史记录:{transfer_history.id}")
|
||||
|
||||
|
||||
def delete_empty_folders(self, path):
|
||||
"""
|
||||
从指定路径开始,逐级向上层目录检测并删除空目录,直到遇到非空目录或到达指定监控目录为止
|
||||
@@ -589,7 +588,7 @@ class RemoveLink(_PluginBase):
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【清理硬链接】",
|
||||
text=f"监控到删除源文件:[{file_path}]\n"
|
||||
f"同步删除硬链接文件:[{path}]",
|
||||
f"同步删除硬链接文件:[{path}]",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
|
||||
Reference in New Issue
Block a user