diff --git a/package.json b/package.json
index edef083..5534a0a 100644
--- a/package.json
+++ b/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": "飞书机器人消息通知",
diff --git a/plugins.v2/autoclean/__init__.py b/plugins.v2/autoclean/__init__.py
new file mode 100644
index 0000000..d7ba6c5
--- /dev/null
+++ b/plugins.v2/autoclean/__init__.py
@@ -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))
diff --git a/plugins.v2/bestfilmversion/__init__.py b/plugins.v2/bestfilmversion/__init__.py
new file mode 100644
index 0000000..ce0b5f8
--- /dev/null
+++ b/plugins.v2/bestfilmversion/__init__.py
@@ -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()
diff --git a/plugins.v2/brushflow/__init__.py b/plugins.v2/brushflow/__init__.py
new file mode 100644
index 0000000..faa6c05
--- /dev/null
+++ b/plugins.v2/brushflow/__init__.py
@@ -0,0 +1,4054 @@
+import base64
+import json
+import random
+import re
+import threading
+import time
+from datetime import datetime, timedelta
+from threading import Event
+from typing import Any, List, Dict, Tuple, Optional, Union, Set
+from urllib.parse import urlparse, parse_qs, unquote
+
+import pytz
+from app.helper.sites import SitesHelper
+from apscheduler.schedulers.background import BackgroundScheduler
+
+from app import schemas
+from app.chain.torrents import TorrentsChain
+from app.core.config import settings
+from app.core.context import MediaInfo
+from app.core.metainfo import MetaInfo
+from app.db.site_oper import SiteOper
+from app.db.subscribe_oper import SubscribeOper
+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, TorrentInfo, MediaType
+from app.schemas.types import EventType
+from app.utils.http import RequestUtils
+from app.utils.string import StringUtils
+
+lock = threading.Lock()
+
+
+class BrushConfig:
+ """
+ 刷流配置
+ """
+
+ def __init__(self, config: dict, process_site_config=True):
+ self.enabled = config.get("enabled", False)
+ self.notify = config.get("notify", True)
+ self.onlyonce = config.get("onlyonce", False)
+ self.brushsites = config.get("brushsites", [])
+ self.downloader = config.get("downloader", "qbittorrent")
+ self.disksize = self.__parse_number(config.get("disksize"))
+ self.freeleech = config.get("freeleech", "free")
+ self.hr = config.get("hr", "no")
+ self.maxupspeed = self.__parse_number(config.get("maxupspeed"))
+ self.maxdlspeed = self.__parse_number(config.get("maxdlspeed"))
+ self.maxdlcount = self.__parse_number(config.get("maxdlcount"))
+ self.include = config.get("include")
+ self.exclude = config.get("exclude")
+ self.size = config.get("size")
+ self.seeder = config.get("seeder")
+ self.pubtime = config.get("pubtime")
+ self.seed_time = self.__parse_number(config.get("seed_time"))
+ self.hr_seed_time = self.__parse_number(config.get("hr_seed_time"))
+ self.seed_ratio = self.__parse_number(config.get("seed_ratio"))
+ self.seed_size = self.__parse_number(config.get("seed_size"))
+ self.download_time = self.__parse_number(config.get("download_time"))
+ self.seed_avgspeed = self.__parse_number(config.get("seed_avgspeed"))
+ self.seed_inactivetime = self.__parse_number(config.get("seed_inactivetime"))
+ self.delete_size_range = config.get("delete_size_range")
+ self.up_speed = self.__parse_number(config.get("up_speed"))
+ self.dl_speed = self.__parse_number(config.get("dl_speed"))
+ self.auto_archive_days = self.__parse_number(config.get("auto_archive_days"))
+ self.save_path = config.get("save_path")
+ self.clear_task = config.get("clear_task", False)
+ self.archive_task = config.get("archive_task", False)
+ self.delete_except_tags = config.get("delete_except_tags")
+ self.except_subscribe = config.get("except_subscribe", True)
+ self.brush_sequential = config.get("brush_sequential", False)
+ self.proxy_download = config.get("proxy_download", False)
+ self.proxy_delete = config.get("proxy_delete", False)
+ self.active_time_range = config.get("active_time_range")
+ self.downloader_monitor = config.get("downloader_monitor")
+ self.qb_category = config.get("qb_category")
+ self.auto_qb_category = config.get("auto_qb_category", False)
+ self.qb_first_last_piece = config.get("qb_first_last_piece", False)
+ self.site_hr_active = config.get("site_hr_active", False)
+
+ self.brush_tag = "刷流"
+ # 站点独立配置
+ self.enable_site_config = config.get("enable_site_config", False)
+ self.site_config = config.get("site_config", "[]")
+ self.group_site_configs = {}
+
+ # 如果开启了独立站点配置,那么则初始化,否则判断配置是否为空,如果为空,则恢复默认配置
+ if process_site_config:
+ if self.enable_site_config:
+ self.__initialize_site_config()
+ elif not self.site_config:
+ self.site_config = self.get_demo_site_config()
+
+ def __initialize_site_config(self):
+ if not self.site_config:
+ logger.error(f"没有设置站点配置,已关闭站点独立配置并恢复默认配置示例,请检查配置项")
+ self.site_config = self.get_demo_site_config()
+ self.group_site_configs = {}
+ self.enable_site_config = False
+ return
+
+ # 定义允许覆盖的字段列表
+ allowed_fields = {
+ "freeleech",
+ "hr",
+ "include",
+ "exclude",
+ "size",
+ "seeder",
+ "pubtime",
+ "seed_time",
+ "hr_seed_time",
+ "seed_ratio",
+ "seed_size",
+ "download_time",
+ "seed_avgspeed",
+ "seed_inactivetime",
+ "save_path",
+ "proxy_download",
+ "proxy_delete",
+ "qb_category",
+ "auto_qb_category",
+ "qb_first_last_piece",
+ "site_hr_active"
+ # 当新增支持字段时,仅在此处添加字段名
+ }
+ try:
+ # site_config中去掉以//开始的行
+ site_config = re.sub(r'//.*?\n', '', self.site_config).strip()
+ site_configs = json.loads(site_config)
+ self.group_site_configs = {}
+ for config in site_configs:
+ sitename = config.get("sitename")
+ if not sitename:
+ continue
+
+ # 只从站点特定配置中获取允许的字段
+ site_specific_config = {key: config[key] for key in allowed_fields & set(config.keys())}
+
+ full_config = {key: getattr(self, key) for key in vars(self) if
+ key not in ['group_site_configs', 'site_config']}
+ full_config.update(site_specific_config)
+
+ self.group_site_configs[sitename] = BrushConfig(config=full_config, process_site_config=False)
+ except Exception as e:
+ logger.error(f"解析站点配置失败,已停用插件并关闭站点独立配置,请检查配置项,错误详情: {e}")
+ self.group_site_configs = {}
+ self.enable_site_config = False
+ self.enabled = False
+
+ @staticmethod
+ def get_demo_site_config() -> str:
+ desc = (
+ "// 以下为配置示例,请参考:https://github.com/InfinityPacer/MoviePilot-Plugins/blob/main/plugins/brushflowlowfreq/README.md 进行配置\n"
+ "// 如与全局保持一致的配置项,请勿在站点配置中配置\n"
+ "// 注意无关内容需使用 // 注释\n")
+ config = """[{
+ "sitename": "站点1",
+ "seed_time": 96,
+ "hr_seed_time": 144
+}, {
+ "sitename": "站点2",
+ "hr": "yes",
+ "size": "10-500",
+ "seeder": "5-10",
+ "pubtime": "5-120",
+ "seed_time": 96,
+ "save_path": "/downloads/site2",
+ "proxy_download": true,
+ "hr_seed_time": 144
+}, {
+ "sitename": "站点3",
+ "freeleech": "free",
+ "hr": "yes",
+ "include": "",
+ "exclude": "",
+ "size": "10-500",
+ "seeder": "1",
+ "pubtime": "5-120",
+ "seed_time": 120,
+ "hr_seed_time": 144,
+ "seed_ratio": "",
+ "seed_size": "",
+ "download_time": "",
+ "seed_avgspeed": "",
+ "seed_inactivetime": "",
+ "save_path": "/downloads/site1",
+ "proxy_download": false,
+ "proxy_delete": false,
+ "qb_category": "刷流",
+ "auto_qb_category": true,
+ "qb_first_last_piece": true,
+ "site_hr_active": true
+}]"""
+ return desc + config
+
+ def get_site_config(self, sitename):
+ """
+ 根据站点名称获取特定的BrushConfig实例。如果没有找到站点特定的配置,则返回全局的BrushConfig实例。
+ """
+ if not self.enable_site_config:
+ return self
+ return self if not sitename else self.group_site_configs.get(sitename, self)
+
+ @staticmethod
+ def __parse_number(value):
+ if value is None or value == '': # 更精确地检查None或空字符串
+ return value
+ elif isinstance(value, int): # 直接判断是否为int
+ return value
+ elif isinstance(value, float): # 直接判断是否为float
+ return value
+ else:
+ try:
+ number = float(value)
+ # 检查number是否等于其整数形式
+ if number == int(number):
+ return int(number)
+ else:
+ return number
+ except (ValueError, TypeError):
+ return 0
+
+ def __format_value(self, v):
+ """
+ Format the value to mimic JSON serialization. This is now an instance method.
+ """
+ if isinstance(v, str):
+ return f'"{v}"'
+ elif isinstance(v, (int, float, bool)):
+ return str(v).lower() if isinstance(v, bool) else str(v)
+ elif isinstance(v, list):
+ return '[' + ', '.join(self.__format_value(i) for i in v) + ']'
+ elif isinstance(v, dict):
+ return '{' + ', '.join(f'"{k}": {self.__format_value(val)}' for k, val in v.items()) + '}'
+ else:
+ return str(v)
+
+ def __str__(self):
+ attrs = vars(self)
+ # Note the use of self.format_value(v) here to call the instance method
+ attrs_str = ', '.join(f'"{k}": {self.__format_value(v)}' for k, v in attrs.items())
+ return f'{{ {attrs_str} }}'
+
+ def __repr__(self):
+ return self.__str__()
+
+
+class BrushFlow(_PluginBase):
+ # region 全局定义
+
+ # 插件名称
+ plugin_name = "站点刷流"
+ # 插件描述
+ plugin_desc = "自动托管刷流,将会提高对应站点的访问频率。"
+ # 插件图标
+ plugin_icon = "brush.jpg"
+ # 插件版本
+ plugin_version = "3.8"
+ # 插件作者
+ plugin_author = "jxxghp,InfinityPacer"
+ # 作者主页
+ author_url = "https://github.com/InfinityPacer"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "brushflow_"
+ # 加载顺序
+ plugin_order = 21
+ # 可使用的用户级别
+ auth_level = 2
+
+ # 私有属性
+ siteshelper = None
+ siteoper = None
+ torrents = None
+ subscribeoper = None
+ qb = None
+ tr = None
+ # 刷流配置
+ _brush_config = None
+ # Brush任务是否启动
+ _task_brush_enable = False
+ # 订阅缓存信息
+ _subscribe_infos = None
+ # Brush定时
+ _brush_interval = 10
+ # Check定时
+ _check_interval = 5
+ # 退出事件
+ _event = Event()
+ _scheduler = None
+ # tabs
+ _tabs = None
+
+ # endregion
+
+ def init_plugin(self, config: dict = None):
+ self.siteshelper = SitesHelper()
+ self.siteoper = SiteOper()
+ self.torrents = TorrentsChain()
+ self.subscribeoper = SubscribeOper()
+ self._task_brush_enable = False
+
+ if not config:
+ logger.info("站点刷流任务出错,无法获取插件配置")
+ return False
+
+ self._tabs = config.get("_tabs", None)
+
+ # 如果配置校验没有通过,那么这里修改配置文件后退出
+ if not self.__validate_and_fix_config(config=config):
+ self._brush_config = BrushConfig(config=config)
+ self._brush_config.enabled = False
+ self.__update_config()
+ return
+
+ self._brush_config = BrushConfig(config=config)
+
+ brush_config = self._brush_config
+
+ # 这里先过滤掉已删除的站点并保存,特别注意的是,这里保留了界面选择站点时的顺序,以便后续站点随机刷流或顺序刷流
+ if brush_config.brushsites:
+ site_id_to_public_status = {site.get("id"): site.get("public") for site in self.siteshelper.get_indexers()}
+ brush_config.brushsites = [
+ site_id for site_id in brush_config.brushsites
+ if site_id in site_id_to_public_status and not site_id_to_public_status[site_id]
+ ]
+
+ self.__update_config()
+
+ if brush_config.clear_task:
+ self.__clear_tasks()
+ brush_config.clear_task = False
+ brush_config.archive_task = False
+ self.__update_config()
+
+ elif brush_config.archive_task:
+ self.__archive_tasks()
+ brush_config.archive_task = False
+ self.__update_config()
+
+ if brush_config.enable_site_config:
+ logger.debug(f"已开启站点独立配置,配置信息:{brush_config}")
+ else:
+ logger.debug(f"没有开启站点独立配置,配置信息:{brush_config}")
+
+ # 停止现有任务
+ self.stop_service()
+
+ if not self.__setup_downloader():
+ return
+
+ # 如果下载器都没有配置,那么这里也不需要继续
+ if not brush_config.downloader:
+ brush_config.enabled = False
+ self.__update_config()
+ logger.info(f"站点刷流服务停止,没有配置下载器")
+ return
+
+ # 如果站点都没有配置,则不开启定时刷流服务
+ if not brush_config.brushsites:
+ logger.info(f"站点刷流Brush定时服务停止,没有配置站点")
+
+ # 如果开启&存在站点时,才需要启用后台任务
+ self._task_brush_enable = brush_config.enabled and brush_config.brushsites
+
+ # 检查是否启用了一次性任务
+ if brush_config.onlyonce:
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+
+ logger.info(f"站点刷流Brush服务启动,立即运行一次")
+ self._scheduler.add_job(self.brush, 'date',
+ run_date=datetime.now(
+ tz=pytz.timezone(settings.TZ)
+ ) + timedelta(seconds=3),
+ name="站点刷流Brush服务")
+
+ logger.info(f"站点刷流Check服务启动,立即运行一次")
+ self._scheduler.add_job(self.check, 'date',
+ run_date=datetime.now(
+ tz=pytz.timezone(settings.TZ)
+ ) + timedelta(seconds=3),
+ name="站点刷流Check服务")
+
+ # 关闭一次性开关
+ brush_config.onlyonce = False
+ self.__update_config()
+
+ # 存在任务则启动任务
+ if self._scheduler.get_jobs():
+ # 启动服务
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+
+ def get_state(self) -> bool:
+ brush_config = self.__get_brush_config()
+ return True if brush_config and brush_config.enabled 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": {} # 定时器参数
+ }]
+ """
+ services = []
+
+ brush_config = self.__get_brush_config()
+ if not brush_config:
+ return services
+
+ if self._task_brush_enable:
+ logger.info(f"站点刷流Brush定时服务启动,时间间隔 {self._brush_interval} 分钟")
+ services.append({
+ "id": "BrushFlow",
+ "name": "站点刷流Brush服务",
+ "trigger": "interval",
+ "func": self.brush,
+ "kwargs": {"minutes": self._brush_interval}
+ })
+
+ if brush_config.enabled:
+ logger.info(f"站点刷流Check定时服务启动,时间间隔 {self._check_interval} 分钟")
+ services.append({
+ "id": "BrushFlowCheck",
+ "name": "站点刷流Check服务",
+ "trigger": "interval",
+ "func": self.check,
+ "kwargs": {"minutes": self._check_interval}
+ })
+
+ if not services:
+ logger.info("站点刷流服务未开启")
+
+ return services
+
+ def __get_total_elements(self) -> List[dict]:
+ """
+ 组装汇总元素
+ """
+ # 统计数据
+ statistic_info = self.__get_statistic_info()
+ # 总上传量
+ total_uploaded = StringUtils.str_filesize(statistic_info.get("uploaded") or 0)
+ # 总下载量
+ total_downloaded = StringUtils.str_filesize(statistic_info.get("downloaded") or 0)
+ # 下载种子数
+ total_count = statistic_info.get("count") or 0
+ # 删除种子数
+ total_deleted = statistic_info.get("deleted") or 0
+ # 待归档种子数
+ total_unarchived = statistic_info.get("unarchived") or 0
+ # 活跃种子数
+ total_active = statistic_info.get("active") or 0
+ # 活跃上传量
+ total_active_uploaded = StringUtils.str_filesize(statistic_info.get("active_uploaded") or 0)
+ # 活跃下载量
+ total_active_downloaded = StringUtils.str_filesize(statistic_info.get("active_downloaded") or 0)
+
+ return [
+ # 总上传量
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3,
+ 'sm': 6
+ },
+ 'content': [
+ {
+ 'component': 'VCard',
+ 'props': {
+ 'variant': 'tonal',
+ },
+ 'content': [
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'd-flex align-center',
+ },
+ 'content': [
+ {
+ 'component': 'VAvatar',
+ 'props': {
+ 'rounded': True,
+ 'variant': 'text',
+ 'class': 'me-3'
+ },
+ 'content': [
+ {
+ 'component': 'VImg',
+ 'props': {
+ 'src': '/plugin_icon/upload.png'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-caption'
+ },
+ 'text': '总上传量 / 活跃'
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex align-center flex-wrap'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-h6'
+ },
+ 'text': f"{total_uploaded} / {total_active_uploaded}"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ ]
+ },
+ # 总下载量
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3,
+ 'sm': 6
+ },
+ 'content': [
+ {
+ 'component': 'VCard',
+ 'props': {
+ 'variant': 'tonal',
+ },
+ 'content': [
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'd-flex align-center',
+ },
+ 'content': [
+ {
+ 'component': 'VAvatar',
+ 'props': {
+ 'rounded': True,
+ 'variant': 'text',
+ 'class': 'me-3'
+ },
+ 'content': [
+ {
+ 'component': 'VImg',
+ 'props': {
+ 'src': '/plugin_icon/download.png'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-caption'
+ },
+ 'text': '总下载量 / 活跃'
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex align-center flex-wrap'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-h6'
+ },
+ 'text': f"{total_downloaded} / {total_active_downloaded}"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ ]
+ },
+ # 下载种子数
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3,
+ 'sm': 6
+ },
+ 'content': [
+ {
+ 'component': 'VCard',
+ 'props': {
+ 'variant': 'tonal',
+ },
+ 'content': [
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'd-flex align-center',
+ },
+ 'content': [
+ {
+ 'component': 'VAvatar',
+ 'props': {
+ 'rounded': True,
+ 'variant': 'text',
+ 'class': 'me-3'
+ },
+ 'content': [
+ {
+ 'component': 'VImg',
+ 'props': {
+ 'src': '/plugin_icon/seed.png'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-caption'
+ },
+ 'text': '下载种子数 / 活跃'
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex align-center flex-wrap'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-h6'
+ },
+ 'text': f"{total_count} / {total_active}"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ ]
+ },
+ # 删除种子数
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3,
+ 'sm': 6
+ },
+ 'content': [
+ {
+ 'component': 'VCard',
+ 'props': {
+ 'variant': 'tonal',
+ },
+ 'content': [
+ {
+ 'component': 'VCardText',
+ 'props': {
+ 'class': 'd-flex align-center',
+ },
+ 'content': [
+ {
+ 'component': 'VAvatar',
+ 'props': {
+ 'rounded': True,
+ 'variant': 'text',
+ 'class': 'me-3'
+ },
+ 'content': [
+ {
+ 'component': 'VImg',
+ 'props': {
+ 'src': '/plugin_icon/delete.png'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'div',
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-caption'
+ },
+ 'text': '删除种子数 / 待归档'
+ },
+ {
+ 'component': 'div',
+ 'props': {
+ 'class': 'd-flex align-center flex-wrap'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'props': {
+ 'class': 'text-h6'
+ },
+ 'text': f"{total_deleted} / {total_unarchived}"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ ]
+
+ def get_dashboard(self) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], List[dict]]]:
+ """
+ 获取插件仪表盘页面,需要返回:1、仪表板col配置字典;2、全局配置(自动刷新等);3、仪表板页面元素配置json(含数据)
+ 1、col配置参考:
+ {
+ "cols": 12, "md": 6
+ }
+ 2、全局配置参考:
+ {
+ "refresh": 10 // 自动刷新时间,单位秒
+ }
+ 3、页面配置使用Vuetify组件拼装,参考:https://vuetifyjs.com/
+ """
+ # 列配置
+ cols = {
+ "cols": 12
+ }
+ # 全局配置
+ attrs = {}
+ # 拼装页面元素
+ elements = [
+ {
+ 'component': 'VRow',
+ 'content': self.__get_total_elements()
+ }
+ ]
+ return cols, attrs, elements
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+
+ # 站点的可选项
+ site_options = [{"title": site.get("name"), "value": site.get("id")}
+ for site in self.siteshelper.get_indexers()]
+ 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
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'multiple': True,
+ 'chips': True,
+ 'clearable': True,
+ 'model': 'brushsites',
+ 'label': '刷流站点',
+ 'items': site_options
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ "cols": 12,
+ "md": 4
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'model': 'downloader',
+ 'label': '下载器',
+ 'items': [
+ {'title': 'Qbittorrent', 'value': 'qbittorrent'},
+ {'title': 'Transmission', 'value': 'transmission'}
+ ]
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ "cols": 12,
+ "md": 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'active_time_range',
+ 'label': '开启时间段',
+ 'placeholder': '如:00:00-08:00'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ "cols": 12,
+ "md": 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'delete_size_range',
+ 'label': '动态删种阈值(GB)',
+ 'placeholder': '如:500 或 500-1000,达到后删除任务'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VTabs',
+ 'props': {
+ 'model': '_tabs',
+ 'style': {
+ 'margin-top': '8px',
+ 'margin-bottom': '16px'
+ },
+ 'stacked': True,
+ 'fixed-tabs': True
+ },
+ 'content': [
+ {
+ 'component': 'VTab',
+ 'props': {
+ 'value': 'base_tab'
+ },
+ 'text': '基本配置'
+ }, {
+ 'component': 'VTab',
+ 'props': {
+ 'value': 'download_tab'
+ },
+ 'text': '选种规则'
+ }, {
+ 'component': 'VTab',
+ 'props': {
+ 'value': 'delete_tab'
+ },
+ 'text': '删除规则'
+ }, {
+ 'component': 'VTab',
+ 'props': {
+ 'value': 'other_tab'
+ },
+ 'text': '更多配置'
+ }
+ ]
+ },
+ {
+ 'component': 'VWindow',
+ 'props': {
+ 'model': '_tabs'
+ },
+ 'content': [
+ {
+ 'component': 'VWindowItem',
+ 'props': {
+ 'value': 'base_tab'
+ },
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'props': {
+ 'style': {
+ 'margin-top': '0px'
+ }
+ },
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ "cols": 12,
+ "md": 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'maxdlcount',
+ 'label': '同时下载任务数',
+ 'placeholder': '达到后停止新增任务'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ "cols": 12,
+ "md": 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'disksize',
+ 'label': '保种体积(GB)',
+ 'placeholder': '如:500,达到后停止新增任务'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ "cols": 12,
+ "md": 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'qb_category',
+ 'label': '种子分类',
+ 'placeholder': '仅支持qBittorrent,需提前创建'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ "cols": 12,
+ "md": 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'maxupspeed',
+ 'label': '总上传带宽(KB/s)',
+ 'placeholder': '达到后停止新增任务'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ "cols": 12,
+ "md": 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'maxdlspeed',
+ 'label': '总下载带宽(KB/s)',
+ 'placeholder': '达到后停止新增任务'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ "cols": 12,
+ "md": 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'save_path',
+ 'label': '保存目录',
+ 'placeholder': '留空自动'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ "cols": 12,
+ "md": 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'up_speed',
+ 'label': '单任务上传限速(KB/s)',
+ 'placeholder': '种子上传限速'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ "cols": 12,
+ "md": 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'dl_speed',
+ 'label': '单任务下载限速(KB/s)',
+ 'placeholder': '种子下载限速'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'auto_archive_days',
+ 'label': '自动归档记录天数',
+ 'placeholder': '超过此天数后自动归档',
+ 'type': 'number',
+ "min": "0"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VWindowItem',
+ 'props': {
+ 'value': 'download_tab'
+ },
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'props': {
+ 'style': {
+ 'margin-top': '0px'
+ }
+ },
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ "cols": 12,
+ "md": 4
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'model': 'hr',
+ 'label': '排除H&R',
+ 'items': [
+ {'title': '是', 'value': 'yes'},
+ {'title': '否', 'value': 'no'},
+ ]
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ "cols": 12,
+ "md": 4
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'model': 'freeleech',
+ 'label': '促销',
+ 'items': [
+ {'title': '全部(包括普通)', 'value': ''},
+ {'title': '免费', 'value': 'free'},
+ {'title': '2X免费', 'value': '2xfree'},
+ ]
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ "cols": 12,
+ "md": 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'pubtime',
+ 'label': '发布时间(分钟)',
+ 'placeholder': '如:5 或 5-10'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ "cols": 12,
+ "md": 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'size',
+ 'label': '种子大小(GB)',
+ 'placeholder': '如:5 或 5-10'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ "cols": 12,
+ "md": 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'seeder',
+ 'label': '做种人数',
+ 'placeholder': '如:5 或 5-10'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ "cols": 12,
+ "md": 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'include',
+ 'label': '包含规则',
+ 'placeholder': '支持正式表达式'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ "cols": 12,
+ "md": 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'exclude',
+ 'label': '排除规则',
+ 'placeholder': '支持正式表达式'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VWindowItem',
+ 'props': {
+ 'value': 'delete_tab'
+ },
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'props': {
+ 'style': {
+ 'margin-top': '0px'
+ }
+ },
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ "cols": 12,
+ "md": 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'seed_time',
+ 'label': '做种时间(小时)',
+ 'placeholder': '达到后删除任务'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ "cols": 12,
+ "md": 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'hr_seed_time',
+ 'label': 'H&R做种时间(小时)',
+ 'placeholder': '达到后删除任务'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ "cols": 12,
+ "md": 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'seed_ratio',
+ 'label': '分享率',
+ 'placeholder': '达到后删除任务'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ "cols": 12,
+ "md": 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'seed_size',
+ 'label': '上传量(GB)',
+ 'placeholder': '达到后删除任务'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ "cols": 12,
+ "md": 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'seed_avgspeed',
+ 'label': '平均上传速度(KB/s)',
+ 'placeholder': '低于时删除任务'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ "cols": 12,
+ "md": 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'download_time',
+ 'label': '下载超时时间(小时)',
+ 'placeholder': '达到后删除任务'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ "cols": 12,
+ "md": 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'seed_inactivetime',
+ 'label': '未活动时间(分钟)',
+ 'placeholder': '超过时删除任务'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ "cols": 12,
+ "md": 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'delete_except_tags',
+ 'label': '删除排除标签',
+ 'placeholder': '如:MOVIEPILOT,H&R'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VWindowItem',
+ 'props': {
+ 'value': 'other_tab'
+ },
+ 'content': [
+ {
+ 'component': 'VRow',
+ 'props': {
+ 'style': {
+ 'margin-top': '-16px'
+ }
+ },
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'brush_sequential',
+ 'label': '站点顺序刷流',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'except_subscribe',
+ 'label': '排除订阅(实验性功能)',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'qb_first_last_piece',
+ 'label': '优先下载首尾文件块',
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'clear_task',
+ 'label': '清除统计数据',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'archive_task',
+ 'label': '归档已删除种子',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'proxy_delete',
+ 'label': '动态删除种子(实验性功能)',
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ "content": [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'enable_site_config',
+ 'label': '站点独立配置',
+ }
+ }
+ ]
+ },
+ {
+ "component": "VCol",
+ "props": {
+ "cols": 12,
+ "md": 4
+ },
+ "content": [
+ {
+ "component": "VSwitch",
+ "props": {
+ "model": "dialog_closed",
+ "label": "打开站点配置窗口"
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'proxy_download',
+ 'label': '代理下载种子',
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ "content": [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'downloader_monitor',
+ 'label': '下载器监控',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'auto_qb_category',
+ 'label': '自动分类管理',
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'props': {
+ 'style': {
+ 'margin-top': '12px'
+ },
+ },
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'success',
+ 'variant': 'tonal'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'text': '注意:详细配置说明以及刷流规则请参考:'
+ },
+ {
+ 'component': 'a',
+ 'props': {
+ 'href': 'https://github.com/InfinityPacer/MoviePilot-Plugins/blob/main/plugins/brushflowlowfreq/README.md',
+ 'target': '_blank'
+ },
+ 'content': [
+ {
+ 'component': 'u',
+ 'text': 'README'
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'error',
+ 'variant': 'tonal',
+ 'text': '注意:排除H&R并不保证能完全适配所有站点(部分站点在列表页不显示H&R标志,但实际上是有H&R的),请注意核对使用'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "component": "VDialog",
+ "props": {
+ "model": "dialog_closed",
+ "max-width": "65rem",
+ "overlay-class": "v-dialog--scrollable v-overlay--scroll-blocked",
+ "content-class": "v-card v-card--density-default v-card--variant-elevated rounded-t"
+ },
+ "content": [
+ {
+ "component": "VCard",
+ "props": {
+ "title": "设置站点配置"
+ },
+ "content": [
+ {
+ "component": "VDialogCloseBtn",
+ "props": {
+ "model": "dialog_closed"
+ }
+ },
+ {
+ "component": "VCardText",
+ "props": {},
+ "content": [
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAceEditor',
+ 'props': {
+ 'modelvalue': 'site_config',
+ 'lang': 'json',
+ 'theme': 'monokai',
+ 'style': 'height: 30rem',
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal'
+ },
+ 'content': [
+ {
+ 'component': 'span',
+ 'text': '注意:只有启用站点独立配置时,该配置项才会生效,详细配置参考:'
+ },
+ {
+ 'component': 'a',
+ 'props': {
+ 'href': 'https://github.com/InfinityPacer/MoviePilot-Plugins/blob/main/plugins/brushflowlowfreq/README.md',
+ 'target': '_blank'
+ },
+ 'content': [
+ {
+ 'component': 'u',
+ 'text': 'README'
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "enabled": False,
+ "notify": True,
+ "onlyonce": False,
+ "clear_task": False,
+ "archive_task": False,
+ "delete_except_tags": f"{settings.TORRENT_TAG},H&R" if settings.TORRENT_TAG else "H&R",
+ "except_subscribe": True,
+ "brush_sequential": False,
+ "proxy_download": False,
+ "proxy_delete": False,
+ "freeleech": "free",
+ "hr": "yes",
+ "enable_site_config": False,
+ "downloader_monitor": False,
+ "auto_qb_category": False,
+ "qb_first_last_piece": False,
+ "site_config": BrushConfig.get_demo_site_config()
+ }
+
+ def get_page(self) -> List[dict]:
+ # 种子明细
+ torrents = self.get_data("torrents") or {}
+
+ if not torrents:
+ return [
+ {
+ 'component': 'div',
+ 'text': '暂无数据',
+ 'props': {
+ 'class': 'text-center',
+ }
+ }
+ ]
+ else:
+ data_list = torrents.values()
+ # 按time倒序排序
+ data_list = sorted(data_list, key=lambda x: x.get("time") or 0, reverse=True)
+
+ # 表格标题
+ headers = [
+ {'title': '站点', 'key': 'site', 'sortable': True},
+ {'title': '标题', 'key': 'title', 'sortable': True},
+ {'title': '大小', 'key': 'size', 'sortable': True},
+ {'title': '上传量', 'key': 'uploaded', 'sortable': True},
+ {'title': '下载量', 'key': 'downloaded', 'sortable': True},
+ {'title': '分享率', 'key': 'ratio', 'sortable': True},
+ {'title': '状态', 'key': 'status', 'sortable': True},
+ ]
+ # 种子数据明细
+ items = [
+ {
+ 'site': data.get("site_name"),
+ 'title': data.get("title"),
+ 'size': StringUtils.str_filesize(data.get("size")),
+ 'uploaded': StringUtils.str_filesize(data.get("uploaded") or 0),
+ 'downloaded': StringUtils.str_filesize(data.get("downloaded") or 0),
+ 'ratio': round(data.get('ratio') or 0, 2),
+ 'status': "已删除" if data.get("deleted") else "正常"
+ } for data in data_list
+ ]
+
+ # 拼装页面
+ return [
+ {
+ 'component': 'VRow',
+ 'props': {
+ 'style': {
+ 'overflow': 'hidden',
+ }
+ },
+ 'content': self.__get_total_elements() + [
+ # 种子明细
+ {
+ 'component': 'VRow',
+ 'props': {
+ 'class': 'd-none d-sm-block',
+ },
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VDataTableVirtual',
+ 'props': {
+ 'class': 'text-sm',
+ 'headers': headers,
+ 'items': items,
+ 'height': '30rem',
+ 'density': 'compact',
+ 'fixed-header': True,
+ 'hide-no-data': True,
+ 'hover': True
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+
+ 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))
+
+ # region Brush
+
+ def brush(self):
+ """
+ 定时刷流,添加下载任务
+ """
+ brush_config = self.__get_brush_config()
+
+ if not brush_config.brushsites or not brush_config.downloader:
+ return
+
+ if not self.__is_current_time_in_range():
+ logger.info(f"当前不在指定的刷流时间区间内,刷流操作将暂时暂停")
+ return
+
+ with lock:
+ logger.info(f"开始执行刷流任务 ...")
+
+ torrent_tasks: Dict[str, dict] = self.get_data("torrents") or {}
+ torrents_size = self.__calculate_seeding_torrents_size(torrent_tasks=torrent_tasks)
+
+ # 判断能否通过保种体积前置条件
+ size_condition_passed, reason = self.__evaluate_size_condition_for_brush(torrents_size=torrents_size)
+ self.__log_brush_conditions(passed=size_condition_passed, reason=reason)
+ if not size_condition_passed:
+ logger.info(f"刷流任务执行完成")
+ return
+
+ # 判断能否通过刷流前置条件
+ pre_condition_passed, reason = self.__evaluate_pre_conditions_for_brush()
+ self.__log_brush_conditions(passed=pre_condition_passed, reason=reason)
+ if not pre_condition_passed:
+ logger.info(f"刷流任务执行完成")
+ return
+
+ statistic_info = self.__get_statistic_info()
+
+ # 获取所有站点的信息,并过滤掉不存在的站点
+ site_infos = []
+ for siteid in brush_config.brushsites:
+ siteinfo = self.siteoper.get(siteid)
+ if siteinfo:
+ site_infos.append(siteinfo)
+
+ # 根据是否开启顺序刷流来决定是否需要打乱顺序
+ if not brush_config.brush_sequential:
+ random.shuffle(site_infos)
+
+ logger.info(f"即将针对站点 {', '.join(site.name for site in site_infos)} 开始刷流")
+
+ # 获取订阅标题
+ subscribe_titles = self.__get_subscribe_titles()
+
+ # 处理所有站点
+ for site in site_infos:
+ # 如果站点刷流没有正确响应,说明没有通过前置条件,其他站点也不需要继续刷流了
+ if not self.__brush_site_torrents(siteid=site.id, torrent_tasks=torrent_tasks,
+ statistic_info=statistic_info,
+ subscribe_titles=subscribe_titles):
+ logger.info(f"站点 {site.name} 刷流中途结束,停止后续刷流")
+ break
+ else:
+ logger.info(f"站点 {site.name} 刷流完成")
+
+ # 保存数据
+ self.save_data("torrents", torrent_tasks)
+ # 保存统计数据
+ self.save_data("statistic", statistic_info)
+ logger.info(f"刷流任务执行完成")
+
+ def __brush_site_torrents(self, siteid, torrent_tasks: Dict[str, dict], statistic_info: Dict[str, int],
+ subscribe_titles: Set[str]) -> bool:
+ """
+ 针对站点进行刷流
+ """
+ siteinfo = self.siteoper.get(siteid)
+ if not siteinfo:
+ logger.warn(f"站点不存在:{siteid}")
+ return True
+
+ logger.info(f"开始获取站点 {siteinfo.name} 的新种子 ...")
+ torrents = self.torrents.browse(domain=siteinfo.domain)
+ if not torrents:
+ logger.info(f"站点 {siteinfo.name} 没有获取到种子")
+ return True
+
+ brush_config = self.__get_brush_config(sitename=siteinfo.name)
+
+ if brush_config.site_hr_active:
+ logger.info(f"站点 {siteinfo.name} 已开启全站H&R选项,所有种子设置为H&R种子")
+
+ # 排除包含订阅的种子
+ if brush_config.except_subscribe:
+ torrents = self.__filter_torrents_contains_subscribe(torrents=torrents, subscribe_titles=subscribe_titles)
+
+ # 按发布日期降序排列
+ torrents.sort(key=lambda x: x.pubdate or '', reverse=True)
+
+ torrents_size = self.__calculate_seeding_torrents_size(torrent_tasks=torrent_tasks)
+
+ logger.info(f"正在准备种子刷流,数量 {len(torrents)}")
+
+ # 过滤种子
+ for torrent in torrents:
+ # 判断能否通过刷流前置条件
+ pre_condition_passed, reason = self.__evaluate_pre_conditions_for_brush(include_network_conditions=False)
+ self.__log_brush_conditions(passed=pre_condition_passed, reason=reason)
+ if not pre_condition_passed:
+ return False
+
+ logger.debug(f"种子详情:{torrent}")
+
+ # 判断能否通过保种体积刷流条件
+ size_condition_passed, reason = self.__evaluate_size_condition_for_brush(torrents_size=torrents_size,
+ add_torrent_size=torrent.size)
+ self.__log_brush_conditions(passed=size_condition_passed, reason=reason, torrent=torrent)
+ if not size_condition_passed:
+ continue
+
+ # 判断能否通过刷流条件
+ condition_passed, reason = self.__evaluate_conditions_for_brush(torrent=torrent,
+ torrent_tasks=torrent_tasks)
+ self.__log_brush_conditions(passed=condition_passed, reason=reason, torrent=torrent)
+ if not condition_passed:
+ continue
+
+ # 添加下载任务
+ hash_string = self.__download(torrent=torrent)
+ if not hash_string:
+ logger.warn(f"{torrent.title} 添加刷流任务失败!")
+ continue
+
+ # 触发刷流下载时间并保存任务信息
+ torrent_task = {
+ "site": siteinfo.id,
+ "site_name": siteinfo.name,
+ "title": torrent.title,
+ "size": torrent.size,
+ "pubdate": torrent.pubdate,
+ # "site_cookie": torrent.site_cookie,
+ # "site_ua": torrent.site_ua,
+ # "site_proxy": torrent.site_proxy,
+ # "site_order": torrent.site_order,
+ "description": torrent.description,
+ "imdbid": torrent.imdbid,
+ # "enclosure": torrent.enclosure,
+ "page_url": torrent.page_url,
+ # "seeders": torrent.seeders,
+ # "peers": torrent.peers,
+ # "grabs": torrent.grabs,
+ "date_elapsed": torrent.date_elapsed,
+ "freedate": torrent.freedate,
+ "uploadvolumefactor": torrent.uploadvolumefactor,
+ "downloadvolumefactor": torrent.downloadvolumefactor,
+ "hit_and_run": torrent.hit_and_run or brush_config.site_hr_active,
+ "volume_factor": torrent.volume_factor,
+ "freedate_diff": torrent.freedate_diff,
+ # "labels": torrent.labels,
+ # "pri_order": torrent.pri_order,
+ # "category": torrent.category,
+ "ratio": 0,
+ "downloaded": 0,
+ "uploaded": 0,
+ "seeding_time": 0,
+ "deleted": False,
+ "time": time.time()
+ }
+
+ self.eventmanager.send_event(etype=EventType.PluginAction, data={
+ "action": "brushflow_download_added",
+ "hash": hash_string,
+ "data": torrent_task
+ })
+ torrent_tasks[hash_string] = torrent_task
+
+ # 统计数据
+ torrents_size += torrent.size
+ statistic_info["count"] += 1
+ logger.info(f"站点 {siteinfo.name},新增刷流种子下载:{torrent.title}|{torrent.description}")
+ self.__send_add_message(torrent)
+
+ return True
+
+ def __evaluate_size_condition_for_brush(self, torrents_size: float,
+ add_torrent_size: float = 0.0) -> Tuple[bool, Optional[str]]:
+ """
+ 过滤体积不符合条件的种子
+ """
+ brush_config = self.__get_brush_config()
+
+ # 如果没有明确指定增加的种子大小,则检查配置中是否有种子大小下限,如果有,使用这个大小作为增加的种子大小
+ preset_condition = False
+ if not add_torrent_size and brush_config.size:
+ size_limits = [float(size) * 1024 ** 3 for size in brush_config.size.split("-")]
+ add_torrent_size = size_limits[0] # 使用配置的种子大小下限
+ preset_condition = True
+
+ total_size = self.__bytes_to_gb(torrents_size + add_torrent_size) # 预计总做种体积
+
+ def generate_message(config):
+ if add_torrent_size:
+ if preset_condition:
+ return (f"当前做种体积 {self.__bytes_to_gb(torrents_size):.1f} GB,"
+ f"刷流种子下限 {self.__bytes_to_gb(add_torrent_size):.1f} GB,"
+ f"预计做种体积 {total_size:.1f} GB,"
+ f"超过设定的保种体积 {config} GB,暂时停止新增任务")
+ else:
+ return (f"当前做种体积 {self.__bytes_to_gb(torrents_size):.1f} GB,"
+ f"刷流种子大小 {self.__bytes_to_gb(add_torrent_size):.1f} GB,"
+ f"预计做种体积 {total_size:.1f} GB,"
+ f"超过设定的保种体积 {config} GB")
+ else:
+ return (f"当前做种体积 {self.__bytes_to_gb(torrents_size):.1f} GB,"
+ f"超过设定的保种体积 {config} GB,暂时停止新增任务")
+
+ reasons = [
+ ("disksize",
+ lambda config: torrents_size + add_torrent_size > float(config) * 1024 ** 3, generate_message)
+ ]
+
+ for condition, check, message in reasons:
+ config_value = getattr(brush_config, condition, None)
+ if config_value and check(config_value):
+ reason = message(config_value)
+ return False, reason
+
+ return True, None
+
+ def __evaluate_pre_conditions_for_brush(self, include_network_conditions: bool = True) \
+ -> Tuple[bool, Optional[str]]:
+ """
+ 前置过滤不符合条件的种子
+ """
+ reasons = [
+ ("maxdlcount", lambda config: self.__get_downloading_count() >= int(config),
+ lambda config: f"当前同时下载任务数已达到最大值 {config},暂时停止新增任务")
+ ]
+
+ if include_network_conditions:
+ downloader_info = self.__get_downloader_info()
+ if downloader_info:
+ current_upload_speed = downloader_info.upload_speed or 0
+ current_download_speed = downloader_info.download_speed or 0
+ reasons.extend([
+ ("maxupspeed", lambda config: current_upload_speed >= float(config) * 1024,
+ lambda config: f"当前总上传带宽 {StringUtils.str_filesize(current_upload_speed)},"
+ f"已达到最大值 {config} KB/s,暂时停止新增任务"),
+ ("maxdlspeed", lambda config: current_download_speed >= float(config) * 1024,
+ lambda config: f"当前总下载带宽 {StringUtils.str_filesize(current_download_speed)},"
+ f"已达到最大值 {config} KB/s,暂时停止新增任务"),
+ ])
+
+ brush_config = self.__get_brush_config()
+ for condition, check, message in reasons:
+ config_value = getattr(brush_config, condition, None)
+ if config_value and check(config_value):
+ reason = message(config_value)
+ return False, reason
+
+ return True, None
+
+ def __evaluate_conditions_for_brush(self, torrent, torrent_tasks) -> Tuple[bool, Optional[str]]:
+ """
+ 过滤不符合条件的种子
+ """
+ brush_config = self.__get_brush_config(torrent.site_name)
+
+ # 排除重复种子
+ # 默认根据标题和站点名称进行排除
+ task_key = f"{torrent.site_name}{torrent.title}"
+ if any(task_key == f"{task.get('site_name')}{task.get('title')}" for task in torrent_tasks.values()):
+ return False, "重复种子"
+
+ # 部分站点标题会上新时携带后缀,这里进一步根据种子详情地址进行排除
+ if torrent.page_url:
+ task_page_url = f"{torrent.site_name}{torrent.page_url}"
+ if any(task_page_url == f"{task.get('site_name')}{task.get('page_url')}" for task in
+ torrent_tasks.values()):
+ return False, "重复种子"
+
+ # 不同站点如果遇到相同种子,判断前一个种子是否已经在做种,否则排除处理
+ if torrent.title:
+ if any(torrent.site_name != f"{task.get('site_name')}" and torrent.title == f"{task.get('title')}"
+ and not task.get("seed_time") for task in torrent_tasks.values()):
+ return False, "其他站点存在尚未下载完成的相同种子"
+
+ # 促销条件
+ if brush_config.freeleech and torrent.downloadvolumefactor != 0:
+ return False, "非免费种子"
+ if brush_config.freeleech == "2xfree" and torrent.uploadvolumefactor != 2:
+ return False, "非双倍上传种子"
+
+ # H&R
+ if brush_config.hr == "yes" and torrent.hit_and_run:
+ return False, "存在H&R"
+
+ # 包含规则
+ if brush_config.include and not (
+ re.search(brush_config.include, torrent.title, re.I) or re.search(brush_config.include,
+ torrent.description, re.I)):
+ return False, "不符合包含规则"
+
+ # 排除规则
+ if brush_config.exclude and (
+ re.search(brush_config.exclude, torrent.title, re.I) or re.search(brush_config.exclude,
+ torrent.description, re.I)):
+ return False, "符合排除规则"
+
+ # 种子大小(GB)
+ if brush_config.size:
+ sizes = [float(size) * 1024 ** 3 for size in brush_config.size.split("-")]
+ if len(sizes) == 1 and torrent.size < sizes[0]:
+ return False, f"种子大小 {self.__bytes_to_gb(torrent.size):.1f} GB,不符合条件"
+ elif len(sizes) > 1 and not sizes[0] <= torrent.size <= sizes[1]:
+ return False, f"种子大小 {self.__bytes_to_gb(torrent.size):.1f} GB,不在指定范围内"
+
+ # 做种人数
+ if brush_config.seeder:
+ seeders_range = [float(n) for n in brush_config.seeder.split("-")]
+ # 检查是否仅指定了一个数字,即做种人数需要小于等于该数字
+ if len(seeders_range) == 1:
+ # 当做种人数大于该数字时,不符合条件
+ if torrent.seeders > seeders_range[0]:
+ return False, f"做种人数 {torrent.seeders},超过单个指定值"
+ # 如果指定了一个范围
+ elif len(seeders_range) > 1:
+ # 检查做种人数是否在指定的范围内(包括边界)
+ if not (seeders_range[0] <= torrent.seeders <= seeders_range[1]):
+ return False, f"做种人数 {torrent.seeders},不在指定范围内"
+
+ # 发布时间
+ pubdate_minutes = self.__get_pubminutes(torrent.pubdate)
+ # 已支持独立站点配置,取消单独适配站点时区逻辑,可通过配置项「pubtime」自行适配
+ # pubdate_minutes = self.__adjust_site_pubminutes(pubdate_minutes, torrent)
+ if brush_config.pubtime:
+ pubtimes = [float(n) for n in brush_config.pubtime.split("-")]
+ if len(pubtimes) == 1:
+ # 单个值:选择发布时间小于等于该值的种子
+ if pubdate_minutes > pubtimes[0]:
+ return False, f"发布时间 {torrent.pubdate},{pubdate_minutes:.0f} 分钟前,不符合条件"
+ else:
+ # 范围值:选择发布时间在范围内的种子
+ if not (pubtimes[0] <= pubdate_minutes <= pubtimes[1]):
+ return False, f"发布时间 {torrent.pubdate},{pubdate_minutes:.0f} 分钟前,不在指定范围内"
+
+ return True, None
+
+ @staticmethod
+ def __log_brush_conditions(passed: bool, reason: str, torrent: Any = None):
+ """
+ 记录刷流日志
+ """
+ if not passed:
+ if not torrent:
+ logger.warn(f"没有通过前置刷流条件校验,原因:{reason}")
+ else:
+ logger.debug(f"种子没有通过刷流条件校验,原因:{reason} 种子:{torrent.title}|{torrent.description}")
+
+ # endregion
+
+ # region Check
+
+ def check(self):
+ """
+ 定时检查,删除下载任务
+ """
+ brush_config = self.__get_brush_config()
+
+ if not brush_config.downloader:
+ return
+
+ with lock:
+ logger.info("开始检查刷流下载任务 ...")
+ torrent_tasks: Dict[str, dict] = self.get_data("torrents") or {}
+ unmanaged_tasks: Dict[str, dict] = self.get_data("unmanaged") or {}
+
+ downloader = self.__get_downloader(brush_config.downloader)
+ if not downloader:
+ logger.warn("无法获取下载器实例,将在下个时间周期重试")
+ return
+
+ seeding_torrents, error = downloader.get_torrents()
+ if error:
+ logger.warn("连接下载器出错,将在下个时间周期重试")
+ return
+
+ seeding_torrents_dict = {self.__get_hash(torrent): torrent for torrent in seeding_torrents}
+
+ # 检查种子刷流标签变更情况
+ self.__update_seeding_tasks_based_on_tags(torrent_tasks=torrent_tasks, unmanaged_tasks=unmanaged_tasks,
+ seeding_torrents_dict=seeding_torrents_dict)
+
+ torrent_check_hashes = list(torrent_tasks.keys())
+ if not torrent_tasks or not torrent_check_hashes:
+ logger.info("没有需要检查的刷流下载任务")
+ return
+
+ logger.info(f"共有 {len(torrent_check_hashes)} 个任务正在刷流,开始检查任务状态")
+
+ # 获取到当前所有做种数据中需要被检查的种子数据
+ check_torrents = [seeding_torrents_dict[th] for th in torrent_check_hashes if th in seeding_torrents_dict]
+
+ # 先更新刷流任务的最新状态,上下传,分享率
+ self.__update_torrent_tasks_state(torrents=check_torrents, torrent_tasks=torrent_tasks)
+
+ # 更新刷流任务列表中在下载器中删除的种子为删除状态
+ self.__update_undeleted_torrents_missing_in_downloader(torrent_tasks, torrent_check_hashes, check_torrents)
+
+ # 根据配置的标签进行种子排除
+ if check_torrents:
+ logger.info(f"当前刷流任务共 {len(check_torrents)} 个有效种子,正在准备按设定的种子标签进行排除")
+ # 初始化一个空的列表来存储需要排除的标签
+ tags_to_exclude = set()
+ # 如果 delete_except_tags 非空且不是纯空白,则添加到排除列表中
+ if brush_config.delete_except_tags and brush_config.delete_except_tags.strip():
+ tags_to_exclude.update(tag.strip() for tag in brush_config.delete_except_tags.split(','))
+ # 将所有需要排除的标签组合成一个字符串,每个标签之间用逗号分隔
+ combined_tags = ",".join(tags_to_exclude)
+ if combined_tags: # 确保有标签需要排除
+ pre_filter_count = len(check_torrents) # 获取过滤前的任务数量
+ check_torrents = self.__filter_torrents_by_tag(torrents=check_torrents, exclude_tag=combined_tags)
+ post_filter_count = len(check_torrents) # 获取过滤后的任务数量
+ excluded_count = pre_filter_count - post_filter_count # 计算被排除的任务数量
+ logger.info(
+ f"有效种子数 {pre_filter_count},排除标签 '{combined_tags}' 后,"
+ f"剩余种子数 {post_filter_count},排除种子数 {excluded_count}")
+ else:
+ logger.info("没有配置有效的排除标签,所有种子均参与后续处理")
+
+ # 种子删除检查
+ if not check_torrents:
+ logger.info("没有需要检查的任务,跳过")
+ else:
+ need_delete_hashes = []
+
+ # 如果配置了动态删除以及删种阈值,则根据动态删种进行分组处理
+ if brush_config.proxy_delete and brush_config.delete_size_range:
+ logger.info("已开启动态删种,按系统默认动态删种条件开始检查任务")
+ proxy_delete_hashes = self.__delete_torrent_for_proxy(torrents=check_torrents,
+ torrent_tasks=torrent_tasks) or []
+ need_delete_hashes.extend(proxy_delete_hashes)
+ # 否则均认为是没有开启动态删种
+ else:
+ logger.info("没有开启动态删种,按用户设置删种条件开始检查任务")
+ not_proxy_delete_hashes = self.__delete_torrent_for_evaluate_conditions(torrents=check_torrents,
+ torrent_tasks=torrent_tasks) or []
+ need_delete_hashes.extend(not_proxy_delete_hashes)
+
+ if need_delete_hashes:
+ # 如果是QB,则重新汇报Tracker
+ if brush_config.downloader == "qbittorrent":
+ self.__qb_torrents_reannounce(torrent_hashes=need_delete_hashes)
+ # 删除种子
+ if downloader.delete_torrents(ids=need_delete_hashes, delete_file=True):
+ for torrent_hash in need_delete_hashes:
+ torrent_tasks[torrent_hash]["deleted"] = True
+ torrent_tasks[torrent_hash]["deleted_time"] = time.time()
+
+ # 归档数据
+ self.__auto_archive_tasks(torrent_tasks=torrent_tasks)
+
+ self.__update_and_save_statistic_info(torrent_tasks)
+
+ self.save_data("torrents", torrent_tasks)
+
+ logger.info("刷流下载任务检查完成")
+
+ def __update_torrent_tasks_state(self, torrents: List[Any], torrent_tasks: Dict[str, dict]):
+ """
+ 更新刷流任务的最新状态,上下传,分享率
+ """
+ for torrent in torrents:
+ torrent_hash = self.__get_hash(torrent)
+ torrent_task = torrent_tasks.get(torrent_hash, None)
+ # 如果找不到种子任务,说明不在管理的种子范围内,直接跳过
+ if not torrent_task:
+ continue
+
+ torrent_info = self.__get_torrent_info(torrent)
+
+ # 更新上传量、下载量
+ torrent_task.update({
+ "downloaded": torrent_info.get("downloaded"),
+ "uploaded": torrent_info.get("uploaded"),
+ "ratio": torrent_info.get("ratio"),
+ "seeding_time": torrent_info.get("seeding_time"),
+ })
+
+ def __update_seeding_tasks_based_on_tags(self, torrent_tasks: Dict[str, dict], unmanaged_tasks: Dict[str, dict],
+ seeding_torrents_dict: Dict[str, Any]):
+ brush_config = self.__get_brush_config()
+
+ if brush_config.downloader_monitor:
+ logger.info("已开启下载器监控,开始同步种子刷流标签记录")
+ else:
+ logger.info("没有开启下载器监控,取消同步种子刷流标签记录")
+ return
+
+ if not brush_config.downloader == "qbittorrent":
+ logger.info("同步种子刷流标签记录目前仅支持qbittorrent")
+ return
+
+ # 初始化汇总信息
+ added_tasks = []
+ reset_tasks = []
+ removed_tasks = []
+ # 基于 seeding_torrents_dict 的信息更新或添加到 torrent_tasks
+ for torrent_hash, torrent in seeding_torrents_dict.items():
+ tags = self.__get_label(torrent=torrent)
+ # 判断是否包含刷流标签
+ if brush_config.brush_tag in tags:
+ # 如果包含刷流标签又不在刷流任务中,则需要加入管理
+ if torrent_hash not in torrent_tasks:
+ # 检查该种子是否在 unmanaged_tasks 中
+ if torrent_hash in unmanaged_tasks:
+ # 如果在 unmanaged_tasks 中,移除并转移到 torrent_tasks
+ torrent_task = unmanaged_tasks.pop(torrent_hash)
+ torrent_tasks[torrent_hash] = torrent_task
+ added_tasks.append(torrent_task)
+ logger.info(f"站点 {torrent_task.get('site_name')},"
+ f"刷流任务种子再次加入:{torrent_task.get('title')}|{torrent_task.get('description')}")
+ else:
+ # 否则,创建一个新的任务
+ torrent_task = self.__convert_torrent_info_to_task(torrent)
+ torrent_tasks[torrent_hash] = torrent_task
+ added_tasks.append(torrent_task)
+ logger.info(f"站点 {torrent_task.get('site_name')},"
+ f"刷流任务种子加入:{torrent_task.get('title')}|{torrent_task.get('description')}")
+ # 包含刷流标签又在刷流任务中,这里额外处理一个特殊逻辑,就是种子在刷流任务中可能被标记删除但实际上又还在下载器中,这里进行重置
+ else:
+ torrent_task = torrent_tasks[torrent_hash]
+ if torrent_task.get("deleted"):
+ torrent_task["deleted"] = False
+ reset_tasks.append(torrent_task)
+ logger.info(
+ f"站点 {torrent_task.get('site_name')},在下载器中找到已标记删除的刷流任务对应的种子信息,"
+ f"更新刷流任务状态为正常:{torrent_task.get('title')}|{torrent_task.get('description')}")
+ else:
+ # 不包含刷流标签但又在刷流任务中,则移除管理
+ if torrent_hash in torrent_tasks:
+ # 如果种子不符合刷流条件但在 torrent_tasks 中,移除并加入 unmanaged_tasks
+ torrent_task = torrent_tasks.pop(torrent_hash)
+ unmanaged_tasks[torrent_hash] = torrent_task
+ removed_tasks.append(torrent_task)
+ logger.info(f"站点 {torrent_task.get('site_name')},"
+ f"刷流任务种子移除:{torrent_task.get('title')}|{torrent_task.get('description')}")
+
+ self.save_data("torrents", torrent_tasks)
+ self.save_data("unmanaged", unmanaged_tasks)
+
+ # 发送汇总消息
+ if added_tasks:
+ self.__log_and_send_torrent_task_update_message(title="【刷流任务种子加入】", status="纳入刷流管理",
+ reason="刷流标签添加", torrent_tasks=added_tasks)
+ if removed_tasks:
+ self.__log_and_send_torrent_task_update_message(title="【刷流任务种子移除】", status="移除刷流管理",
+ reason="刷流标签移除", torrent_tasks=removed_tasks)
+ if reset_tasks:
+ self.__log_and_send_torrent_task_update_message(title="【刷流任务状态更新】", status="更新刷流状态为正常",
+ reason="在下载器中找到已标记删除的刷流任务对应的种子信息",
+ torrent_tasks=reset_tasks)
+
+ def __group_torrents_by_proxy_delete(self, torrents: List[Any], torrent_tasks: Dict[str, dict]):
+ """
+ 根据是否启用动态删种进行分组
+ """
+ proxy_delete_torrents = []
+ not_proxy_delete_torrents = []
+
+ for torrent in torrents:
+ torrent_hash = self.__get_hash(torrent)
+ torrent_task = torrent_tasks.get(torrent_hash, None)
+
+ # 如果找不到种子任务,说明不在管理的种子范围内,直接跳过
+ if not torrent_task:
+ continue
+
+ site_name = torrent_task.get("site_name", "")
+
+ brush_config = self.__get_brush_config(site_name)
+ if brush_config.proxy_delete:
+ proxy_delete_torrents.append(torrent)
+ else:
+ not_proxy_delete_torrents.append(torrent)
+
+ return proxy_delete_torrents, not_proxy_delete_torrents
+
+ def __evaluate_conditions_for_delete(self, site_name: str, torrent_info: dict, torrent_task: dict) \
+ -> Tuple[bool, str]:
+ """
+ 评估删除条件并返回是否应删除种子及其原因
+ """
+ brush_config = self.__get_brush_config(sitename=site_name)
+
+ reason = "未能满足设置的删除条件"
+
+ # 当配置了H&R做种时间/分享率时,则H&R种子只有达到预期行为时,才会进行删除,如果没有配置H&R做种时间/分享率,则普通种子的删除规则也适用于H&R种子
+ # 判断是否为H&R种子并且是否配置了特定的H&R条件
+ hit_and_run = torrent_task.get("hit_and_run", False)
+ hr_specific_conditions_configured = hit_and_run and (brush_config.hr_seed_time or brush_config.seed_ratio)
+ if hr_specific_conditions_configured:
+ if (brush_config.hr_seed_time and torrent_info.get("seeding_time")
+ >= float(brush_config.hr_seed_time) * 3600):
+ return True, (f"H&R种子,做种时间 {torrent_info.get('seeding_time') / 3600:.1f} 小时,"
+ f"大于 {brush_config.hr_seed_time} 小时")
+ if brush_config.seed_ratio and torrent_info.get("ratio") >= float(brush_config.seed_ratio):
+ return True, f"H&R种子,分享率 {torrent_info.get('ratio'):.2f},大于 {brush_config.seed_ratio}"
+ return False, "H&R种子,未能满足设置的H&R删除条件"
+
+ # 处理其他场景,1. 不是H&R种子;2. 是H&R种子但没有特定条件配置
+ reason = reason if not hit_and_run else "H&R种子(未设置H&R条件),未能满足设置的删除条件"
+ if brush_config.seed_time and torrent_info.get("seeding_time") >= float(brush_config.seed_time) * 3600:
+ reason = f"做种时间 {torrent_info.get('seeding_time') / 3600:.1f} 小时,大于 {brush_config.seed_time} 小时"
+ elif brush_config.seed_ratio and torrent_info.get("ratio") >= float(brush_config.seed_ratio):
+ reason = f"分享率 {torrent_info.get('ratio'):.2f},大于 {brush_config.seed_ratio}"
+ elif brush_config.seed_size and torrent_info.get("uploaded") >= float(brush_config.seed_size) * 1024 ** 3:
+ reason = f"上传量 {torrent_info.get('uploaded') / 1024 ** 3:.1f} GB,大于 {brush_config.seed_size} GB"
+ elif brush_config.download_time and torrent_info.get("downloaded") < torrent_info.get(
+ "total_size") and torrent_info.get("dltime") >= float(brush_config.download_time) * 3600:
+ reason = f"下载耗时 {torrent_info.get('dltime') / 3600:.1f} 小时,大于 {brush_config.download_time} 小时"
+ elif brush_config.seed_avgspeed and torrent_info.get("avg_upspeed") <= float(
+ brush_config.seed_avgspeed) * 1024 and torrent_info.get("seeding_time") >= 30 * 60:
+ reason = f"平均上传速度 {torrent_info.get('avg_upspeed') / 1024:.1f} KB/s,低于 {brush_config.seed_avgspeed} KB/s"
+ elif brush_config.seed_inactivetime and torrent_info.get("iatime") >= float(
+ brush_config.seed_inactivetime) * 60:
+ reason = f"未活动时间 {torrent_info.get('iatime') / 60:.0f} 分钟,大于 {brush_config.seed_inactivetime} 分钟"
+ else:
+ return False, reason
+
+ return True, reason if not hit_and_run else "H&R种子(未设置H&R条件)," + reason
+
+ def __evaluate_proxy_pre_conditions_for_delete(self, site_name: str, torrent_info: dict) -> Tuple[bool, str]:
+ """
+ 评估动态删除前置条件并返回是否应删除种子及其原因
+ """
+ brush_config = self.__get_brush_config(sitename=site_name)
+
+ reason = "未能满足动态删除设置的前置删除条件"
+
+ if brush_config.download_time and torrent_info.get("downloaded") < torrent_info.get(
+ "total_size") and torrent_info.get("dltime") >= float(brush_config.download_time) * 3600:
+ reason = f"下载耗时 {torrent_info.get('dltime') / 3600:.1f} 小时,大于 {brush_config.download_time} 小时"
+ else:
+ return False, reason
+
+ return True, reason
+
+ def __delete_torrent_for_evaluate_conditions(self, torrents: List[Any], torrent_tasks: Dict[str, dict],
+ proxy_delete: bool = False) -> List:
+ """
+ 根据条件删除种子并获取已删除列表
+ """
+ brush_config = self.__get_brush_config()
+ delete_hashes = []
+
+ for torrent in torrents:
+ torrent_hash = self.__get_hash(torrent)
+ torrent_task = torrent_tasks.get(torrent_hash, None)
+ # 如果找不到种子任务,说明不在管理的种子范围内,直接跳过
+ if not torrent_task:
+ continue
+ site_name = torrent_task.get("site_name", "")
+ torrent_title = torrent_task.get("title", "")
+ torrent_desc = torrent_task.get("description", "")
+
+ torrent_info = self.__get_torrent_info(torrent)
+
+ # 删除种子的具体实现可能会根据实际情况略有不同
+ should_delete, reason = self.__evaluate_conditions_for_delete(site_name=site_name,
+ torrent_info=torrent_info,
+ torrent_task=torrent_task)
+ if should_delete:
+ delete_hashes.append(torrent_hash)
+ reason = "触发动态删除阈值," + reason if proxy_delete else reason
+ self.__send_delete_message(site_name=site_name, torrent_title=torrent_title, torrent_desc=torrent_desc,
+ reason=reason)
+ logger.info(f"站点:{site_name},{reason},删除种子:{torrent_title}|{torrent_desc}")
+ else:
+ logger.debug(f"站点:{site_name},{reason},不删除种子:{torrent_title}|{torrent_desc}")
+
+ return delete_hashes
+
+ def __delete_torrent_for_evaluate_proxy_pre_conditions(self, torrents: List[Any],
+ torrent_tasks: Dict[str, dict]) -> List:
+ """
+ 根据动态删除前置条件排除H&R种子后删除种子并获取已删除列表
+ """
+ brush_config = self.__get_brush_config()
+ delete_hashes = []
+
+ for torrent in torrents:
+ torrent_hash = self.__get_hash(torrent)
+ torrent_task = torrent_tasks.get(torrent_hash, None)
+ # 如果找不到种子任务,说明不在管理的种子范围内,直接跳过
+ if not torrent_task:
+ continue
+
+ # 如果是H&R种子,前置条件中不进行处理
+ if torrent_task.get('hit_and_run', False):
+ continue
+
+ site_name = torrent_task.get("site_name", "")
+ torrent_title = torrent_task.get("title", "")
+ torrent_desc = torrent_task.get("description", "")
+
+ torrent_info = self.__get_torrent_info(torrent)
+
+ # 删除种子的具体实现可能会根据实际情况略有不同
+ should_delete, reason = self.__evaluate_proxy_pre_conditions_for_delete(site_name=site_name,
+ torrent_info=torrent_info)
+ if should_delete:
+ delete_hashes.append(torrent_hash)
+ self.__send_delete_message(site_name=site_name, torrent_title=torrent_title, torrent_desc=torrent_desc,
+ reason=reason)
+ logger.info(f"站点:{site_name},{reason},删除种子:{torrent_title}|{torrent_desc}")
+ else:
+ logger.debug(f"站点:{site_name},{reason},不删除种子:{torrent_title}|{torrent_desc}")
+
+ return delete_hashes
+
+ def __delete_torrent_for_proxy(self, torrents: List[Any], torrent_tasks: Dict[str, dict]) -> List:
+ """
+ 动态删除种子,删除规则如下;
+ - 不管做种体积是否超过设定的动态删除阈值,默认优先执行排除H&R种子后满足「下载超时时间」的种子
+ - 上述规则执行完成后,当做种体积依旧超过设定的动态删除阈值时,继续执行下述种子删除规则
+ - 优先删除满足用户设置删除规则的全部种子,即便在删除过程中已经低于了阈值下限,也会继续删除
+ - 若删除后还没有达到阈值,则在已完成种子中排除H&R种子后按做种时间倒序进行删除
+ - 动态删除阈值:100,当做种体积 > 100G 时,则开始删除种子,直至降低至 100G
+ - 动态删除阈值:50-100,当做种体积 > 100G 时,则开始删除种子,直至降至为 50G
+ """
+ brush_config = self.__get_brush_config()
+
+ # 如果没有启用动态删除或没有设置删除阈值,则不执行删除操作
+ if not (brush_config.proxy_delete and brush_config.delete_size_range):
+ return []
+
+ # 获取种子信息Map
+ torrent_info_map = {self.__get_hash(torrent): self.__get_torrent_info(torrent=torrent) for torrent in torrents}
+
+ # 计算当前总做种体积
+ total_torrent_size = self.__calculate_seeding_torrents_size(torrent_tasks=torrent_tasks)
+
+ logger.info(
+ f"当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB,正在准备计算满足动态前置删除条件的种子")
+
+ # 执行排除H&R种子后满足前置删除条件的种子
+ pre_delete_hashes = self.__delete_torrent_for_evaluate_proxy_pre_conditions(torrents=torrents,
+ torrent_tasks=torrent_tasks) or []
+
+ # 如果存在前置删除种子,这里进行额外判断,总做种体积排除前置删除种子的体积
+ if pre_delete_hashes:
+ pre_delete_total_size = sum(torrent_info_map[self.__get_hash(torrent)].get("total_size", 0)
+ for torrent in torrents if self.__get_hash(torrent) in pre_delete_hashes)
+ total_torrent_size = total_torrent_size - pre_delete_total_size
+ torrents = [torrent for torrent in torrents if self.__get_hash(torrent) not in pre_delete_hashes]
+ logger.info(
+ f"满足动态删除前置条件的种子共 {len(pre_delete_hashes)} 个,体积 {self.__bytes_to_gb(pre_delete_total_size):.1f} GB,"
+ f"删除种子后,当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB")
+ else:
+ logger.info(f"没有找到任何满足动态删除前置条件的种子")
+
+ # 解析删除阈值范围
+ sizes = [float(size) * 1024 ** 3 for size in brush_config.delete_size_range.split("-")]
+ min_size = sizes[0] # 至少需要达到的做种体积
+ max_size = sizes[1] if len(sizes) > 1 else sizes[0] # 触发删除操作的做种体积上限
+
+ # 判断是否为区间删除
+ proxy_size_range = len(sizes) > 1
+
+ # 当总体积未超过最大阈值时,不需要执行删除操作
+ if total_torrent_size < max_size:
+ logger.info(
+ f"当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB,上限 {self.__bytes_to_gb(max_size):.1f} GB,"
+ f"下限 {self.__bytes_to_gb(min_size):.1f} GB,未进一步触发动态删除")
+ return pre_delete_hashes or []
+ else:
+ logger.info(
+ f"当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB,上限 {self.__bytes_to_gb(max_size):.1f} GB,"
+ f"下限 {self.__bytes_to_gb(min_size):.1f} GB,进一步触发动态删除")
+
+ need_delete_hashes = []
+ need_delete_hashes.extend(pre_delete_hashes)
+
+ # 即使开了动态删除,但是也有可能部分站点单独设置了关闭,这里根据种子托管进行分组,先处理不需要托管的种子,按设置的规则进行删除
+ proxy_delete_torrents, not_proxy_delete_torrents = self.__group_torrents_by_proxy_delete(torrents=torrents,
+ torrent_tasks=torrent_tasks)
+ logger.info(f"托管种子数 {len(proxy_delete_torrents)},未托管种子数 {len(not_proxy_delete_torrents)}")
+ if not_proxy_delete_torrents:
+ not_proxy_delete_hashes = self.__delete_torrent_for_evaluate_conditions(torrents=not_proxy_delete_torrents,
+ torrent_tasks=torrent_tasks) or []
+ need_delete_hashes.extend(not_proxy_delete_hashes)
+ total_torrent_size -= sum(
+ torrent_info_map[self.__get_hash(torrent)].get("total_size", 0) for torrent in not_proxy_delete_torrents
+ if self.__get_hash(torrent) in not_proxy_delete_hashes)
+
+ # 如果删除非托管种子后仍未达到最小体积要求,则处理托管种子
+ if total_torrent_size > min_size and proxy_delete_torrents:
+ proxy_delete_hashes = self.__delete_torrent_for_evaluate_conditions(torrents=proxy_delete_torrents,
+ torrent_tasks=torrent_tasks,
+ proxy_delete=True) or []
+ need_delete_hashes.extend(proxy_delete_hashes)
+ total_torrent_size -= sum(
+ torrent_info_map[self.__get_hash(torrent)].get("total_size", 0) for torrent in proxy_delete_torrents if
+ self.__get_hash(torrent) in proxy_delete_hashes)
+
+ # 在完成初始删除步骤后,如果总体积仍然超过最小阈值,则进一步找到已完成种子并排除HR种子后按做种时间正序进行删除
+ if total_torrent_size > min_size:
+ # 重新计算当前的种子列表,排除已删除的种子
+ remaining_hashes = list(
+ {self.__get_hash(torrent) for torrent in proxy_delete_torrents} - set(need_delete_hashes))
+ # 这里根据排除后的种子列表,再次从下载器中找到已完成的任务
+ downloader = self.__get_downloader(brush_config.downloader)
+ completed_torrents = downloader.get_completed_torrents(ids=remaining_hashes)
+ remaining_hashes = {self.__get_hash(torrent) for torrent in completed_torrents}
+ remaining_torrents = [(_hash, torrent_info_map[_hash]) for _hash in remaining_hashes]
+
+ # 准备一个列表,用于存放满足条件的种子,即非HR种子且有明确做种时间
+ filtered_torrents = [(_hash, info['seeding_time']) for _hash, info in remaining_torrents if
+ not torrent_tasks[_hash].get("hit_and_run", False)]
+ sorted_torrents = sorted(filtered_torrents, key=lambda x: x[1], reverse=True)
+
+ # 进行额外的删除操作,直到满足最小阈值或没有更多种子可删除
+ for torrent_hash, _ in sorted_torrents:
+ if total_torrent_size <= min_size:
+ break
+ torrent_task = torrent_tasks.get(torrent_hash, None)
+ torrent_info = torrent_info_map.get(torrent_hash, None)
+ if not torrent_task or not torrent_info:
+ continue
+
+ need_delete_hashes.append(torrent_hash)
+ total_torrent_size -= torrent_info.get("total_size", 0)
+
+ site_name = torrent_task.get("site_name", "")
+ torrent_title = torrent_task.get("title", "")
+ torrent_desc = torrent_task.get("description", "")
+ seeding_time = torrent_task.get("seeding_time", 0)
+ if seeding_time:
+ reason = (f"触发动态删除阈值,系统自动删除,做种时间 {seeding_time / 3600:.1f} 小时,"
+ f"当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB")
+ # 如果是区间删除,一次性删除的数据过多,取消消息推送
+ if not proxy_size_range:
+ self.__send_delete_message(site_name=site_name, torrent_title=torrent_title,
+ torrent_desc=torrent_desc,
+ reason=reason)
+ logger.info(f"站点:{site_name},{reason},删除种子:{torrent_title}|{torrent_desc}")
+
+ delete_sites = {torrent_tasks[hash_key].get('site_name', '') for hash_key in need_delete_hashes if
+ hash_key in torrent_tasks}
+ msg = (f"站点:{','.join(delete_sites)}\n内容:已完成 {len(need_delete_hashes)} 个种子删除,"
+ f"当前做种体积 {self.__bytes_to_gb(total_torrent_size):.1f} GB\n原因:触发动态删除阈值,系统自动删除")
+ logger.info(msg)
+
+ # 如果是区间删除,这里则进行统一推送
+ if proxy_size_range:
+ self.__send_message(title="【刷流任务种子删除】", text=msg)
+
+ # 返回所有需要删除的种子的哈希列表
+ return need_delete_hashes
+
+ def __update_undeleted_torrents_missing_in_downloader(self, torrent_tasks, torrent_check_hashes, torrents):
+ """
+ 处理已经被删除,但是任务记录中还没有被标记删除的种子
+ """
+ brush_config = self.__get_brush_config()
+
+ if brush_config.downloader_monitor:
+ logger.info("已开启下载器监控,开始同步刷流任务删除记录")
+ else:
+ logger.info("没有开启下载器监控,取消同步刷流任务删除记录")
+ return
+
+ # 先通过获取的全量种子,判断已经被删除,但是任务记录中还没有被标记删除的种子
+ torrent_all_hashes = self.__get_all_hashes(torrents)
+ missing_hashes = [hash_value for hash_value in torrent_check_hashes if hash_value not in torrent_all_hashes]
+ undeleted_hashes = [hash_value for hash_value in missing_hashes if not torrent_tasks[hash_value].get("deleted")]
+
+ if not undeleted_hashes:
+ return
+
+ # 初始化汇总信息
+ delete_tasks = []
+ for hash_value in undeleted_hashes:
+ # 获取对应的任务信息
+ torrent_task = torrent_tasks[hash_value]
+ # 标记为已删除
+ torrent_task["deleted"] = True
+ torrent_task["deleted_time"] = time.time()
+ # 处理日志相关内容
+ delete_tasks.append(torrent_task)
+ site_name = torrent_task.get("site_name", "")
+ torrent_title = torrent_task.get("title", "")
+ torrent_desc = torrent_task.get("description", "")
+ logger.info(
+ f"站点:{site_name},无法在下载器中找到对应种子信息,更新刷流任务状态为已删除,种子:{torrent_title}|{torrent_desc}")
+
+ self.__log_and_send_torrent_task_update_message(title="【刷流任务状态更新】", status="更新刷流状态为已删除",
+ reason="无法在下载器中找到对应的种子信息",
+ torrent_tasks=delete_tasks)
+
+ def __convert_torrent_info_to_task(self, torrent: Any) -> dict:
+ """
+ 根据torrent_info转换成torrent_task
+ """
+ torrent_info = self.__get_torrent_info(torrent=torrent)
+
+ site_id, site_name = self.__get_site_by_torrent(torrent=torrent)
+
+ torrent_task = {
+ "site": site_id,
+ "site_name": site_name,
+ "title": torrent_info.get("title", ""),
+ "size": torrent_info.get("total_size", 0), # 假设total_size对应于size
+ "pubdate": None,
+ "description": None,
+ "imdbid": None,
+ "page_url": None,
+ "date_elapsed": None,
+ "freedate": None,
+ "uploadvolumefactor": None,
+ "downloadvolumefactor": None,
+ "hit_and_run": None,
+ "volume_factor": None,
+ "freedate_diff": None, # 假设无法从torrent_info直接获取
+ "ratio": torrent_info.get("ratio", 0),
+ "downloaded": torrent_info.get("downloaded", 0),
+ "uploaded": torrent_info.get("uploaded", 0),
+ "deleted": False,
+ "time": torrent_info.get("add_on", time.time())
+ }
+ return torrent_task
+
+ # endregion
+
+ def __update_and_save_statistic_info(self, torrent_tasks):
+ """
+ 更新并保存统计信息
+ """
+ total_count, total_uploaded, total_downloaded, total_deleted = 0, 0, 0, 0
+ active_uploaded, active_downloaded, active_count, total_unarchived = 0, 0, 0, 0
+
+ statistic_info = self.__get_statistic_info()
+ archived_tasks = self.get_data("archived") or {}
+ combined_tasks = {**torrent_tasks, **archived_tasks}
+
+ for task in combined_tasks.values():
+ if task.get("deleted", False):
+ total_deleted += 1
+ total_downloaded += task.get("downloaded", 0)
+ total_uploaded += task.get("uploaded", 0)
+
+ # 计算torrent_tasks中未标记为删除的活跃任务的统计信息,及待归档的任务数
+ for task in torrent_tasks.values():
+ if not task.get("deleted", False):
+ active_uploaded += task.get("uploaded", 0)
+ active_downloaded += task.get("downloaded", 0)
+ active_count += 1
+ else:
+ total_unarchived += 1
+
+ # 更新统计信息
+ total_count = len(combined_tasks)
+ statistic_info.update({
+ "uploaded": total_uploaded,
+ "downloaded": total_downloaded,
+ "deleted": total_deleted,
+ "unarchived": total_unarchived,
+ "count": total_count,
+ "active": active_count,
+ "active_uploaded": active_uploaded,
+ "active_downloaded": active_downloaded
+ })
+
+ logger.info(f"刷流任务统计数据,总任务数:{total_count},活跃任务数:{active_count},已删除:{total_deleted},"
+ f"待归档:{total_unarchived},"
+ f"活跃上传量:{StringUtils.str_filesize(active_uploaded)},"
+ f"活跃下载量:{StringUtils.str_filesize(active_downloaded)},"
+ f"总上传量:{StringUtils.str_filesize(total_uploaded)},"
+ f"总下载量:{StringUtils.str_filesize(total_downloaded)}")
+
+ self.save_data("statistic", statistic_info)
+ self.save_data("torrents", torrent_tasks)
+
+ def __get_brush_config(self, sitename: str = None) -> BrushConfig:
+ """
+ 获取BrushConfig
+ """
+ return self._brush_config if not sitename else self._brush_config.get_site_config(sitename=sitename)
+
+ def __validate_and_fix_config(self, config: dict = None) -> bool:
+ """
+ 检查并修正配置值
+ """
+ if config is None:
+ logger.error("配置为None,无法验证和修正")
+ return False
+
+ # 设置一个标志,用于跟踪是否发现校验错误
+ found_error = False
+
+ config_number_attr_to_desc = {
+ "disksize": "保种体积",
+ "maxupspeed": "总上传带宽",
+ "maxdlspeed": "总下载带宽",
+ "maxdlcount": "同时下载任务数",
+ "seed_time": "做种时间",
+ "hr_seed_time": "H&R做种时间",
+ "seed_ratio": "分享率",
+ "seed_size": "上传量",
+ "download_time": "下载超时时间",
+ "seed_avgspeed": "平均上传速度",
+ "seed_inactivetime": "未活动时间",
+ "up_speed": "单任务上传限速",
+ "dl_speed": "单任务下载限速",
+ "auto_archive_days": "自动清理记录天数"
+ }
+
+ config_range_number_attr_to_desc = {
+ "pubtime": "发布时间",
+ "size": "种子大小",
+ "seeder": "做种人数",
+ "delete_size_range": "动态删种阈值"
+ }
+
+ for attr, desc in config_number_attr_to_desc.items():
+ value = config.get(attr)
+ if value and not self.__is_number(value):
+ self.__log_and_notify_error(f"站点刷流任务出错,{desc}设置错误:{value}")
+ config[attr] = None
+ found_error = True # 更新错误标志
+
+ for attr, desc in config_range_number_attr_to_desc.items():
+ value = config.get(attr)
+ # 检查 value 是否存在且是否符合数字或数字-数字的模式
+ if value and not self.__is_number_or_range(str(value)):
+ self.__log_and_notify_error(f"站点刷流任务出错,{desc}设置错误:{value}")
+ config[attr] = None
+ found_error = True # 更新错误标志
+
+ active_time_range = config.get("active_time_range")
+ if active_time_range and not self.__is_valid_time_range(time_range=active_time_range):
+ self.__log_and_notify_error(f"站点刷流任务出错,开启时间段设置错误:{active_time_range}")
+ config["active_time_range"] = None
+ found_error = True # 更新错误标志
+
+ # 如果发现任何错误,返回False;否则返回True
+ return not found_error
+
+ def __update_config(self, brush_config: BrushConfig = None):
+ """
+ 根据传入的BrushConfig实例更新配置
+ """
+ if brush_config is None:
+ brush_config = self._brush_config
+
+ if brush_config is None:
+ return
+
+ # 创建一个将配置属性名称映射到BrushConfig属性值的字典
+ config_mapping = {
+ "onlyonce": brush_config.onlyonce,
+ "enabled": brush_config.enabled,
+ "notify": brush_config.notify,
+ "brushsites": brush_config.brushsites,
+ "downloader": brush_config.downloader,
+ "disksize": brush_config.disksize,
+ "freeleech": brush_config.freeleech,
+ "hr": brush_config.hr,
+ "maxupspeed": brush_config.maxupspeed,
+ "maxdlspeed": brush_config.maxdlspeed,
+ "maxdlcount": brush_config.maxdlcount,
+ "include": brush_config.include,
+ "exclude": brush_config.exclude,
+ "size": brush_config.size,
+ "seeder": brush_config.seeder,
+ "pubtime": brush_config.pubtime,
+ "seed_time": brush_config.seed_time,
+ "hr_seed_time": brush_config.hr_seed_time,
+ "seed_ratio": brush_config.seed_ratio,
+ "seed_size": brush_config.seed_size,
+ "download_time": brush_config.download_time,
+ "seed_avgspeed": brush_config.seed_avgspeed,
+ "seed_inactivetime": brush_config.seed_inactivetime,
+ "delete_size_range": brush_config.delete_size_range,
+ "up_speed": brush_config.up_speed,
+ "dl_speed": brush_config.dl_speed,
+ "auto_archive_days": brush_config.auto_archive_days,
+ "save_path": brush_config.save_path,
+ "clear_task": brush_config.clear_task,
+ "archive_task": brush_config.archive_task,
+ "delete_except_tags": brush_config.delete_except_tags,
+ "except_subscribe": brush_config.except_subscribe,
+ "brush_sequential": brush_config.brush_sequential,
+ "proxy_download": brush_config.proxy_download,
+ "proxy_delete": brush_config.proxy_delete,
+ "active_time_range": brush_config.active_time_range,
+ "downloader_monitor": brush_config.downloader_monitor,
+ "qb_category": brush_config.qb_category,
+ "auto_qb_category": brush_config.auto_qb_category,
+ "qb_first_last_piece": brush_config.qb_first_last_piece,
+ "enable_site_config": brush_config.enable_site_config,
+ "site_config": brush_config.site_config,
+ "_tabs": self._tabs
+ }
+
+ # 使用update_config方法或其等效方法更新配置
+ self.update_config(config_mapping)
+
+ def __setup_downloader(self):
+ """
+ 根据下载器类型初始化下载器实例
+ """
+ brush_config = self.__get_brush_config()
+ self.qb = Qbittorrent()
+ self.tr = Transmission()
+
+ if brush_config.downloader == "qbittorrent":
+ if self.qb.is_inactive():
+ self.__log_and_notify_error("站点刷流任务出错:Qbittorrent未连接")
+ return False
+
+ elif brush_config.downloader == "transmission":
+
+ if self.tr.is_inactive():
+ self.__log_and_notify_error("站点刷流任务出错:Transmission未连接")
+ return False
+
+ return True
+
+ def __get_downloader(self, dtype: str) -> Optional[Union[Transmission, Qbittorrent]]:
+ """
+ 根据类型返回下载器实例
+ """
+ if dtype == "qbittorrent":
+ return self.qb
+ elif dtype == "transmission":
+ return self.tr
+ else:
+ return None
+
+ @staticmethod
+ def __get_redict_url(url: str, proxies: str = None, ua: str = None, cookie: str = None) -> Optional[str]:
+ """
+ 获取下载链接, url格式:[base64]url
+ """
+ # 获取[]中的内容
+ m = re.search(r"\[(.*)](.*)", url)
+ if m:
+ # 参数
+ base64_str = m.group(1)
+ # URL
+ url = m.group(2)
+ if not base64_str:
+ return url
+ # 解码参数
+ req_str = base64.b64decode(base64_str.encode('utf-8')).decode('utf-8')
+ req_params: Dict[str, dict] = json.loads(req_str)
+ # 是否使用cookie
+ if not req_params.get('cookie'):
+ cookie = None
+ # 请求头
+ if req_params.get('header'):
+ headers = req_params.get('header')
+ else:
+ headers = None
+ if req_params.get('method') == 'get':
+ # GET请求
+ res = RequestUtils(
+ ua=ua,
+ proxies=proxies,
+ cookies=cookie,
+ headers=headers
+ ).get_res(url, params=req_params.get('params'))
+ else:
+ # POST请求
+ res = RequestUtils(
+ ua=ua,
+ proxies=proxies,
+ cookies=cookie,
+ headers=headers
+ ).post_res(url, params=req_params.get('params'))
+ if not res:
+ return None
+ if not req_params.get('result'):
+ return res.text
+ else:
+ data = res.json()
+ for key in str(req_params.get('result')).split("."):
+ data = data.get(key)
+ if not data:
+ return None
+ logger.debug(f"获取到下载地址:{data}")
+ return data
+ return None
+
+ def __download(self, torrent: TorrentInfo) -> Optional[str]:
+ """
+ 添加下载任务
+ """
+ if not torrent.enclosure:
+ logger.error(f"获取下载链接失败:{torrent.title}")
+ return None
+
+ brush_config = self.__get_brush_config(torrent.site_name)
+
+ # 上传限速
+ up_speed = int(brush_config.up_speed) if brush_config.up_speed else None
+ # 下载限速
+ down_speed = int(brush_config.dl_speed) if brush_config.dl_speed else None
+ # 保存地址
+ download_dir = brush_config.save_path or None
+ # 获取下载链接
+ torrent_content = torrent.enclosure
+ # proxies
+ proxies = settings.PROXY if torrent.site_proxy else None
+ # cookie
+ cookies = torrent.site_cookie
+ if torrent_content.startswith("["):
+ torrent_content = self.__get_redict_url(url=torrent_content,
+ proxies=proxies,
+ ua=torrent.site_ua,
+ cookie=cookies)
+ # 目前馒头请求实际种子时,不能传入Cookie
+ cookies = None
+ if not torrent_content:
+ logger.error(f"获取下载链接失败:{torrent.title}")
+ return None
+
+ if brush_config.downloader == "qbittorrent":
+ if not self.qb:
+ return None
+ # 限速值转为bytes
+ up_speed = up_speed * 1024 if up_speed else None
+ down_speed = down_speed * 1024 if down_speed else None
+ # 生成随机Tag
+ tag = StringUtils.generate_random_str(10)
+ # 如果开启代理下载以及种子地址不是磁力地址,则请求种子到内存再传入下载器
+ if brush_config.proxy_download and not torrent_content.startswith("magnet"):
+ response = RequestUtils(cookies=cookies,
+ proxies=proxies,
+ ua=torrent.site_ua).get_res(url=torrent_content)
+ if response and response.ok:
+ torrent_content = response.content
+ else:
+ logger.error('尝试通过MP下载种子失败,继续尝试传递种子地址到下载器进行下载')
+ if torrent_content:
+ state = self.__qb_add_torrent(content=torrent_content,
+ download_dir=download_dir,
+ cookie=cookies,
+ tag=["已整理", brush_config.brush_tag, tag],
+ category=brush_config.qb_category,
+ is_auto=brush_config.auto_qb_category,
+ is_first_last_piece_priority=brush_config.qb_first_last_piece,
+ upload_limit=up_speed,
+ download_limit=down_speed)
+ 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"{brush_config.downloader} 获取种子Hash失败,详细信息请查看 README")
+ return None
+ return torrent_hash
+ return None
+
+ elif brush_config.downloader == "transmission":
+ if not self.tr:
+ return None
+ # 如果开启代理下载以及种子地址不是磁力地址,则请求种子到内存再传入下载器
+ if brush_config.proxy_download and not torrent_content.startswith("magnet"):
+ response = RequestUtils(cookies=cookies,
+ proxies=proxies,
+ ua=torrent.site_ua).get_res(url=torrent_content)
+ if response and response.ok:
+ torrent_content = response.content
+ else:
+ logger.error('尝试通过MP下载种子失败,继续尝试传递种子地址到下载器进行下载')
+ if torrent_content:
+ torrent = self.tr.add_torrent(content=torrent_content,
+ download_dir=download_dir,
+ cookie=cookies,
+ labels=["已整理", brush_config.brush_tag])
+ if not torrent:
+ return None
+ else:
+ if brush_config.up_speed or brush_config.dl_speed:
+ self.tr.change_torrent(hash_string=torrent.hashString,
+ upload_limit=up_speed,
+ download_limit=down_speed)
+ return torrent.hashString
+ return None
+
+ def __qb_add_torrent(self,
+ content: Union[str, bytes],
+ is_paused: bool = False,
+ download_dir: str = None,
+ tag: Union[str, list] = None,
+ category: str = None,
+ cookie=None,
+ is_auto=False,
+ is_first_last_piece_priority=False,
+ **kwargs
+ ) -> bool:
+ """
+ 添加种子
+ :param content: 种子urls或文件内容
+ :param is_paused: 添加后暂停
+ :param tag: 标签
+ :param category: 种子分类
+ :param download_dir: 下载路径
+ :param cookie: 站点Cookie用于辅助下载种子
+ :return: bool
+ """
+ if not self.qb.qbc or not content:
+ return False
+
+ # 下载内容
+ if isinstance(content, str):
+ urls = content
+ torrent_files = None
+ else:
+ urls = None
+ torrent_files = content
+
+ # 保存目录
+ if download_dir:
+ save_path = download_dir
+ else:
+ save_path = None
+
+ # 标签
+ if tag:
+ tags = tag
+ else:
+ tags = None
+
+ try:
+ # 添加下载
+ qbc_ret = self.qb.qbc.torrents_add(urls=urls,
+ torrent_files=torrent_files,
+ save_path=save_path,
+ is_paused=is_paused,
+ tags=tags,
+ use_auto_torrent_management=is_auto,
+ is_first_last_piece_priority=is_first_last_piece_priority,
+ cookie=cookie,
+ category=category,
+ **kwargs)
+ return True if qbc_ret and str(qbc_ret).find("Ok") != -1 else False
+ except Exception as err:
+ logger.error(f"添加种子出错:{str(err)}")
+ return False
+
+ def __qb_torrents_reannounce(self, torrent_hashes: List[str]):
+ """强制重新汇报"""
+ if not self.qb.qbc:
+ return
+
+ if not torrent_hashes:
+ return
+
+ try:
+ # 重新汇报
+ self.qb.qbc.torrents_reannounce(torrent_hashes=torrent_hashes)
+ except Exception as err:
+ logger.error(f"强制重新汇报失败:{str(err)}")
+
+ def __get_hash(self, torrent: Any):
+ """
+ 获取种子hash
+ """
+ brush_config = self.__get_brush_config()
+ try:
+ return torrent.get("hash") if brush_config.downloader == "qbittorrent" else torrent.hashString
+ except Exception as e:
+ print(str(e))
+ return ""
+
+ def __get_all_hashes(self, torrents):
+ """
+ 获取torrents列表中所有种子的Hash值
+
+ :param torrents: 包含种子信息的列表
+ :return: 包含所有Hash值的列表
+ """
+ brush_config = self.__get_brush_config()
+ try:
+ all_hashes = []
+ for torrent in torrents:
+ # 根据下载器类型获取Hash值
+ hash_value = torrent.get("hash") if brush_config.downloader == "qbittorrent" else torrent.hashString
+ if hash_value:
+ all_hashes.append(hash_value)
+ return all_hashes
+ except Exception as e:
+ print(str(e))
+ return []
+
+ def __get_label(self, torrent: Any):
+ """
+ 获取种子标签
+ """
+ brush_config = self.__get_brush_config()
+ try:
+ return [str(tag).strip() for tag in torrent.get("tags").split(',')] \
+ if brush_config.downloader == "qbittorrent" else torrent.labels or []
+ except Exception as e:
+ print(str(e))
+ return []
+
+ def __get_torrent_info(self, torrent: Any) -> dict:
+ """
+ 获取种子信息
+ """
+ date_now = int(time.time())
+ brush_config = self.__get_brush_config()
+ # QB
+ if brush_config.downloader == "qbittorrent":
+ """
+ {
+ "added_on": 1693359031,
+ "amount_left": 0,
+ "auto_tmm": false,
+ "availability": -1,
+ "category": "tJU",
+ "completed": 67759229411,
+ "completion_on": 1693609350,
+ "content_path": "/mnt/sdb/qb/downloads/Steel.Division.2.Men.of.Steel-RUNE",
+ "dl_limit": -1,
+ "dlspeed": 0,
+ "download_path": "",
+ "downloaded": 67767365851,
+ "downloaded_session": 0,
+ "eta": 8640000,
+ "f_l_piece_prio": false,
+ "force_start": false,
+ "hash": "116bc6f3efa6f3b21a06ce8f1cc71875",
+ "infohash_v1": "116bc6f306c40e072bde8f1cc71875",
+ "infohash_v2": "",
+ "last_activity": 1693609350,
+ "magnet_uri": "magnet:?xt=",
+ "max_ratio": -1,
+ "max_seeding_time": -1,
+ "name": "Steel.Division.2.Men.of.Steel-RUNE",
+ "num_complete": 1,
+ "num_incomplete": 0,
+ "num_leechs": 0,
+ "num_seeds": 0,
+ "priority": 0,
+ "progress": 1,
+ "ratio": 0,
+ "ratio_limit": -2,
+ "save_path": "/mnt/sdb/qb/downloads",
+ "seeding_time": 615035,
+ "seeding_time_limit": -2,
+ "seen_complete": 1693609350,
+ "seq_dl": false,
+ "size": 67759229411,
+ "state": "stalledUP",
+ "super_seeding": false,
+ "tags": "",
+ "time_active": 865354,
+ "total_size": 67759229411,
+ "tracker": "https://tracker",
+ "trackers_count": 2,
+ "up_limit": -1,
+ "uploaded": 0,
+ "uploaded_session": 0,
+ "upspeed": 0
+ }
+ """
+ # ID
+ torrent_id = torrent.get("hash")
+ # 标题
+ torrent_title = torrent.get("name")
+ # 下载时间
+ if (not torrent.get("added_on")
+ or torrent.get("added_on") < 0):
+ dltime = 0
+ else:
+ dltime = date_now - torrent.get("added_on")
+ # 做种时间
+ if (not torrent.get("completion_on")
+ or torrent.get("completion_on") < 0):
+ seeding_time = 0
+ else:
+ seeding_time = date_now - torrent.get("completion_on")
+ # 分享率
+ ratio = torrent.get("ratio") or 0
+ # 上传量
+ uploaded = torrent.get("uploaded") or 0
+ # 平均上传速度 Byte/s
+ if dltime:
+ avg_upspeed = int(uploaded / dltime)
+ else:
+ avg_upspeed = uploaded
+ # 已未活动 秒
+ if (not torrent.get("last_activity")
+ or torrent.get("last_activity") < 0):
+ iatime = 0
+ else:
+ iatime = date_now - torrent.get("last_activity")
+ # 下载量
+ downloaded = torrent.get("downloaded")
+ # 种子大小
+ total_size = torrent.get("total_size")
+ # 添加时间
+ add_on = (torrent.get("added_on") or 0)
+ add_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(add_on))
+ # 种子标签
+ tags = torrent.get("tags")
+ # tracker
+ tracker = torrent.get("tracker")
+ # TR
+ else:
+ # ID
+ torrent_id = torrent.hashString
+ # 标题
+ torrent_title = torrent.name
+ # 做种时间
+ if (not torrent.date_done
+ or torrent.date_done.timestamp() < 1):
+ seeding_time = 0
+ else:
+ seeding_time = date_now - int(torrent.date_done.timestamp())
+ # 下载耗时
+ if (not torrent.date_added
+ or torrent.date_added.timestamp() < 1):
+ dltime = 0
+ else:
+ dltime = date_now - int(torrent.date_added.timestamp())
+ # 下载量
+ downloaded = int(torrent.total_size * torrent.progress / 100)
+ # 分享率
+ ratio = torrent.ratio or 0
+ # 上传量
+ uploaded = int(downloaded * torrent.ratio)
+ # 平均上传速度
+ if dltime:
+ avg_upspeed = int(uploaded / dltime)
+ else:
+ avg_upspeed = uploaded
+ # 未活动时间
+ if (not torrent.date_active
+ or torrent.date_active.timestamp() < 1):
+ iatime = 0
+ else:
+ iatime = date_now - int(torrent.date_active.timestamp())
+ # 种子大小
+ total_size = torrent.total_size
+ # 添加时间
+ add_on = (torrent.date_added.timestamp() if torrent.date_added else 0)
+ add_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(add_on))
+ # 种子标签
+ tags = torrent.get("tags")
+ # tracker
+ tracker = torrent.get("tracker")
+
+ return {
+ "hash": torrent_id,
+ "title": torrent_title,
+ "seeding_time": seeding_time,
+ "ratio": ratio,
+ "uploaded": uploaded,
+ "downloaded": downloaded,
+ "avg_upspeed": avg_upspeed,
+ "iatime": iatime,
+ "dltime": dltime,
+ "total_size": total_size,
+ "add_time": add_time,
+ "add_on": add_on,
+ "tags": tags,
+ "tracker": tracker
+ }
+
+ def __log_and_notify_error(self, message):
+ """
+ 记录错误日志并发送系统通知
+ """
+ logger.error(message)
+ self.systemmessage.put(message, title="站点刷流")
+
+ def __send_delete_message(self, site_name: str, torrent_title: str, torrent_desc: str, reason: str,
+ title: str = "【刷流任务种子删除】"):
+ """
+ 发送删除种子的消息
+ """
+ brush_config = self.__get_brush_config()
+ if not brush_config.notify:
+ return
+ msg_text = ""
+ if site_name:
+ msg_text = f"站点:{site_name}"
+ if torrent_title:
+ msg_text = f"{msg_text}\n标题:{torrent_title}"
+ if torrent_desc:
+ msg_text = f"{msg_text}\n内容:{torrent_desc}"
+ if reason:
+ msg_text = f"{msg_text}\n原因:{reason}"
+
+ self.post_message(mtype=NotificationType.SiteMessage, title=title, text=msg_text)
+
+ @staticmethod
+ def __build_add_message_text(torrent):
+ """
+ 构建消息文本,兼容TorrentInfo对象和torrent_task字典
+ """
+
+ # 定义一个辅助函数来统一获取数据的方式
+ def get_data(_key, default=None):
+ if isinstance(torrent, dict):
+ return torrent.get(_key, default)
+ else:
+ return getattr(torrent, _key, default)
+
+ # 构造消息文本,确保使用中文标签
+ msg_parts = []
+ label_mapping = {
+ "site_name": "站点",
+ "title": "标题",
+ "description": "内容",
+ "size": "大小",
+ "pubdate": "发布时间",
+ "seeders": "做种数",
+ "volume_factor": "促销",
+ "hit_and_run": "Hit&Run"
+ }
+ for key in label_mapping:
+ value = get_data(key)
+ if key == "size" and value and str(value).replace(".", "", 1).isdigit():
+ value = StringUtils.str_filesize(value)
+ if value:
+ msg_parts.append(f"{label_mapping[key]}:{'是' if key == 'hit_and_run' and value else value}")
+
+ return "\n".join(msg_parts)
+
+ def __send_add_message(self, torrent, title: str = "【刷流任务种子下载】"):
+ """
+ 发送添加下载的消息
+ """
+ brush_config = self.__get_brush_config()
+ if not brush_config.notify:
+ return
+
+ # 使用辅助方法构建消息文本
+ msg_text = self.__build_add_message_text(torrent)
+ self.post_message(mtype=NotificationType.SiteMessage, title=title, text=msg_text)
+
+ def __send_message(self, title: str, text: str):
+ """
+ 发送消息
+ """
+ brush_config = self.__get_brush_config()
+ if not brush_config.notify:
+ return
+
+ self.post_message(mtype=NotificationType.SiteMessage, title=title, text=text)
+
+ def __log_and_send_torrent_task_update_message(self, title: str, status: str, reason: str,
+ torrent_tasks: List[dict]):
+ """
+ 记录和发送刷流任务更新消息
+ """
+ if torrent_tasks:
+ sites_names = ', '.join({task.get("site_name", "N/A") for task in torrent_tasks})
+ first_title = torrent_tasks[0].get('title', 'N/A')
+ count = len(torrent_tasks)
+ msg = f"站点:{sites_names}\n内容:{first_title} 等 {count} 个种子已经{status}\n原因:{reason}"
+ logger.info(f"{title},{msg}")
+ self.__send_message(title=title, text=msg)
+
+ def __get_torrents_size(self) -> int:
+ """
+ 获取任务中的种子总大小
+ """
+ # 读取种子记录
+ task_info = self.get_data("torrents") or {}
+ if not task_info:
+ return 0
+ total_size = sum([task.get("size") or 0 for task in task_info.values()])
+ return total_size
+
+ def __get_downloader_info(self) -> schemas.DownloaderInfo:
+ """
+ 获取下载器实时信息(所有下载器)
+ """
+ ret_info = schemas.DownloaderInfo()
+
+ # Qbittorrent
+ if self.qb:
+ info = self.qb.transfer_info()
+ if info:
+ ret_info.download_speed += info.get("dl_info_speed")
+ ret_info.upload_speed += info.get("up_info_speed")
+ ret_info.download_size += info.get("dl_info_data")
+ ret_info.upload_size += info.get("up_info_data")
+
+ # Transmission
+ if self.tr:
+ info = self.tr.transfer_info()
+ if info:
+ ret_info.download_speed += info.download_speed
+ ret_info.upload_speed += info.upload_speed
+ ret_info.download_size += info.current_stats.downloaded_bytes
+ ret_info.upload_size += info.current_stats.uploaded_bytes
+
+ return ret_info
+
+ def __get_downloading_count(self) -> int:
+ """
+ 获取正在下载的任务数量
+ """
+ try:
+ brush_config = self.__get_brush_config()
+ downloader = self.__get_downloader(brush_config.downloader)
+ if not downloader:
+ return 0
+
+ torrents = downloader.get_downloading_torrents(tags=brush_config.brush_tag)
+ if torrents is None:
+ logger.warn("获取下载数量失败,可能是下载器连接发生异常")
+ return 0
+
+ return len(torrents)
+ except Exception as e:
+ logger.error(f"获取下载数量发生异常: {e}")
+ return 0
+
+ @staticmethod
+ def __get_pubminutes(pubdate: str) -> float:
+ """
+ 将字符串转换为时间,并计算与当前时间差)(分钟)
+ """
+ try:
+ if not pubdate:
+ return 0
+ pubdate = pubdate.replace("T", " ").replace("Z", "")
+ pubdate = datetime.strptime(pubdate, "%Y-%m-%d %H:%M:%S")
+ now = datetime.now()
+ return (now - pubdate).total_seconds() // 60
+ except Exception as e:
+ logger.error(f"发布时间 {pubdate} 获取分钟失败,错误详情: {e}")
+ return 0
+
+ @staticmethod
+ def __adjust_site_pubminutes(pub_minutes: float, torrent: TorrentInfo) -> float:
+ """
+ 处理部分站点的时区逻辑
+ """
+ try:
+ if not torrent:
+ return pub_minutes
+
+ if torrent.site_name == "我堡":
+ # 获取当前时区的UTC偏移量(以秒为单位)
+ utc_offset_seconds = time.timezone
+
+ # 将UTC偏移量转换为分钟
+ utc_offset_minutes = utc_offset_seconds / 60
+
+ # 增加UTC偏移量到pub_minutes
+ adjusted_pub_minutes = pub_minutes + utc_offset_minutes
+
+ return adjusted_pub_minutes
+
+ return pub_minutes
+ except Exception as e:
+ logger.error(str(e))
+ return 0
+
+ def __filter_torrents_by_tag(self, torrents: List[Any], exclude_tag: str) -> List[Any]:
+ """
+ 根据标签过滤torrents,排除标签格式为逗号分隔的字符串,例如 "MOVIEPILOT, H&R"
+ """
+ # 如果排除标签字符串为空,则返回原始列表
+ if not exclude_tag:
+ return torrents
+
+ # 将 exclude_tag 字符串分割成一个集合,并去除每个标签两端的空白,忽略空白标签并自动去重
+ exclude_tags = set(tag.strip() for tag in exclude_tag.split(',') if tag.strip())
+
+ filter_torrents = []
+ for torrent in torrents:
+ # 使用 __get_label 方法获取每个 torrent 的标签列表
+ labels = self.__get_label(torrent)
+ # 检查是否有任何一个排除标签存在于标签列表中
+ if not any(exclude in labels for exclude in exclude_tags):
+ filter_torrents.append(torrent)
+ return filter_torrents
+
+ def __get_subscribe_titles(self) -> Set[str]:
+ """
+ 获取当前订阅的所有标题,返回一个不包含None和空白字符的集合
+ """
+ brush_config = self.__get_brush_config()
+ if not brush_config.except_subscribe:
+ logger.info("没有开启排除订阅,取消订阅标题匹配")
+ return set()
+
+ logger.info("已开启排除订阅,正在准备订阅标题匹配 ...")
+
+ if not self._subscribe_infos:
+ self._subscribe_infos = {}
+
+ subscribes = self.subscribeoper.list()
+ if subscribes:
+ # 遍历订阅
+ for subscribe in subscribes:
+ # 判断当前订阅是否已经在缓存中,如果已经处理过,那么这里直接跳过
+ subscribe_key = f"{subscribe.id}_{subscribe.name}"
+ if subscribe_key in self._subscribe_infos:
+ continue
+
+ subscribe_titles = [subscribe.name]
+ try:
+ # 生成元数据
+ meta = MetaInfo(subscribe.name)
+ meta.year = subscribe.year
+ meta.begin_season = subscribe.season or None
+ meta.type = MediaType(subscribe.type)
+ # 识别媒体信息
+ mediainfo: MediaInfo = self.chain.recognize_media(meta=meta, mtype=meta.type,
+ tmdbid=subscribe.tmdbid,
+ doubanid=subscribe.doubanid,
+ cache=True)
+ if mediainfo:
+ logger.info(f"订阅 {subscribe.name} 已识别到媒体信息")
+ logger.debug(f"subscribe {subscribe.name} {mediainfo.to_dict()}")
+ subscribe_titles.extend(mediainfo.names)
+ subscribe_titles = [title.strip() for title in subscribe_titles if title and title.strip()]
+ self._subscribe_infos[subscribe_key] = subscribe_titles
+ else:
+ logger.info(f"订阅 {subscribe.name} 没有识别到媒体信息,跳过订阅标题匹配")
+ except Exception as e:
+ logger.error(f"识别订阅 {subscribe.name} 媒体信息失败,错误详情: {e}")
+
+ # 移除不再存在的订阅
+ current_keys = {f"{subscribe.id}_{subscribe.name}" for subscribe in subscribes}
+ for key in set(self._subscribe_infos) - current_keys:
+ del self._subscribe_infos[key]
+
+ logger.info("订阅标题匹配完成")
+ logger.debug(f"当前订阅的标题集合为:{self._subscribe_infos}")
+ unique_titles = {title for titles in self._subscribe_infos.values() for title in titles}
+ return unique_titles
+
+ @staticmethod
+ def __filter_torrents_contains_subscribe(torrents: Any, subscribe_titles: Set[str]):
+ # 初始化两个列表,一个用于收集未被排除的种子,一个用于记录被排除的种子
+ included_torrents = []
+ excluded_torrents = []
+
+ # 单次遍历处理
+ for torrent in torrents:
+ # 确保title和description至少是空字符串
+ title = torrent.title or ''
+ description = torrent.description or ''
+
+ if any(subscribe_title in title or subscribe_title in description for subscribe_title in subscribe_titles):
+ # 如果种子的标题或描述包含订阅标题中的任一项,则记录为被排除
+ excluded_torrents.append(torrent)
+ logger.info(f"命中订阅内容,排除种子:{title}|{description}")
+ else:
+ # 否则,收集为未被排除的种子
+ included_torrents.append(torrent)
+
+ if not excluded_torrents:
+ logger.info(f"没有命中订阅内容,不需要排除种子")
+
+ # 返回未被排除的种子列表
+ return included_torrents
+
+ @staticmethod
+ def __bytes_to_gb(size_in_bytes: float) -> float:
+ """
+ 将字节单位的大小转换为千兆字节(GB)。
+
+ :param size_in_bytes: 文件大小,单位为字节。
+ :return: 文件大小,单位为千兆字节(GB)。
+ """
+ if not size_in_bytes:
+ return 0.0
+ return size_in_bytes / (1024 ** 3)
+
+ @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))
+
+ @staticmethod
+ def __is_number(value):
+ """
+ 检查给定的值是否可以被转换为数字(整数或浮点数)
+ """
+ try:
+ float(value)
+ return True
+ except ValueError:
+ return False
+
+ @staticmethod
+ def __calculate_seeding_torrents_size(torrent_tasks: Dict[str, dict]) -> float:
+ """
+ 计算保种种子体积
+ """
+ return sum(task.get("size", 0) for task in torrent_tasks.values() if not task.get("deleted", False))
+
+ def __auto_archive_tasks(self, torrent_tasks: Dict[str, dict]) -> None:
+ """
+ 自动归档已经删除的种子数据
+ """
+ if not self._brush_config.auto_archive_days or self._brush_config.auto_archive_days <= 0:
+ logger.info("自动归档记录天数小于等于0,取消自动归档")
+ return
+
+ # 用于存储已删除的数据
+ archived_tasks: Dict[str, dict] = self.get_data("archived") or {}
+
+ current_time = time.time()
+ archive_threshold_seconds = self._brush_config.auto_archive_days * 86400 # 将天数转换为秒数
+
+ # 准备一个列表,记录所有需要从原始数据中删除的键
+ keys_to_delete = set()
+
+ # 遍历所有 torrent 条目
+ for key, value in torrent_tasks.items():
+ deleted_time = value.get("deleted_time")
+ # 场景 1: 检查任务是否已被标记为删除且超出保留天数
+ if (value.get("deleted") and isinstance(deleted_time, (int, float)) and
+ current_time - deleted_time > archive_threshold_seconds):
+ keys_to_delete.add(key)
+ archived_tasks[key] = value
+ continue
+
+ # 场景 2: 检查没有明确删除时间的历史数据
+ if value.get("deleted") and deleted_time is None:
+ keys_to_delete.add(key)
+ archived_tasks[key] = value
+ continue
+
+ # 从原始字典中移除已删除的条目
+ for key in keys_to_delete:
+ del torrent_tasks[key]
+
+ self.save_data("archived", archived_tasks)
+
+ def __archive_tasks(self):
+ """
+ 归档已经删除的种子数据
+ """
+ torrent_tasks: Dict[str, dict] = self.get_data("torrents") or {}
+
+ # 用于存储已删除的数据
+ archived_tasks: Dict[str, dict] = self.get_data("archived") or {}
+
+ # 准备一个列表,记录所有需要从原始数据中删除的键
+ keys_to_delete = set()
+
+ # 遍历所有 torrent 条目
+ for key, value in torrent_tasks.items():
+ # 检查是否标记为已删除
+ if value.get("deleted"):
+ # 如果是,加入到归档字典中
+ archived_tasks[key] = value
+ # 记录键,稍后删除
+ keys_to_delete.add(key)
+
+ # 从原始字典中移除已删除的条目
+ for key in keys_to_delete:
+ del torrent_tasks[key]
+
+ self.save_data("archived", archived_tasks)
+ self.save_data("torrents", torrent_tasks)
+ # 归档需要更新一下统计数据
+ self.__update_and_save_statistic_info(torrent_tasks=torrent_tasks)
+
+ def __clear_tasks(self):
+ """
+ 清除统计数据
+ 彻底重置所有刷流数据,如当前还存在正在做种的刷流任务,待定时检查任务执行后,会自动纳入刷流管理
+ """
+ self.save_data("torrents", {})
+ self.save_data("archived", {})
+ self.save_data("unmanaged", {})
+ self.save_data("statistic", {})
+
+ def __get_statistic_info(self) -> Dict[str, int]:
+ """
+ 获取统计数据
+ """
+ statistic_info = self.get_data("statistic") or {
+ "count": 0,
+ "deleted": 0,
+ "uploaded": 0,
+ "downloaded": 0,
+ "unarchived": 0,
+ "active": 0,
+ "active_uploaded": 0,
+ "active_downloaded": 0
+ }
+ return statistic_info
+
+ @staticmethod
+ def __is_valid_time_range(time_range: str) -> bool:
+ """检查时间范围字符串是否有效:格式为"HH:MM-HH:MM",且时间有效"""
+ if not time_range:
+ return False
+
+ # 使用正则表达式匹配格式
+ pattern = re.compile(r'^\d{2}:\d{2}-\d{2}:\d{2}$')
+ if not pattern.match(time_range):
+ return False
+
+ try:
+ start_str, end_str = time_range.split('-')
+ datetime.strptime(start_str, '%H:%M').time()
+ datetime.strptime(end_str, '%H:%M').time()
+ except Exception as e:
+ print(str(e))
+ return False
+
+ return True
+
+ def __is_current_time_in_range(self) -> bool:
+ """判断当前时间是否在开启时间区间内"""
+
+ brush_config = self.__get_brush_config()
+ active_time_range = brush_config.active_time_range
+
+ if not self.__is_valid_time_range(active_time_range):
+ # 如果时间范围格式不正确或不存在,说明当前没有开启时间段,返回True
+ return True
+
+ start_str, end_str = active_time_range.split('-')
+ start_time = datetime.strptime(start_str, '%H:%M').time()
+ end_time = datetime.strptime(end_str, '%H:%M').time()
+ now = datetime.now().time()
+
+ if start_time <= end_time:
+ # 情况1: 时间段不跨越午夜
+ return start_time <= now <= end_time
+ else:
+ # 情况2: 时间段跨越午夜
+ return now >= start_time or now <= end_time
+
+ def __get_site_by_torrent(self, torrent: Any) -> Tuple[int, str]:
+ """
+ 根据tracker获取站点信息
+ """
+ trackers = []
+ try:
+ tracker_url = torrent.get("tracker")
+ if tracker_url:
+ trackers.append(tracker_url)
+
+ magnet_link = torrent.get("magnet_uri")
+ if magnet_link:
+ query_params: dict = parse_qs(urlparse(magnet_link).query)
+ encoded_tracker_urls = query_params.get('tr', [])
+ # 解码tracker URLs然后扩展到trackers列表中
+ decoded_tracker_urls = [unquote(url) for url in encoded_tracker_urls]
+ trackers.extend(decoded_tracker_urls)
+ except Exception as e:
+ logger.error(e)
+
+ domain = "未知"
+ if not trackers:
+ return 0, domain
+
+ # 特定tracker到域名的映射
+ tracker_mappings = {
+ "chdbits.xyz": "ptchdbits.co",
+ "agsvpt.trackers.work": "agsvpt.com",
+ "tracker.cinefiles.info": "audiences.me",
+ }
+
+ for tracker in trackers:
+ if not tracker:
+ continue
+ # 检查tracker是否包含特定的关键字,并进行相应的映射
+ for key, mapped_domain in tracker_mappings.items():
+ if key in tracker:
+ domain = mapped_domain
+ break
+ else:
+ # 使用StringUtils工具类获取tracker的域名
+ domain = StringUtils.get_url_domain(tracker)
+
+ site_info = self.siteshelper.get_indexer(domain)
+ if site_info:
+ return site_info.get("id"), site_info.get("name")
+
+ # 当找不到对应的站点信息时,返回一个默认值
+ return 0, domain
diff --git a/plugins.v2/cleaninvalidseed/__init__.py b/plugins.v2/cleaninvalidseed/__init__.py
new file mode 100644
index 0000000..d041065
--- /dev/null
+++ b/plugins.v2/cleaninvalidseed/__init__.py
@@ -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))
diff --git a/plugins.v2/clouddiskdel/__init__.py b/plugins.v2/clouddiskdel/__init__.py
new file mode 100644
index 0000000..7769171
--- /dev/null
+++ b/plugins.v2/clouddiskdel/__init__.py
@@ -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
diff --git a/plugins.v2/configcenter/__init__.py b/plugins.v2/configcenter/__init__.py
new file mode 100644
index 0000000..733de8b
--- /dev/null
+++ b/plugins.v2/configcenter/__init__.py
@@ -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
diff --git a/plugins.v2/crossseed/__init__.py b/plugins.v2/crossseed/__init__.py
new file mode 100644
index 0000000..82fb138
--- /dev/null
+++ b/plugins.v2/crossseed/__init__.py
@@ -0,0 +1,1232 @@
+import hashlib
+import os
+import re
+import time
+from datetime import datetime, timedelta
+from pathlib import Path
+from threading import Event
+from typing import Any, Dict, List, Optional, Tuple, Union
+
+import pytz
+import requests
+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.core.event import eventmanager
+from app.db.site_oper import SiteOper
+from app.helper.sites import SitesHelper
+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.schemas.types import EventType
+from app.utils.string import StringUtils
+from app.utils.timer import TimerUtils
+
+
+class CSSiteConfig(object):
+ """
+ 站点辅种配置类
+ """
+
+ def __init__(
+ self,
+ name: str = None,
+ url: str = None,
+ passkey: str = None,
+ id: int = None,
+ cookie: str = None,
+ ua: str = None,
+ proxy: bool = None,
+ query_gap: int = 1,
+ ) -> None:
+ self.name = name
+ self.url = url
+ self.passkey = passkey
+ self.id = id
+ self.cookie = cookie
+ self.ua = ua
+ self.proxy = proxy
+ self.query_gap = query_gap
+
+ def get_api_url(self):
+ if self.name == "憨憨":
+ return f"{self.url}nexusapi/pieces-hash"
+ return f"{self.url}api/pieces-hash"
+
+ def get_torrent_url(self, torrent_id: str):
+ return f"{self.url}download.php?id={torrent_id}&passkey={self.passkey}"
+
+
+class TorInfo:
+
+ def __init__(
+ self,
+ site_name: str = None,
+ torrent_path: str = None,
+ file_path: str = None,
+ info_hash: str = None,
+ pieces_hash: str = None,
+ torrent_id: str = None,
+ ) -> None:
+ self.site_name = site_name
+ self.torrent_path = torrent_path
+ self.file_path = file_path
+ self.info_hash = info_hash
+ self.pieces_hash = pieces_hash
+ self.torrent_id = torrent_id
+ self.torrent_announce = None
+
+ @staticmethod
+ def local(torrent_path: str, info_hash: str, pieces_hash: str):
+
+ return TorInfo(
+ torrent_path=torrent_path, info_hash=info_hash, pieces_hash=pieces_hash
+ )
+
+ @staticmethod
+ def remote(site_name: str, pieces_hash: str, torrent_id: str):
+ return TorInfo(
+ site_name=site_name, pieces_hash=pieces_hash, torrent_id=torrent_id
+ )
+
+ @staticmethod
+ def from_data(data: bytes) -> Tuple[Optional[Any], Optional[str]]:
+ try:
+ torrent = bdecode(data)
+ info = torrent["info"]
+ pieces = info["pieces"]
+ info_hash = hashlib.sha1(bencode(info)).hexdigest()
+ pieces_hash = hashlib.sha1(pieces).hexdigest()
+ local_tor = TorInfo(info_hash=info_hash, pieces_hash=pieces_hash)
+ # 从种子中获取 announce, qb可能存在获取不到的情况,会存在于fastresume文件中
+ if "announce" in torrent:
+ local_tor.torrent_announce = torrent["announce"]
+ return local_tor, None
+ except Exception as err:
+ return None, str(err)
+
+ def get_name_id_tag(self):
+ return f"{self.site_name}:{self.torrent_id}"
+
+ def get_name_pieces_tag(self):
+ return f"{self.site_name}:{self.pieces_hash}"
+
+
+class CrossSeedHelper(object):
+ _version = "0.2.0"
+
+ @staticmethod
+ def get_local_torrent_info(torrent_path: Path | str) -> Tuple[Optional[TorInfo], str]:
+ try:
+ if isinstance(torrent_path, Path):
+ torrent_data = torrent_path.read_bytes()
+ else:
+ with open(torrent_path, "rb") as f:
+ torrent_data = f.read()
+ local_tor, err = TorInfo.from_data(torrent_data)
+ if not local_tor:
+ return None, err
+ local_tor.torrent_path = str(torrent_path)
+ return local_tor, ""
+ except Exception as err:
+ return None, str(err)
+
+ @staticmethod
+ def get_target_torrent(
+ site: CSSiteConfig,
+ pieces_hash_set: List[str]
+ ) -> Tuple[Optional[List[TorInfo]], Optional[str]]:
+ """
+ 返回pieces_hash对应的种子信息,包括站点id,pieces_hash,种子id
+ """
+ headers = {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ "User-Agent": "CrossSeedHelper",
+ }
+ data = {"passkey": site.passkey, "pieces_hash": pieces_hash_set}
+ remote_torrent_infos = []
+ try:
+ response = requests.post(
+ site.get_api_url(),
+ headers=headers,
+ json=data,
+ timeout=10,
+ proxies=settings.PROXY if site.proxy else None,
+ )
+ response.raise_for_status()
+ rsp_body = response.json()
+ if isinstance(rsp_body["data"], dict):
+ for pieces_hash, torrent_id in rsp_body["data"].items():
+ remote_torrent_infos.append(
+ TorInfo.remote(site.name, pieces_hash, torrent_id)
+ )
+ time.sleep(site.query_gap)
+ except requests.exceptions.RequestException as e:
+ return None, f"站点{site.name}请求失败:{e}"
+ return remote_torrent_infos, None
+
+
+class CrossSeed(_PluginBase):
+ # 插件名称
+ plugin_name = "青蛙辅种助手"
+ # 插件描述
+ plugin_desc = "参考ReseedPuppy和IYUU辅种插件实现自动辅种,支持站点:青蛙、AGSVPT、麒麟、UBits、聆音、憨憨等。"
+ # 插件图标
+ plugin_icon = "qingwa.png"
+ # 插件版本
+ plugin_version = "2.3"
+ # 插件作者
+ plugin_author = "233@qingwa"
+ # 作者主页
+ author_url = "https://qingwapt.com/"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "cross_seed_"
+ # 加载顺序
+ plugin_order = 17
+ # 可使用的用户级别
+ auth_level = 2
+
+ # 私有属性
+ _scheduler = None
+ cross_helper = None
+ qb = None
+ tr = None
+ sites = None
+ siteoper = None
+ torrent = None
+ # 开关
+ _enabled = False
+ _cron = None
+ _onlyonce = False
+ _token = None
+ _downloaders = []
+ _sites = []
+ _torrentpath = None
+ _notify = False
+ _nolabels = None
+ _nopaths = None
+ _clearcache = False
+ # 退出事件
+ _event = Event()
+ _torrent_tags = ["已整理", "辅种"]
+ # 待校全种子hash清单
+ _recheck_torrents = {}
+ _is_recheck_running = False
+ # 辅种缓存,出错的种子不再重复辅种,可清除
+ _error_caches = []
+ # 辅种缓存,辅种成功的种子,可清除
+ _success_caches = []
+ # 辅种缓存,出错的种子不再重复辅种,且无法清除。种子被删除404等情况
+ _permanent_error_caches = []
+ _torrentpaths = []
+ _site_cs_infos = []
+ # 辅种计数
+ total = 0
+ realtotal = 0
+ success = 0
+ exist = 0
+ fail = 0
+ cached = 0
+
+ def init_plugin(self, config: dict = None):
+ self.sites = SitesHelper()
+ self.siteoper = SiteOper()
+ self.torrent = TorrentHelper()
+ # 读取配置
+ if config:
+ self._enabled = config.get("enabled")
+ self._onlyonce = config.get("onlyonce")
+ self._cron = config.get("cron")
+ self._token = config.get("token") # passkey格式 青蛙:xxxxxx,站点名称:xxxxxxx
+
+ self._downloaders = config.get("downloaders")
+ self._torrentpath = config.get("torrentpath") # 种子路径和下载器对应 /qb,/tr
+ self._torrentpaths = self._torrentpath.strip().split(",")
+ self._sites = config.get("sites") or []
+ self._notify = config.get("notify")
+ self._nolabels = config.get("nolabels")
+ self._nopaths = config.get("nopaths")
+ self._clearcache = config.get("clearcache")
+ self._permanent_error_caches = [] if self._clearcache else config.get("permanent_error_caches") or []
+ self._error_caches = [] if self._clearcache else config.get("error_caches") or []
+ self._success_caches = [] if self._clearcache else config.get("success_caches") or []
+
+ # 过滤掉已删除的站点
+ inner_site_list = self.siteoper.list_order_by_pri()
+ all_sites = [(site.id, site.name) for site in inner_site_list] + [
+ (site.get("id"), site.get("name")) for site in self.__custom_sites()
+ ]
+ self._sites = [site_id for site_id, site_name in all_sites if site_id in self._sites]
+
+ # 整理所有可用内部站点信息
+ all_site_cs_info_map: dict[str, CSSiteConfig] = dict()
+ for site in inner_site_list:
+ if site.is_active:
+ all_site_cs_info_map[site.name] = CSSiteConfig(
+ name=site.name,
+ url=site.url,
+ id=site.id,
+ cookie=site.cookie,
+ ua=site.ua,
+ proxy=True if site.proxy else False,
+ )
+ for site in self.__custom_sites():
+ all_site_cs_info_map[site.get("name")] = CSSiteConfig(
+ name=site.get("name"),
+ url=site.get("url"),
+ id=site.get("id"),
+ cookie=site.get("cookie"),
+ ua=site.get("ua"),
+ proxy=site.get("proxy"),
+ )
+ self._sites = [site.id for site in all_site_cs_info_map.values() if site.id in self._sites]
+ site_names = [site.name for site in all_site_cs_info_map.values() if site.id in self._sites]
+
+ # 整理passkey映射关系
+ site_name_key_map = dict()
+ site_name_gap_map = dict()
+ for site_key in self._token.strip().split("\n"):
+ site_key_arr = re.split(r"[\s::]+", site_key.strip())
+ site_name = site_key_arr[0]
+ if len(site_key_arr) > 1:
+ site_name_key_map[site_name] = site_key_arr[1]
+ if len(site_key_arr) > 2:
+ if str.isdigit(site_key_arr[2]):
+ site_name_gap_map[site_name] = int(site_key_arr[2])
+ else:
+ logger.warn(
+ f"站点{site_name}配置的查询请求间隔时间不为整数,不能生效, 请修改 {site_key_arr[2]}"
+ )
+
+ # 只给选中的站点补全站点配置
+ self._site_cs_infos: List[CSSiteConfig] = []
+ # 根据配置来补充passkey
+ for site_name in site_names:
+ site_key = site_name_key_map.get(site_name)
+ if not site_key:
+ logger.warning(
+ f"未找到站点{site_name}的passkey, 请检查passkey配置是否有误,站点{site_name}将跳过辅种")
+ continue
+ site_cs_info = all_site_cs_info_map.get(site_name)
+ site_cs_info.passkey = site_key
+ # 追加设置的请求间隔时间
+ site_query_gap = site_name_gap_map.get(site_name)
+ if site_query_gap:
+ site_cs_info.query_gap = site_query_gap
+ self._site_cs_infos.append(site_cs_info)
+
+ self.__update_config()
+
+ # 停止现有任务
+ self.stop_service()
+
+ # 启动定时任务 & 立即运行一次
+ if self.get_state() or self._onlyonce:
+ self.cross_helper = CrossSeedHelper()
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+ self.qb = Qbittorrent()
+ self.tr = Transmission()
+
+ if self._onlyonce:
+ logger.info(f"辅种服务启动,立即运行一次")
+ self._scheduler.add_job(self.auto_seed, 'date',
+ run_date=datetime.now(
+ tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3)
+ )
+
+ # 关闭一次性开关
+ self._onlyonce = False
+ if self._scheduler.get_jobs():
+ # 追加种子校验服务
+ self._scheduler.add_job(self.check_recheck, 'interval', minutes=3)
+ # 启动服务
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+
+ if self._clearcache:
+ # 关闭清除缓存开关
+ self._clearcache = False
+
+ if self._clearcache or self._onlyonce:
+ # 保存配置
+ self.__update_config()
+
+ def get_state(self) -> bool:
+ return True if self._enabled and self._token and self._downloaders and self._torrentpath 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():
+ # 如果开启了定时任务,并且参数齐全
+ if self._cron:
+ return [{
+ "id": "CrossSeed",
+ "name": "青蛙辅种助手",
+ "trigger": CronTrigger.from_crontab(self._cron),
+ "func": self.auto_seed,
+ "kwargs": {}
+ }]
+ else:
+ # 随机时间
+ triggers = TimerUtils.random_scheduler(num_executions=1,
+ begin_hour=2,
+ end_hour=7,
+ max_interval=290,
+ min_interval=0)
+ ret_jobs = []
+ for trigger in triggers:
+ ret_jobs.append({
+ "id": f"CrossSeed|{trigger.hour}:{trigger.minute}",
+ "name": "青蛙辅种助手",
+ "trigger": "cron",
+ "func": self.auto_seed,
+ "kwargs": {
+ "hour": trigger.hour,
+ "minute": trigger.minute
+ }
+ })
+ return ret_jobs
+ elif self._enabled:
+ logger.warn(f"青蛙辅种助手插件参数不全,定时任务未正常启动")
+ return []
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ # 站点的可选项(内置站点 + 自定义站点)
+ customSites = self.__custom_sites()
+
+ # 站点的可选项
+ site_options = ([{"title": site.name, "value": site.id}
+ for site in self.siteoper.list_order_by_pri()]
+ + [{"title": site.get("name"), "value": site.get("id")}
+ for site in customSites])
+ # 测试版本,只支持青蛙
+ # site_options = [s for s in site_options if s["title"]=="青蛙"]
+
+ 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
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'chips': True,
+ 'multiple': True,
+ 'model': 'sites',
+ 'label': '辅种站点',
+ 'items': site_options
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 12
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'token',
+ 'label': '站点Passkey',
+ 'rows': 3,
+ 'placeholder': '每行一个, 格式为 站点名称:Passkey ,站点名称为上面选择的名称,例如青蛙为 青蛙:xxxxxx 其中xxxxxx替换为你的Passkey'
+ }
+ }
+ ]
+ },
+
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ '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': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'cron',
+ 'label': '执行周期',
+ 'placeholder': '0 0 0 ? *'
+ }
+ }
+ ]
+ },
+
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 12
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'torrentpath',
+ 'label': '种子文件目录',
+ 'placeholder': '多个目录逗号分隔,按下载器顺序对应填写,每个下载器只能有一个种子目录'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'nolabels',
+ 'label': '不辅种标签',
+ 'placeholder': '使用,分隔多个标签'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'nopaths',
+ 'label': '不辅种数据文件目录',
+ 'rows': 3,
+ 'placeholder': '每一行一个目录'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'onlyonce',
+ 'label': '立即运行一次',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'clearcache',
+ 'label': '清除缓存后运行',
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '1. 定时任务周期建议每次辅种间隔时间大于1天,不填写每天上午2点到7点随机辅种一次; '
+ '2. 支持辅种站点列表:青蛙、AGSVPT、红豆饭、麒麟、UBits、聆音等,配置passkey时,站点名称需严格和上面选项一致,只有选中的站点会辅种,passkey可保存多个; '
+ '3. 请勿与IYUU辅种插件同时添加相同站点,可能会有冲突,且意义不大;'
+ '4. 测试站点是否支持的方法:【站点域名/api/pieces-hash】接口访问返回405则大概率支持 '
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '【进阶设置】如果辅种过程中访问/api/pieces-hash接口偶尔会失败,可以设置请求间隔时间。 '
+ '可以在passkey后增加 :3 来将某个站点的请求间隔调整为3秒,3可以改为其他数字,只能为整数,默认请求间隔为1秒。 '
+ '示例配置 站点名称:Passkey:3'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "enabled": False,
+ "onlyonce": False,
+ "notify": False,
+ "clearcache": False,
+ "cron": "",
+ "token": "",
+ "downloaders": [],
+ "torrentpath": "",
+ "sites": [],
+ "nopaths": "",
+ "nolabels": ""
+ }
+
+ def get_page(self) -> List[dict]:
+ pass
+
+ def __update_config(self):
+ self.update_config({
+ "enabled": self._enabled,
+ "onlyonce": self._onlyonce,
+ "clearcache": self._clearcache,
+ "cron": self._cron,
+ "token": self._token,
+ "downloaders": self._downloaders,
+ "torrentpath": self._torrentpath,
+ "sites": self._sites,
+ "notify": self._notify,
+ "nolabels": self._nolabels,
+ "nopaths": self._nopaths,
+ "success_caches": self._success_caches,
+ "error_caches": self._error_caches,
+ "permanent_error_caches": self._permanent_error_caches
+ })
+
+ def __get_downloader(self, dtype: str):
+ """
+ 根据类型返回下载器实例
+ """
+ if dtype == "qbittorrent":
+ return self.qb
+ elif dtype == "transmission":
+ return self.tr
+ else:
+ return None
+
+ def auto_seed(self):
+ """
+ 开始辅种
+ """
+ logger.info("开始辅种任务 ...")
+
+ # 计数器初始化
+ self.total = 0
+ self.realtotal = 0
+ self.success = 0
+ self.exist = 0
+ self.fail = 0
+ self.cached = 0
+ # 扫描下载器辅种
+ for idx, downloader in enumerate(self._downloaders):
+ 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
+ hash_strs = []
+ for torrent in torrents:
+ if self._event.is_set():
+ logger.info(f"辅种服务停止")
+ return
+ # 获取种子hash
+ hash_str = self.__get_hash(torrent, downloader)
+ if hash_str in self._error_caches or hash_str in self._permanent_error_caches:
+ logger.info(f"种子 {hash_str} 辅种失败且已缓存,跳过 ...")
+ continue
+ save_path = self.__get_save_path(torrent, downloader)
+ # 获取种子文件路径
+ torrent_path = Path(self._torrentpaths[idx]) / f"{hash_str}.torrent"
+ torrent_info = None
+ if not torrent_path.exists():
+ if False and downloader == "qbittorrent":
+ # qb开启SQLite功能后将不再以hash命名的方式保存torrent文件
+ # TODO 导出功能需要qb4.5.0以上版本才支持
+ logger.warn(f"QB种子文件不存在:{torrent_path} 尝试远程导出种子")
+ try:
+ torrent_data = torrent.export()
+ torrent_info, err = TorInfo.from_data(torrent_data)
+ except Exception as e:
+ err = str(e)
+ if not torrent_info:
+ logger.error(f"尝试远程导出种子 {hash_str} 出错 {err}")
+ continue
+ else:
+ logger.error(f"种子文件不存在:{torrent_path}")
+ continue
+
+ # 读取种子文件具体信息
+ if not torrent_info:
+ torrent_info, err = self.cross_helper.get_local_torrent_info(torrent_path)
+ if not torrent_info:
+ logger.error(f"未能读取到种子文件具体信息:{torrent_path} {err}")
+ continue
+
+ # 用站点+pieces_hash记录该站点是否已经在该下载器中,需要从tracker补充站点名字
+ tracker_urls = set()
+ try:
+ if downloader == "qbittorrent":
+ for i in torrent.trackers:
+ if "https" in i.get("url"):
+ tracker_urls.add(i.get("url"))
+ elif downloader == "transmission":
+ if torrent_info and torrent_info.torrent_announce:
+ if "https" in torrent_info.torrent_announce:
+ tracker_urls.add(torrent_info.torrent_announce)
+ except Exception as err:
+ logger.warn(f"尝试获取 {downloader} 的tracker出错 {err}")
+ # 根据tracker补充站点信息
+ for tracker in tracker_urls:
+ # 优先通过passkey获取站点名
+ for site_config in self._site_cs_infos:
+ if site_config.passkey in tracker:
+ torrent_info.site_name = site_config.name
+ break
+ if not torrent_info.site_name:
+ # 尝试通过域名获取站点信息
+ tracker_domain = StringUtils.get_url_domain(tracker)
+ site_info = self.sites.get_indexer(tracker_domain)
+ if site_info:
+ torrent_info.site_name = site_info.get("name")
+
+ 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)
+ if torrent_labels and 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
+ hash_strs.append({
+ "hash": hash_str,
+ "save_path": save_path,
+ "torrent_info": torrent_info
+ })
+ if hash_strs:
+ self.__seed_torrents(hash_strs=hash_strs, downloader=downloader)
+ # 触发校验检查
+ self.check_recheck()
+ else:
+ logger.info(f"没有需要辅种的种子")
+ # 保存缓存
+ self.__update_config()
+ # 发送消息
+ if self._notify:
+ if self.success or self.fail:
+ self.post_message(
+ mtype=NotificationType.SiteMessage,
+ title="【青蛙辅种助手辅种任务完成】",
+ text=f"服务器返回可辅种总数:{self.total}\n"
+ f"实际可辅种数:{self.realtotal}\n"
+ f"已存在:{self.exist}\n"
+ f"成功:{self.success}\n"
+ f"失败:{self.fail}\n"
+ f"{self.cached} 条失败记录已加入缓存"
+ )
+ logger.info("辅种任务执行完成")
+
+ def check_recheck(self):
+ """
+ 定时检查下载器中种子是否校验完成,校验完成且完整的自动开始辅种
+ """
+ if not self._recheck_torrents:
+ return
+ if self._is_recheck_running:
+ return
+ self._is_recheck_running = True
+ for downloader in self._downloaders:
+ # 需要检查的种子
+ recheck_torrents = self._recheck_torrents.get(downloader) or []
+ if not recheck_torrents:
+ continue
+ logger.info(f"开始检查下载器 {downloader} 的校验任务 ...")
+ # 下载器
+ 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)))
+ elif torrents is None:
+ logger.info(f"下载器 {downloader} 查询校验任务失败,将在下次继续查询 ...")
+ continue
+ else:
+ logger.info(f"下载器 {downloader} 中没有需要检查的校验任务,清空待处理列表 ...")
+ self._recheck_torrents[downloader] = []
+ self._is_recheck_running = False
+
+ def __seed_torrents(self, hash_strs: list, downloader: str):
+ """
+ 执行所有种子的辅种
+ """
+ if not hash_strs:
+ return
+ logger.info(f"下载器 {downloader} 开始查询辅种,种子总数量:{len(hash_strs)} ...")
+
+ # 每个Hash的保存目录
+ save_paths = {}
+ pieces_hash_set = set()
+ site_pieces_hash_set = set()
+ for item in hash_strs:
+ tor_info: TorInfo = item.get("torrent_info")
+ save_paths[tor_info.pieces_hash] = item.get("save_path")
+ pieces_hash_set.add(tor_info.pieces_hash)
+ if tor_info.site_name:
+ site_pieces_hash_set.add(tor_info.get_name_pieces_tag())
+
+ logger.info(f"去重后,总共需要辅种查询的种子数:{len(pieces_hash_set)}")
+ pieces_hashes = list(pieces_hash_set)
+
+ # 分站点逐个批次辅种
+ # 逐个站点查询可辅种数据
+ chunk_size = 100
+ for site_config in self._site_cs_infos:
+ # 检查站点是否已经停用
+ db_site = self.siteoper.get(site_config.id)
+ if db_site and not db_site.is_active:
+ logger.info(f"站点{site_config.name}已停用,跳过辅种")
+ continue
+ remote_tors: List[TorInfo] = []
+ total_size = len(pieces_hashes)
+ for i in range(0, len(pieces_hashes), chunk_size):
+ if self._event.is_set():
+ logger.info(f"辅种服务停止")
+ return
+ # 切片操作
+ chunk = pieces_hashes[i:i + chunk_size]
+ # 处理分组
+ chunk_tors, err_msg = self.cross_helper.get_target_torrent(site_config, chunk)
+ if not chunk_tors and err_msg:
+ logger.info(
+ f"查询站点{site_config.name}可辅种的信息出错 {err_msg},进度={i + 1}/{total_size}"
+ )
+ else:
+ logger.info(
+ f"站点{site_config.name}本批次的可辅种/查询数={len(chunk_tors)}/{len(chunk)},进度={i + 1}/{total_size}"
+ )
+ remote_tors = remote_tors + chunk_tors
+
+ logger.info(f"站点{site_config.name}返回可以辅种的种子总数为{len(remote_tors)}")
+
+ # 去除已经下载过的种子
+ local_cnt = 0
+ not_local_tors = []
+ for tor_info in remote_tors:
+ if (
+ tor_info
+ and tor_info.site_name
+ and tor_info.pieces_hash
+ and tor_info.get_name_pieces_tag() in site_pieces_hash_set
+ ):
+ local_cnt = local_cnt + 1
+ else:
+ not_local_tors.append(tor_info)
+ logger.info(f"站点{site_config.name}正在做种或已经辅种过的种子数为{local_cnt}")
+
+ for tor_info in not_local_tors:
+ if self._event.is_set():
+ logger.info(f"辅种服务停止")
+ return
+ if not tor_info:
+ continue
+ if not tor_info.torrent_id or not tor_info.pieces_hash:
+ continue
+ if tor_info.get_name_id_tag() in self._success_caches:
+ logger.info(f"{tor_info.get_name_id_tag()} 已处理过辅种,跳过 ...")
+ continue
+ if tor_info.get_name_id_tag() in self._error_caches or tor_info.get_name_id_tag() in self._permanent_error_caches:
+ logger.info(f"种子 {tor_info.get_name_id_tag()} 辅种失败且已缓存,跳过 ...")
+ continue
+ # 添加任务
+ self.__download_torrent(tor=tor_info, site_config=site_config,
+ downloader=downloader,
+ save_path=save_paths.get(tor_info.pieces_hash))
+
+ logger.info(f"下载器 {downloader} 辅种完成")
+
+ def __download(self, downloader: str, content: Union[bytes, str],
+ 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 __download_torrent(
+ self,
+ tor: TorInfo,
+ site_config: CSSiteConfig,
+ downloader: str,
+ save_path: str,
+ ):
+ """
+ 下载种子
+
+ """
+ self.total += 1
+ self.realtotal += 1
+
+ # 下载种子
+ torrent_url = site_config.get_torrent_url(tor.torrent_id)
+
+ # 下载种子文件
+ _, content, _, _, error_msg = self.torrent.download_torrent(
+ url=torrent_url,
+ cookie=site_config.cookie,
+ ua=site_config.ua or settings.USER_AGENT,
+ proxy=True if site_config.proxy else False)
+
+ # 兼容种子无法访问的情况
+ if not content or (isinstance(content, bytes) and "你没有该权限".encode(encoding="utf-8") in content):
+ # 下载失败
+ self.fail += 1
+ self.cached += 1
+ # 加入失败缓存
+ if error_msg and ('无法打开链接' in error_msg or '触发站点流控' in error_msg):
+ self._error_caches.append(tor.get_name_id_tag())
+ else:
+ # 种子不存在的情况
+ self._permanent_error_caches.append(tor.get_name_id_tag())
+ logger.error(f"下载种子文件失败:{tor.get_name_id_tag()}")
+ return False
+
+ # 添加任务前查询校验一次,避免重复添加,导致暂停的任务被重新开始
+ tmp_tor_info, err_msg = TorInfo.from_data(content)
+ if tmp_tor_info and tmp_tor_info.info_hash:
+ tors, msg = self.__get_downloader(downloader).get_torrents(ids=[tmp_tor_info.info_hash])
+ if tors:
+ self.exist += 1
+ self._success_caches.append(tor.get_name_id_tag())
+ logger.info(f"下载的种子{tor.get_name_id_tag()}已存在, 跳过")
+ return True
+ else:
+ logger.warn(f"获取下载种子的信息出错{err_msg},不能检查该种子是否已暂停")
+
+ # 添加下载,辅种任务默认暂停
+ logger.info(f"添加下载任务:{tor.get_name_id_tag()} ...")
+ download_id = self.__download(downloader=downloader,
+ content=content,
+ save_path=save_path)
+ if not download_id:
+ # 下载失败
+ self.fail += 1
+ self.cached += 1
+ # 加入失败缓存
+ self._error_caches.append(tor.get_name_id_tag())
+ return False
+ else:
+ self.success += 1
+ # 追加校验任务
+ logger.info(f"添加校验检查任务:{download_id} ...")
+ if not self._recheck_torrents.get(downloader):
+ self._recheck_torrents[downloader] = []
+ self._recheck_torrents[downloader].append(download_id)
+ # 下载成功
+ logger.info(f"成功添加辅种下载,站点种子:{tor.get_name_id_tag()}")
+ # TR会自动校验
+ if downloader == "qbittorrent":
+ # 开始校验种子
+ self.__get_downloader(downloader).recheck_torrents(ids=[download_id])
+ # 成功也加入缓存,有一些改了路径校验不通过的,手动删除后,下一次又会辅上
+ self._success_caches.append(tor.get_name_id_tag())
+ return True
+
+ @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 __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 __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 ""
+
+ 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 __custom_sites(self) -> List[Any]:
+ custom_sites = []
+ custom_sites_config = self.get_config("CustomSites")
+ if custom_sites_config and custom_sites_config.get("enabled"):
+ custom_sites = custom_sites_config.get("sites")
+ return custom_sites
+
+ @eventmanager.register(EventType.SiteDeleted)
+ def site_deleted(self, event):
+ """
+ 删除对应站点选中
+ """
+ site_id = event.event_data.get("site_id")
+ config = self.get_config()
+ if config:
+ sites = config.get("sites")
+ if sites:
+ if isinstance(sites, str):
+ sites = [sites]
+
+ # 删除对应站点
+ if site_id:
+ sites = [site for site in sites if int(site) != int(site_id)]
+ else:
+ # 清空
+ sites = []
+
+ # 若无站点,则停止
+ if len(sites) == 0:
+ self._enabled = False
+
+ self._sites = sites
+ # 保存配置
+ self.__update_config()
diff --git a/plugins.v2/diagparamadjust/__init__.py b/plugins.v2/diagparamadjust/__init__.py
new file mode 100644
index 0000000..791504f
--- /dev/null
+++ b/plugins.v2/diagparamadjust/__init__.py
@@ -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))
diff --git a/plugins.v2/downloadsitetag/__init__.py b/plugins.v2/downloadsitetag/__init__.py
new file mode 100644
index 0000000..6c473e2
--- /dev/null
+++ b/plugins.v2/downloadsitetag/__init__.py
@@ -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)
+ # 分类, 如果勾选开关的话
因允许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)
+ # 设置分类
+ 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)
+ # 分类, 如果勾选开关的话
+ 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))
diff --git a/plugins.v2/episodegroupmeta/__init__.py b/plugins.v2/episodegroupmeta/__init__.py
new file mode 100644
index 0000000..7a3be26
--- /dev/null
+++ b/plugins.v2/episodegroupmeta/__init__.py
@@ -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
diff --git a/plugins.v2/ffmpegthumb/__init__.py b/plugins.v2/ffmpegthumb/__init__.py
new file mode 100644
index 0000000..b12454e
--- /dev/null
+++ b/plugins.v2/ffmpegthumb/__init__.py
@@ -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))
diff --git a/plugins.v2/ffmpegthumb/ffmpeg_helper.py b/plugins.v2/ffmpegthumb/ffmpeg_helper.py
new file mode 100644
index 0000000..d4ee67c
--- /dev/null
+++ b/plugins.v2/ffmpegthumb/ffmpeg_helper.py
@@ -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
diff --git a/plugins.v2/iyuuautoseed/__init__.py b/plugins.v2/iyuuautoseed/__init__.py
new file mode 100644
index 0000000..64e4f2f
--- /dev/null
+++ b/plugins.v2/iyuuautoseed/__init__.py
@@ -0,0 +1,1246 @@
+import os
+import re
+from datetime import datetime, timedelta
+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 lxml import etree
+from ruamel.yaml import CommentedMap
+
+from app.core.config import settings
+from app.core.event import eventmanager
+from app.db.site_oper import SiteOper
+from app.helper.sites import SitesHelper
+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.plugins.iyuuautoseed.iyuu_helper import IyuuHelper
+from app.schemas import NotificationType
+from app.schemas.types import EventType
+from app.utils.http import RequestUtils
+from app.utils.string import StringUtils
+
+
+class IYUUAutoSeed(_PluginBase):
+ # 插件名称
+ plugin_name = "IYUU自动辅种"
+ # 插件描述
+ plugin_desc = "基于IYUU官方Api实现自动辅种。"
+ # 插件图标
+ plugin_icon = "IYUU.png"
+ # 插件版本
+ plugin_version = "1.9.5"
+ # 插件作者
+ plugin_author = "jxxghp"
+ # 作者主页
+ author_url = "https://github.com/jxxghp"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "iyuuautoseed_"
+ # 加载顺序
+ plugin_order = 17
+ # 可使用的用户级别
+ auth_level = 2
+
+ # 私有属性
+ _scheduler = None
+ iyuuhelper = None
+ qb = None
+ tr = None
+ sites = None
+ siteoper = None
+ torrent = None
+ # 开关
+ _enabled = False
+ _cron = None
+ _skipverify = False
+ _onlyonce = False
+ _token = None
+ _downloaders = []
+ _sites = []
+ _notify = False
+ _nolabels = None
+ _nopaths = None
+ _labelsafterseed = None
+ _categoryafterseed = None
+ _addhosttotag = False
+ _size = None
+ _clearcache = False
+ # 退出事件
+ _event = Event()
+ # 种子链接xpaths
+ _torrent_xpaths = [
+ "//form[contains(@action, 'download.php?id=')]/@action",
+ "//a[contains(@href, 'download.php?hash=')]/@href",
+ "//a[contains(@href, 'download.php?id=')]/@href",
+ "//a[@class='index'][contains(@href, '/dl/')]/@href",
+ ]
+ # 待校全种子hash清单
+ _recheck_torrents = {}
+ _is_recheck_running = False
+ # 辅种缓存,出错的种子不再重复辅种,可清除
+ _error_caches = []
+ # 辅种缓存,辅种成功的种子,可清除
+ _success_caches = []
+ # 辅种缓存,出错的种子不再重复辅种,且无法清除。种子被删除404等情况
+ _permanent_error_caches = []
+ # 辅种计数
+ total = 0
+ realtotal = 0
+ success = 0
+ exist = 0
+ fail = 0
+ cached = 0
+
+ def init_plugin(self, config: dict = None):
+ self.sites = SitesHelper()
+ self.siteoper = SiteOper()
+ self.torrent = TorrentHelper()
+ # 读取配置
+ if config:
+ self._enabled = config.get("enabled")
+ self._skipverify = config.get("skipverify")
+ self._onlyonce = config.get("onlyonce")
+ self._cron = config.get("cron")
+ self._token = config.get("token")
+ self._downloaders = config.get("downloaders")
+ self._sites = config.get("sites") or []
+ self._notify = config.get("notify")
+ self._nolabels = config.get("nolabels")
+ self._nopaths = config.get("nopaths")
+ self._labelsafterseed = config.get("labelsafterseed") if config.get("labelsafterseed") else "已整理,辅种"
+ self._categoryafterseed = config.get("categoryafterseed")
+ self._addhosttotag = config.get("addhosttotag")
+ self._size = float(config.get("size")) if config.get("size") else 0
+ self._clearcache = config.get("clearcache")
+ self._permanent_error_caches = [] if self._clearcache else config.get("permanent_error_caches") or []
+ self._error_caches = [] if self._clearcache else config.get("error_caches") or []
+ self._success_caches = [] if self._clearcache else config.get("success_caches") or []
+
+ # 过滤掉已删除的站点
+ all_sites = [site.id for site in self.siteoper.list_order_by_pri()] + [site.get("id") for site in
+ self.__custom_sites()]
+ self._sites = [site_id for site_id in all_sites if site_id in self._sites]
+ self.__update_config()
+
+ # 停止现有任务
+ self.stop_service()
+
+ # 启动定时任务 & 立即运行一次
+ if self.get_state() or self._onlyonce:
+ self.iyuuhelper = IyuuHelper(token=self._token)
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+ self.qb = Qbittorrent()
+ self.tr = Transmission()
+
+ if self._onlyonce:
+ logger.info(f"辅种服务启动,立即运行一次")
+ self._scheduler.add_job(self.auto_seed, 'date',
+ run_date=datetime.now(
+ tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3)
+ )
+
+ # 关闭一次性开关
+ self._onlyonce = False
+ if self._scheduler.get_jobs():
+ # 追加种子校验服务
+ self._scheduler.add_job(self.check_recheck, 'interval', minutes=3)
+ # 启动服务
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+
+ if self._clearcache:
+ # 关闭清除缓存开关
+ self._clearcache = False
+
+ if self._clearcache or self._onlyonce:
+ # 保存配置
+ self.__update_config()
+
+ def get_state(self) -> bool:
+ return True if self._enabled and self._cron and self._token 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": "IYUUAutoSeed",
+ "name": "IYUU自动辅种服务",
+ "trigger": CronTrigger.from_crontab(self._cron),
+ "func": self.auto_seed,
+ "kwargs": {}
+ }]
+ return []
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ """
+ 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
+ """
+ # 站点的可选项(内置站点 + 自定义站点)
+ customSites = self.__custom_sites()
+
+ # 站点的可选项
+ site_options = ([{"title": site.name, "value": site.id}
+ for site in self.siteoper.list_order_by_pri()]
+ + [{"title": site.get("name"), "value": site.get("id")}
+ for site in customSites])
+ 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': 'token',
+ 'label': 'IYUU Token',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'cron',
+ 'label': '执行周期',
+ 'placeholder': '0 0 0 ? *'
+ }
+ }
+ ]
+ },
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ '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': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'size',
+ 'label': '辅种体积大于(GB)',
+ 'placeholder': '只有大于该值的才辅种'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'chips': True,
+ 'multiple': True,
+ 'model': 'sites',
+ 'label': '辅种站点',
+ 'items': site_options
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'nolabels',
+ 'label': '不辅种标签',
+ 'placeholder': '使用,分隔多个标签'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'labelsafterseed',
+ 'label': '辅种后增加标签',
+ 'placeholder': '使用,分隔多个标签,不填写则默认为(已整理,辅种)'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'categoryafterseed',
+ 'label': '辅种后增加分类',
+ 'placeholder': '设置辅种的种子分类'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'nopaths',
+ 'label': '不辅种数据文件目录',
+ 'rows': 3,
+ 'placeholder': '每一行一个目录'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'addhosttotag',
+ 'label': '将站点名添加到标签中',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'skipverify',
+ 'label': '跳过校验(仅QB有效)',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'clearcache',
+ 'label': '清除缓存后运行',
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "enabled": False,
+ "skipverify": False,
+ "onlyonce": False,
+ "notify": False,
+ "clearcache": False,
+ "addhosttotag": False,
+ "cron": "",
+ "token": "",
+ "downloaders": [],
+ "sites": [],
+ "nopaths": "",
+ "nolabels": "",
+ "labelsafterseed": "",
+ "categoryafterseed": "",
+ "size": ""
+ }
+
+ def get_page(self) -> List[dict]:
+ pass
+
+ def __update_config(self):
+ self.update_config({
+ "enabled": self._enabled,
+ "skipverify": self._skipverify,
+ "onlyonce": self._onlyonce,
+ "clearcache": self._clearcache,
+ "cron": self._cron,
+ "token": self._token,
+ "downloaders": self._downloaders,
+ "sites": self._sites,
+ "notify": self._notify,
+ "nolabels": self._nolabels,
+ "nopaths": self._nopaths,
+ "labelsafterseed": self._labelsafterseed,
+ "categoryafterseed": self._categoryafterseed,
+ "addhosttotag": self._addhosttotag,
+ "size": self._size,
+ "success_caches": self._success_caches,
+ "error_caches": self._error_caches,
+ "permanent_error_caches": self._permanent_error_caches
+ })
+
+ def __get_downloader(self, dtype: str):
+ """
+ 根据类型返回下载器实例
+ """
+ if dtype == "qbittorrent":
+ return self.qb
+ elif dtype == "transmission":
+ return self.tr
+ else:
+ return None
+
+ def auto_seed(self):
+ """
+ 开始辅种
+ """
+ if not self.iyuuhelper:
+ return
+ logger.info("开始辅种任务 ...")
+
+ # 计数器初始化
+ self.total = 0
+ self.realtotal = 0
+ self.success = 0
+ self.exist = 0
+ self.fail = 0
+ self.cached = 0
+ # 扫描下载器辅种
+ for downloader in self._downloaders:
+ 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
+ hash_strs = []
+ for torrent in torrents:
+ if self._event.is_set():
+ logger.info(f"辅种服务停止")
+ return
+ # 获取种子hash
+ hash_str = self.__get_hash(torrent, downloader)
+ if hash_str in self._error_caches or hash_str in self._permanent_error_caches:
+ logger.info(f"种子 {hash_str} 辅种失败且已缓存,跳过 ...")
+ continue
+ 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)
+ if torrent_labels and 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
+
+ # 体积排除辅种
+ torrent_size = self.__get_torrent_size(torrent, downloader) / 1024 / 1024 / 1024
+ if self._size and torrent_size < self._size:
+ logger.info(f"种子 {hash_str} 大小:{torrent_size:.2f}GB,小于设定 {self._size}GB,跳过 ...")
+ continue
+
+ hash_strs.append({
+ "hash": hash_str,
+ "save_path": save_path
+ })
+ if hash_strs:
+ logger.info(f"总共需要辅种的种子数:{len(hash_strs)}")
+ # 分组处理,减少IYUU Api请求次数
+ chunk_size = 200
+ for i in range(0, len(hash_strs), chunk_size):
+ # 切片操作
+ chunk = hash_strs[i:i + chunk_size]
+ # 处理分组
+ self.__seed_torrents(hash_strs=chunk,
+ downloader=downloader)
+ # 触发校验检查
+ self.check_recheck()
+ else:
+ logger.info(f"没有需要辅种的种子")
+ # 保存缓存
+ self.__update_config()
+ # 发送消息
+ if self._notify:
+ if self.success or self.fail:
+ self.post_message(
+ mtype=NotificationType.SiteMessage,
+ title="【IYUU自动辅种任务完成】",
+ text=f"服务器返回可辅种总数:{self.total}\n"
+ f"实际可辅种数:{self.realtotal}\n"
+ f"已存在:{self.exist}\n"
+ f"成功:{self.success}\n"
+ f"失败:{self.fail}\n"
+ f"{self.cached} 条失败记录已加入缓存"
+ )
+ logger.info("辅种任务执行完成")
+
+ def check_recheck(self):
+ """
+ 定时检查下载器中种子是否校验完成,校验完成且完整的自动开始辅种
+ """
+ if not self._recheck_torrents:
+ return
+ if self._is_recheck_running:
+ return
+ self._is_recheck_running = True
+ for downloader in self._downloaders:
+ # 需要检查的种子
+ recheck_torrents = self._recheck_torrents.get(downloader) or []
+ if not recheck_torrents:
+ continue
+ logger.info(f"开始检查下载器 {downloader} 的校验任务 ...")
+ # 下载器
+ 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)))
+ elif torrents is None:
+ logger.info(f"下载器 {downloader} 查询校验任务失败,将在下次继续查询 ...")
+ continue
+ else:
+ logger.info(f"下载器 {downloader} 中没有需要检查的校验任务,清空待处理列表 ...")
+ self._recheck_torrents[downloader] = []
+ self._is_recheck_running = False
+
+ def __seed_torrents(self, hash_strs: list, downloader: str):
+ """
+ 执行一批种子的辅种
+ """
+ if not hash_strs:
+ return
+ logger.info(f"下载器 {downloader} 开始查询辅种,数量:{len(hash_strs)} ...")
+ # 下载器中的Hashs
+ hashs = [item.get("hash") for item in hash_strs]
+ # 每个Hash的保存目录
+ save_paths = {}
+ for item in hash_strs:
+ save_paths[item.get("hash")] = item.get("save_path")
+ # 查询可辅种数据
+ seed_list, msg = self.iyuuhelper.get_seed_info(hashs)
+ if not isinstance(seed_list, dict):
+ # 判断辅种异常是否是由于Token未认证导致的,由于没有解决接口,只能从返回值来判断
+ if self._token and msg == '请求缺少token':
+ logger.warn(f'IYUU辅种失败,疑似站点未绑定插件配置不完整,请先检查是否完成站点绑定!{msg}')
+ else:
+ logger.warn(f"当前种子列表没有可辅种的站点:{msg}")
+ return
+ else:
+ logger.info(f"IYUU返回可辅种数:{len(seed_list)}")
+ # 遍历
+ for current_hash, seed_info in seed_list.items():
+ if not seed_info:
+ continue
+ seed_torrents = seed_info.get("torrent")
+ if not isinstance(seed_torrents, list):
+ seed_torrents = [seed_torrents]
+
+ # 本次辅种成功的种子
+ success_torrents = []
+
+ for seed in seed_torrents:
+ if not seed:
+ continue
+ if not isinstance(seed, dict):
+ continue
+ if not seed.get("sid") or not seed.get("info_hash"):
+ continue
+ if seed.get("info_hash") in hashs:
+ logger.info(f"{seed.get('info_hash')} 已在下载器中,跳过 ...")
+ continue
+ if seed.get("info_hash") in self._success_caches:
+ logger.info(f"{seed.get('info_hash')} 已处理过辅种,跳过 ...")
+ continue
+ if seed.get("info_hash") in self._error_caches or seed.get("info_hash") in self._permanent_error_caches:
+ logger.info(f"种子 {seed.get('info_hash')} 辅种失败且已缓存,跳过 ...")
+ continue
+ # 添加任务
+ success = self.__download_torrent(seed=seed,
+ downloader=downloader,
+ save_path=save_paths.get(current_hash))
+ if success:
+ success_torrents.append(seed.get("info_hash"))
+
+ # 辅种成功的去重放入历史
+ if len(success_torrents) > 0:
+ self.__save_history(current_hash=current_hash,
+ downloader=downloader,
+ success_torrents=success_torrents)
+
+ logger.info(f"下载器 {downloader} 辅种完成")
+
+ def __save_history(self, current_hash: str, downloader: str, success_torrents: []):
+ """
+ [
+ {
+ "downloader":"2",
+ "torrents":[
+ "248103a801762a66c201f39df7ea325f8eda521b",
+ "bd13835c16a5865b01490962a90b3ec48889c1f0"
+ ]
+ },
+ {
+ "downloader":"3",
+ "torrents":[
+ "248103a801762a66c201f39df7ea325f8eda521b",
+ "bd13835c16a5865b01490962a90b3ec48889c1f0"
+ ]
+ }
+ ]
+ """
+ try:
+ # 查询当前Hash的辅种历史
+ seed_history = self.get_data(key=current_hash) or []
+
+ new_history = True
+ if len(seed_history) > 0:
+ for history in seed_history:
+ if not history:
+ continue
+ if not isinstance(history, dict):
+ continue
+ if not history.get("downloader"):
+ continue
+ # 如果本次辅种下载器之前有过记录则继续添加
+ if str(history.get("downloader")) == downloader:
+ history_torrents = history.get("torrents") or []
+ history["torrents"] = list(set(history_torrents + success_torrents))
+ new_history = False
+ break
+
+ # 本次辅种下载器之前没有成功记录则新增
+ if new_history:
+ seed_history.append({
+ "downloader": downloader,
+ "torrents": list(set(success_torrents))
+ })
+
+ # 保存历史
+ self.save_data(key=current_hash,
+ value=seed_history)
+ except Exception as e:
+ print(str(e))
+
+ def __download(self, downloader: str, content: bytes,
+ save_path: str, site_name: str) -> Optional[str]:
+
+ torrent_tags = self._labelsafterseed.split(',')
+
+ # 辅种 tag 叠加站点名
+ if self._addhosttotag:
+ torrent_tags.append(site_name)
+
+ """
+ 添加下载任务
+ """
+ if downloader == "qbittorrent":
+ # 生成随机Tag
+ tag = StringUtils.generate_random_str(10)
+
+ torrent_tags.append(tag)
+
+ state = self.qb.add_torrent(content=content,
+ download_dir=save_path,
+ is_paused=True,
+ tag=torrent_tags,
+ category=self._categoryafterseed,
+ is_skip_checking=self._skipverify)
+ 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=torrent_tags)
+ if not torrent:
+ return None
+ else:
+ return torrent.hashString
+
+ logger.error(f"不支持的下载器:{downloader}")
+ return None
+
+ def __download_torrent(self, seed: dict, downloader: str, save_path: str):
+ """
+ 下载种子
+ torrent: {
+ "sid": 3,
+ "torrent_id": 377467,
+ "info_hash": "a444850638e7a6f6220e2efdde94099c53358159"
+ }
+ """
+
+ def __is_special_site(url):
+ """
+ 判断是否为特殊站点(是否需要添加https)
+ """
+ if "hdsky.me" in url:
+ return False
+ return True
+
+ self.total += 1
+ # 获取种子站点及下载地址模板
+ site_url, download_page = self.iyuuhelper.get_torrent_url(seed.get("sid"))
+ if not site_url or not download_page:
+ # 加入缓存
+ self._error_caches.append(seed.get("info_hash"))
+ self.fail += 1
+ self.cached += 1
+ return False
+ # 查询站点
+ site_domain = StringUtils.get_url_domain(site_url)
+ # 站点信息
+ site_info = self.sites.get_indexer(site_domain)
+ if not site_info or not site_info.get('url'):
+ logger.debug(f"没有维护种子对应的站点:{site_url}")
+ return False
+ if self._sites and site_info.get('id') not in self._sites:
+ logger.info("当前站点不在选择的辅种站点范围,跳过 ...")
+ return False
+ self.realtotal += 1
+ # 查询hash值是否已经在下载器中
+ downloader_obj = self.__get_downloader(downloader)
+ torrent_info, _ = downloader_obj.get_torrents(ids=[seed.get("info_hash")])
+ if torrent_info:
+ logger.info(f"{seed.get('info_hash')} 已在下载器中,跳过 ...")
+ self.exist += 1
+ return False
+ # 站点流控
+ check, checkmsg = self.sites.check(site_domain)
+ if check:
+ logger.warn(checkmsg)
+ self.fail += 1
+ return False
+ # 下载种子
+ torrent_url = self.__get_download_url(seed=seed,
+ site=site_info,
+ base_url=download_page)
+ if not torrent_url:
+ # 加入失败缓存
+ self._error_caches.append(seed.get("info_hash"))
+ self.fail += 1
+ self.cached += 1
+ return False
+ # 强制使用Https
+ if __is_special_site(torrent_url):
+ if "?" in torrent_url:
+ torrent_url += "&https=1"
+ else:
+ torrent_url += "?https=1"
+ # 下载种子文件
+ _, content, _, _, error_msg = self.torrent.download_torrent(
+ url=torrent_url,
+ cookie=site_info.get("cookie"),
+ ua=site_info.get("ua") or settings.USER_AGENT,
+ proxy=site_info.get("proxy"))
+ if not content:
+ # 下载失败
+ self.fail += 1
+ # 加入失败缓存
+ if error_msg and ('无法打开链接' in error_msg or '触发站点流控' in error_msg):
+ self._error_caches.append(seed.get("info_hash"))
+ else:
+ # 种子不存在的情况
+ self._permanent_error_caches.append(seed.get("info_hash"))
+ logger.error(f"下载种子文件失败:{torrent_url}")
+ return False
+ # 添加下载,辅种任务默认暂停
+ logger.info(f"添加下载任务:{torrent_url} ...")
+ download_id = self.__download(downloader=downloader,
+ content=content,
+ save_path=save_path,
+ site_name=site_info.get("name"))
+ if not download_id:
+ # 下载失败
+ self.fail += 1
+ # 加入失败缓存
+ self._error_caches.append(seed.get("info_hash"))
+ return False
+ else:
+ self.success += 1
+ if self._skipverify:
+ # 跳过校验
+ logger.info(f"{download_id} 跳过校验,请自行检查...")
+ # 请注意这里是故意不自动开始的
+ # 跳过校验存在直接失败、种子目录相同文件不同等异常情况
+ # 必须要用户自行二次确认之后才能开始做种
+ # 否则会出现反复下载刷掉分享率、做假种的情况
+ else:
+ # 追加校验任务
+ logger.info(f"添加校验检查任务:{download_id} ...")
+ if not self._recheck_torrents.get(downloader):
+ self._recheck_torrents[downloader] = []
+ self._recheck_torrents[downloader].append(download_id)
+ # TR会自动校验
+ if downloader == "qbittorrent":
+ # 开始校验种子
+ downloader_obj.recheck_torrents(ids=[download_id])
+ # 下载成功
+ logger.info(f"成功添加辅种下载,站点:{site_info.get('name')},种子链接:{torrent_url}")
+ # 成功也加入缓存,有一些改了路径校验不通过的,手动删除后,下一次又会辅上
+ self._success_caches.append(seed.get("info_hash"))
+ return True
+
+ @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 __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 __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 __get_torrent_size(torrent: Any, dl_type: str):
+ """
+ 获取种子大小 int bytes
+ """
+ try:
+ return torrent.get("total_size") if dl_type == "qbittorrent" else torrent.total_size
+ except Exception as e:
+ print(str(e))
+ return ""
+
+ def __get_download_url(self, seed: dict, site: CommentedMap, base_url: str):
+ """
+ 拼装种子下载链接
+ """
+
+ def __is_mteam(url: str):
+ """
+ 判断是否为mteam站点
+ """
+ return True if "m-team." in url else False
+
+ def __is_monika(url: str):
+ """
+ 判断是否为monika站点
+ """
+ return True if "monikadesign." in url else False
+
+ def __get_mteam_enclosure(tid: str, apikey: str):
+ """
+ 获取mteam种子下载链接
+ """
+ if not apikey:
+ logger.error("m-team站点的apikey未配置")
+ return None
+
+ """
+ 将mteam种子下载链接域名替换为使用API
+ """
+ api_url = re.sub(r'//[^/]+\.m-team', '//api.m-team', site.get('url'))
+
+ res = RequestUtils(
+ headers={
+ 'Content-Type': 'application/json',
+ 'User-Agent': f'{site.get("ua")}',
+ 'Accept': 'application/json, text/plain, */*',
+ 'x-api-key': apikey
+ }
+ ).post_res(f"{api_url}api/torrent/genDlToken", params={
+ 'id': tid
+ })
+ if not res:
+ logger.warn(f"m-team 获取种子下载链接失败:{tid}")
+ return None
+ return res.json().get("data")
+
+ def __get_monika_torrent(tid: str, rssurl: str):
+ """
+ Monika下载需要使用rsskey从站点配置中获取并拼接下载链接
+ """
+ if not rssurl:
+ logger.error("Monika站点的rss链接未配置")
+ return None
+
+ rss_match = re.search(r'/rss/\d+\.(\w+)', rssurl)
+ rsskey = rss_match.group(1)
+ download_url = f"{site.get('url')}torrents/download/{tid}.{rsskey}"
+ return download_url
+
+ def __is_special_site(url: str):
+ """
+ 判断是否为特殊站点
+ """
+ spec_params = ["hash=", "authkey="]
+ if any(field in base_url for field in spec_params):
+ return True
+ if "hdchina.org" in url:
+ return True
+ if "hdsky.me" in url:
+ return True
+ if "hdcity.in" in url:
+ return True
+ if "totheglory.im" in url:
+ return True
+ return False
+
+ try:
+ if __is_mteam(site.get('url')):
+ # 调用mteam接口获取下载链接
+ return __get_mteam_enclosure(tid=seed.get("torrent_id"), apikey=site.get("apikey"))
+ if __is_monika(site.get('url')):
+ # 返回种子id和站点配置中所Monika的rss链接
+ return __get_monika_torrent(tid=seed.get("torrent_id"), rssurl=site.get("rss"))
+ elif __is_special_site(site.get('url')):
+ # 从详情页面获取下载链接
+ return self.__get_torrent_url_from_page(seed=seed, site=site)
+ else:
+ download_url = base_url.replace(
+ "id={}",
+ "id={id}"
+ ).replace(
+ "/{}",
+ "/{id}"
+ ).replace(
+ "/{torrent_key}",
+ ""
+ ).format(
+ **{
+ "id": seed.get("torrent_id"),
+ "passkey": site.get("passkey") or '',
+ "uid": site.get("uid") or '',
+ }
+ )
+ if download_url.count("{"):
+ logger.warn(f"当前不支持该站点的辅助任务,Url转换失败:{seed}")
+ return None
+ download_url = re.sub(r"[&?]passkey=", "",
+ re.sub(r"[&?]uid=", "",
+ download_url,
+ flags=re.IGNORECASE),
+ flags=re.IGNORECASE)
+ return f"{site.get('url')}{download_url}"
+ except Exception as e:
+ logger.warn(
+ f"{site.get('name')} Url转换失败,{str(e)}:site_url={site.get('url')},base_url={base_url}, seed={seed}")
+ return self.__get_torrent_url_from_page(seed=seed, site=site)
+
+ def __get_torrent_url_from_page(self, seed: dict, site: dict):
+ """
+ 从详情页面获取下载链接
+ """
+ if not site.get('url'):
+ logger.warn(f"站点 {site.get('name')} 未获取站点地址,无法获取种子下载链接")
+ return None
+ try:
+ page_url = f"{site.get('url')}details.php?id={seed.get('torrent_id')}&hit=1"
+ logger.info(f"正在获取种子下载链接:{page_url} ...")
+ res = RequestUtils(
+ cookies=site.get("cookie"),
+ ua=site.get("ua"),
+ proxies=settings.PROXY if site.get("proxy") else None
+ ).get_res(url=page_url)
+ if res is not None and res.status_code in (200, 500):
+ if "charset=utf-8" in res.text or "charset=UTF-8" in res.text:
+ res.encoding = "UTF-8"
+ else:
+ res.encoding = res.apparent_encoding
+ if not res.text:
+ logger.warn(f"获取种子下载链接失败,页面内容为空:{page_url}")
+ return None
+ # 使用xpath从页面中获取下载链接
+ html = etree.HTML(res.text)
+ for xpath in self._torrent_xpaths:
+ download_url = html.xpath(xpath)
+ if download_url:
+ download_url = download_url[0]
+ logger.info(f"获取种子下载链接成功:{download_url}")
+ if not download_url.startswith("http"):
+ if download_url.startswith("/"):
+ download_url = f"{site.get('url')}{download_url[1:]}"
+ else:
+ download_url = f"{site.get('url')}{download_url}"
+ return download_url
+ logger.warn(f"获取种子下载链接失败,未找到下载链接:{page_url}")
+ return None
+ else:
+ logger.error(f"获取种子下载链接失败,请求失败:{page_url},{res.status_code if res else ''}")
+ return None
+ except Exception as e:
+ logger.warn(f"获取种子下载链接失败:{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))
+
+ def __custom_sites(self) -> List[Any]:
+ custom_sites = []
+ custom_sites_config = self.get_config("CustomSites")
+ if custom_sites_config and custom_sites_config.get("enabled"):
+ custom_sites = custom_sites_config.get("sites")
+ return custom_sites
+
+ @eventmanager.register(EventType.SiteDeleted)
+ def site_deleted(self, event):
+ """
+ 删除对应站点选中
+ """
+ site_id = event.event_data.get("site_id")
+ config = self.get_config()
+ if config:
+ sites = config.get("sites")
+ if sites:
+ if isinstance(sites, str):
+ sites = [sites]
+
+ # 删除对应站点
+ if site_id:
+ sites = [site for site in sites if int(site) != int(site_id)]
+ else:
+ # 清空
+ sites = []
+
+ # 若无站点,则停止
+ if len(sites) == 0:
+ self._enabled = False
+
+ self._sites = sites
+ # 保存配置
+ self.__update_config()
diff --git a/plugins.v2/iyuuautoseed/iyuu_helper.py b/plugins.v2/iyuuautoseed/iyuu_helper.py
new file mode 100644
index 0000000..7fd1a70
--- /dev/null
+++ b/plugins.v2/iyuuautoseed/iyuu_helper.py
@@ -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()
diff --git a/plugins.v2/libraryscraper/__init__.py b/plugins.v2/libraryscraper/__init__.py
new file mode 100644
index 0000000..be18407
--- /dev/null
+++ b/plugins.v2/libraryscraper/__init__.py
@@ -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))
diff --git a/plugins.v2/mediaservermsg/__init__.py b/plugins.v2/mediaservermsg/__init__.py
new file mode 100644
index 0000000..315f1d1
--- /dev/null
+++ b/plugins.v2/mediaservermsg/__init__.py
@@ -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
diff --git a/plugins.v2/mediaserverrefresh/__init__.py b/plugins.v2/mediaserverrefresh/__init__.py
new file mode 100644
index 0000000..cc6578b
--- /dev/null
+++ b/plugins.v2/mediaserverrefresh/__init__.py
@@ -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
diff --git a/plugins.v2/mediasyncdel/__init__.py b/plugins.v2/mediasyncdel/__init__.py
new file mode 100644
index 0000000..41cb858
--- /dev/null
+++ b/plugins.v2/mediasyncdel/__init__.py
@@ -0,0 +1,1589 @@
+import datetime
+import json
+import os
+import re
+import time
+from pathlib import Path
+from typing import List, Tuple, Dict, Any, Optional
+
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.triggers.cron import CronTrigger
+
+from app import schemas
+from app.chain.transfer import TransferChain
+from app.core.config import settings
+from app.core.event import eventmanager, Event
+from app.db.models.transferhistory import TransferHistory
+from app.log import logger
+from app.modules.emby import Emby
+from app.modules.jellyfin import Jellyfin
+from app.plugins import _PluginBase
+from app.schemas.types import NotificationType, EventType, MediaType, MediaImageType
+
+
+class MediaSyncDel(_PluginBase):
+ # 插件名称
+ plugin_name = "媒体文件同步删除"
+ # 插件描述
+ plugin_desc = "同步删除历史记录、源文件和下载任务。"
+ # 插件图标
+ plugin_icon = "mediasyncdel.png"
+ # 插件版本
+ plugin_version = "1.7"
+ # 插件作者
+ plugin_author = "thsrite"
+ # 作者主页
+ author_url = "https://github.com/thsrite"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "mediasyncdel_"
+ # 加载顺序
+ plugin_order = 9
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 私有属性
+ _scheduler: Optional[BackgroundScheduler] = None
+ _enabled = False
+ _sync_type: str = ""
+ _cron: str = ""
+ _notify = False
+ _del_source = False
+ _del_history = False
+ _exclude_path = None
+ _library_path = None
+ _transferchain = None
+ _transferhis = None
+ _downloadhis = None
+
+ def init_plugin(self, config: dict = None):
+ self._transferchain = TransferChain()
+ self._transferhis = self._transferchain.transferhis
+ self._downloadhis = self._transferchain.downloadhis
+
+ # 停止现有任务
+ self.stop_service()
+
+ # 读取配置
+ if config:
+ self._enabled = config.get("enabled")
+ self._sync_type = config.get("sync_type")
+ self._cron = config.get("cron")
+ self._notify = config.get("notify")
+ self._del_source = config.get("del_source")
+ self._del_history = config.get("del_history")
+ self._exclude_path = config.get("exclude_path")
+ self._library_path = config.get("library_path")
+
+ # 清理插件历史
+ if self._del_history:
+ self.del_data(key="history")
+ self.update_config({
+ "enabled": self._enabled,
+ "sync_type": self._sync_type,
+ "cron": self._cron,
+ "notify": self._notify,
+ "del_source": self._del_source,
+ "del_history": False,
+ "exclude_path": self._exclude_path,
+ "library_path": self._library_path
+ })
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ """
+ 定义远程控制命令
+ :return: 命令关键字、事件、描述、附带数据
+ """
+ pass
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ return [
+ {
+ "path": "/delete_history",
+ "endpoint": self.delete_history,
+ "methods": ["GET"],
+ "summary": "删除订阅历史记录"
+ }
+ ]
+
+ 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("unique") != key]
+ self.save_data('history', historys)
+ return schemas.Response(success=True, message="删除成功")
+
+ 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 str(self._sync_type) == "log":
+ # 媒体库同步删除日志方式
+ if self._cron:
+ return [{
+ "id": "MediaSyncDel",
+ "name": "媒体库同步删除服务",
+ "trigger": CronTrigger.from_crontab(self._cron),
+ "func": self.sync_del_by_log,
+ "kwargs": {}
+ }]
+ else:
+ return [{
+ "id": "MediaSyncDel",
+ "name": "媒体库同步删除服务",
+ "trigger": "interval",
+ "func": self.sync_del_by_log,
+ "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': 'del_source',
+ 'label': '删除源文件',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 3
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'del_history',
+ 'label': '删除历史',
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'model': 'sync_type',
+ 'label': '媒体库同步方式',
+ 'items': [
+ {'title': 'Webhook', 'value': 'webhook'},
+ {'title': '日志', 'value': 'log'},
+ {'title': 'Scripter X', 'value': 'plugin'}
+ ]
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'cron',
+ 'label': '日志检查周期',
+ 'placeholder': '5位cron表达式,留空自动'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'exclude_path',
+ 'label': '排除路径'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'library_path',
+ 'rows': '2',
+ 'label': '媒体库路径映射',
+ 'placeholder': '媒体服务器路径:MoviePilot路径(一行一个)'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '媒体库同步方式分为Webhook、日志同步和Scripter X:'
+ '1、Webhook需要Emby4.8.0.45及以上开启媒体删除的Webhook。'
+ '2、日志同步需要配置检查周期,默认30分钟执行一次。'
+ '3、Scripter X方式需要emby安装并配置Scripter X插件,无需配置执行周期。'
+ '4、启用该插件后,非媒体服务器触发的源文件删除,也会同步处理下载器中的下载任务。'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '关于路径映射(转移后文件路径):'
+ 'emby:/data/A.mp4,'
+ 'moviepilot:/mnt/link/A.mp4。'
+ '路径映射填/data:/mnt/link。'
+ '不正确配置会导致查询不到转移记录!(路径一样可不填)'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ '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': 'Scripter X配置文档:'
+ 'https://github.com/thsrite/'
+ 'MediaSyncDel/blob/main/MoviePilot/MoviePilot.md'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '路径映射配置文档:'
+ 'https://github.com/thsrite/MediaSyncDel/blob/main/path.md'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "enabled": False,
+ "notify": True,
+ "del_source": False,
+ "del_history": False,
+ "library_path": "",
+ "sync_type": "webhook",
+ "cron": "*/30 * * * *",
+ "exclude_path": "",
+ }
+
+ 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")
+ unique = history.get("unique")
+ 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": "VDialogCloseBtn",
+ "props": {
+ 'innerClass': 'absolute top-0 right-0',
+ },
+ 'events': {
+ 'click': {
+ 'api': 'plugin/MediaSyncDel/delete_history',
+ 'method': 'get',
+ 'params': {
+ 'key': unique,
+ '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': 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
+ }
+ ]
+
+ @eventmanager.register(EventType.WebhookMessage)
+ def sync_del_by_webhook(self, event: Event):
+ """
+ emby删除媒体库同步删除历史记录
+ webhook
+ """
+ if not self._enabled or str(self._sync_type) != "webhook":
+ return
+
+ event_data = event.event_data
+ event_type = event_data.event
+
+ # Emby Webhook event_type = library.deleted
+ if not event_type or str(event_type) != 'library.deleted':
+ return
+
+ # 媒体类型
+ media_type = event_data.media_type
+ # 媒体名称
+ media_name = event_data.item_name
+ # 媒体路径
+ media_path = event_data.item_path
+ # tmdb_id
+ tmdb_id = event_data.tmdb_id
+ # 季数
+ season_num = event_data.season_id
+ # 集数
+ episode_num = event_data.episode_id
+
+ """
+ 执行删除逻辑
+ """
+ if self._exclude_path and media_path and any(
+ os.path.abspath(media_path).startswith(os.path.abspath(path)) for path in
+ self._exclude_path.split(",")):
+ logger.info(f"媒体路径 {media_path} 已被排除,暂不处理")
+ # 发送消息通知网盘删除插件删除网盘资源
+ self.eventmanager.send_event(EventType.PluginAction,
+ {
+ "action": "networkdisk_del",
+ "media_path": media_path,
+ "media_name": media_name,
+ "tmdb_id": tmdb_id,
+ "media_type": media_type,
+ "season_num": season_num,
+ "episode_num": episode_num,
+ })
+ return
+
+ # 兼容emby webhook season删除没有发送tmdbid
+ if not tmdb_id and str(media_type) != 'Season':
+ logger.error(f"{media_name} 同步删除失败,未获取到TMDB ID,请检查媒体库媒体是否刮削")
+ return
+
+ self.__sync_del(media_type=media_type,
+ media_name=media_name,
+ media_path=media_path,
+ tmdb_id=tmdb_id,
+ season_num=season_num,
+ episode_num=episode_num)
+
+ @eventmanager.register(EventType.WebhookMessage)
+ def sync_del_by_plugin(self, event):
+ """
+ emby删除媒体库同步删除历史记录
+ Scripter X插件
+ """
+ if not self._enabled or str(self._sync_type) != "plugin":
+ return
+
+ event_data = event.event_data
+ event_type = event_data.event
+
+ # Scripter X插件 event_type = media_del
+ if not event_type or str(event_type) != 'media_del':
+ return
+
+ # Scripter X插件 需要是否虚拟标识
+ item_isvirtual = event_data.item_isvirtual
+ if not item_isvirtual:
+ logger.error("Scripter X插件方式,item_isvirtual参数未配置,为防止误删除,暂停插件运行")
+ self.update_config({
+ "enabled": False,
+ "del_source": self._del_source,
+ "exclude_path": self._exclude_path,
+ "library_path": self._library_path,
+ "notify": self._notify,
+ "cron": self._cron,
+ "sync_type": self._sync_type,
+ })
+ return
+
+ # 如果是虚拟item,则直接return,不进行删除
+ if item_isvirtual == 'True':
+ return
+
+ # 媒体类型
+ media_type = event_data.item_type
+ # 媒体名称
+ media_name = event_data.item_name
+ # 媒体路径
+ media_path = event_data.item_path
+ # tmdb_id
+ tmdb_id = event_data.tmdb_id
+ # 季数
+ season_num = event_data.season_id
+ # 集数
+ episode_num = event_data.episode_id
+
+ """
+ 执行删除逻辑
+ """
+ if self._exclude_path and media_path and any(
+ os.path.abspath(media_path).startswith(os.path.abspath(path)) for path in
+ self._exclude_path.split(",")):
+ logger.info(f"媒体路径 {media_path} 已被排除,暂不处理")
+ # 发送消息通知网盘删除插件删除网盘资源
+ self.eventmanager.send_event(EventType.PluginAction,
+ {
+ "action": "networkdisk_del",
+ "media_path": media_path,
+ "media_name": media_name,
+ "tmdb_id": tmdb_id,
+ "media_type": media_type,
+ "season_num": season_num,
+ "episode_num": episode_num,
+ })
+ return
+
+ if not tmdb_id or not str(tmdb_id).isdigit():
+ logger.error(f"{media_name} 同步删除失败,未获取到TMDB ID,请检查媒体库媒体是否刮削")
+ return
+
+ self.__sync_del(media_type=media_type,
+ media_name=media_name,
+ media_path=media_path,
+ tmdb_id=tmdb_id,
+ season_num=season_num,
+ episode_num=episode_num)
+
+ def __sync_del(self, media_type: str, media_name: str, media_path: str,
+ tmdb_id: int, season_num: str, episode_num: str):
+ if not media_type:
+ logger.error(f"{media_name} 同步删除失败,未获取到媒体类型,请检查媒体是否刮削")
+ return
+
+ # 处理路径映射 (处理同一媒体多分辨率的情况)
+ if self._library_path:
+ paths = self._library_path.split("\n")
+ for path in paths:
+ sub_paths = path.split(":")
+ if len(sub_paths) < 2:
+ continue
+ media_path = media_path.replace(sub_paths[0], sub_paths[1]).replace('\\', '/')
+
+ # 兼容重新整理的场景
+ if Path(media_path).exists():
+ logger.warn(f"转移路径 {media_path} 未被删除或重新生成,跳过处理")
+ return
+
+ # 查询转移记录
+ msg, transfer_history = self.__get_transfer_his(media_type=media_type,
+ media_name=media_name,
+ media_path=media_path,
+ tmdb_id=tmdb_id,
+ season_num=season_num,
+ episode_num=episode_num)
+
+ logger.info(f"正在同步删除{msg}")
+
+ if not transfer_history:
+ logger.warn(
+ f"{media_type} {media_name} 未获取到可删除数据,请检查路径映射是否配置错误,请检查tmdbid获取是否正确")
+ return
+
+ # 开始删除
+ year = None
+ del_torrent_hashs = []
+ stop_torrent_hashs = []
+ error_cnt = 0
+ image = 'https://emby.media/notificationicon.png'
+ for transferhis in transfer_history:
+ title = transferhis.title
+ if title not in media_name:
+ logger.warn(
+ f"当前转移记录 {transferhis.id} {title} {transferhis.tmdbid} 与删除媒体{media_name}不符,防误删,暂不自动删除")
+ continue
+ image = transferhis.image or image
+ year = transferhis.year
+
+ # 0、删除转移记录
+ self._transferhis.delete(transferhis.id)
+
+ # 删除种子任务
+ if self._del_source:
+ # 1、直接删除源文件
+ if transferhis.src and Path(transferhis.src).suffix in settings.RMT_MEDIAEXT:
+ self._transferchain.delete_files(Path(transferhis.src))
+ if transferhis.download_hash:
+ try:
+ # 2、判断种子是否被删除完
+ delete_flag, success_flag, handle_torrent_hashs = self.handle_torrent(
+ type=transferhis.type,
+ src=transferhis.src,
+ torrent_hash=transferhis.download_hash)
+ if not success_flag:
+ error_cnt += 1
+ else:
+ if delete_flag:
+ del_torrent_hashs += handle_torrent_hashs
+ else:
+ stop_torrent_hashs += handle_torrent_hashs
+ except Exception as e:
+ logger.error("删除种子失败:%s" % str(e))
+
+ logger.info(f"同步删除 {msg} 完成!")
+
+ 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
+
+ torrent_cnt_msg = ""
+ if del_torrent_hashs:
+ torrent_cnt_msg += f"删除种子{len(set(del_torrent_hashs))}个\n"
+ if stop_torrent_hashs:
+ stop_cnt = 0
+ # 排除已删除
+ for stop_hash in set(stop_torrent_hashs):
+ if stop_hash not in set(del_torrent_hashs):
+ stop_cnt += 1
+ if stop_cnt > 0:
+ torrent_cnt_msg += f"暂停种子{stop_cnt}个\n"
+ if error_cnt:
+ torrent_cnt_msg += f"删种失败{error_cnt}个\n"
+ # 发送通知
+ self.post_message(
+ mtype=NotificationType.MediaServer,
+ title="媒体库同步删除任务完成",
+ image=backrop_image,
+ text=f"{msg}\n"
+ f"删除记录{len(transfer_history)}个\n"
+ f"{torrent_cnt_msg}"
+ 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,
+ "year": year,
+ "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())),
+ "unique": f"{media_name}:{tmdb_id}:{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}"
+ })
+
+ # 保存历史
+ self.save_data("history", history)
+
+ def __get_transfer_his(self, media_type: str, media_name: str, media_path: str,
+ tmdb_id: int, season_num: str, episode_num: str):
+ """
+ 查询转移记录
+ """
+ # 季数
+ if season_num and str(season_num).isdigit():
+ season_num = str(season_num).rjust(2, '0')
+ else:
+ season_num = None
+ # 集数
+ if episode_num and str(episode_num).isdigit():
+ episode_num = str(episode_num).rjust(2, '0')
+ else:
+ episode_num = None
+
+ # 类型
+ mtype = MediaType.MOVIE if media_type in ["Movie", "MOV"] else MediaType.TV
+
+ # 删除电影
+ if mtype == MediaType.MOVIE:
+ msg = f'电影 {media_name} {tmdb_id}'
+ transfer_history: List[TransferHistory] = self._transferhis.get_by(tmdbid=tmdb_id,
+ mtype=mtype.value,
+ dest=media_path)
+ # 删除电视剧
+ elif mtype == MediaType.TV and not season_num and not episode_num:
+ msg = f'剧集 {media_name} {tmdb_id}'
+ transfer_history: List[TransferHistory] = self._transferhis.get_by(tmdbid=tmdb_id,
+ mtype=mtype.value)
+ # 删除季 S02
+ elif mtype == MediaType.TV and season_num and not episode_num:
+ if not season_num or not str(season_num).isdigit():
+ logger.error(f"{media_name} 季同步删除失败,未获取到具体季")
+ return
+ msg = f'剧集 {media_name} S{season_num} {tmdb_id}'
+ if tmdb_id and str(tmdb_id).isdigit():
+ # 根据tmdb_id查询转移记录
+ transfer_history: List[TransferHistory] = self._transferhis.get_by(tmdbid=tmdb_id,
+ mtype=mtype.value,
+ season=f'S{season_num}')
+ else:
+ # 兼容emby webhook不发送tmdb场景
+ transfer_history: List[TransferHistory] = self._transferhis.get_by(mtype=mtype.value,
+ season=f'S{season_num}',
+ dest=media_path)
+ # 删除剧集S02E02
+ elif mtype == MediaType.TV and season_num and episode_num:
+ if not season_num or not str(season_num).isdigit() or not episode_num or not str(episode_num).isdigit():
+ logger.error(f"{media_name} 集同步删除失败,未获取到具体集")
+ return
+ msg = f'剧集 {media_name} S{season_num}E{episode_num} {tmdb_id}'
+ transfer_history: List[TransferHistory] = self._transferhis.get_by(tmdbid=tmdb_id,
+ mtype=mtype.value,
+ season=f'S{season_num}',
+ episode=f'E{episode_num}',
+ dest=media_path)
+ else:
+ return "", []
+
+ return msg, transfer_history
+
+ def sync_del_by_log(self):
+ """
+ emby删除媒体库同步删除历史记录
+ 日志方式
+ """
+ # 读取历史记录
+ history = self.get_data('history') or []
+ last_time = self.get_data("last_time") or None
+ del_medias = []
+
+ # 媒体服务器类型,多个以,分隔
+ if not settings.MEDIASERVER:
+ return
+ media_servers = settings.MEDIASERVER.split(',')
+ for media_server in media_servers:
+ if media_server == 'emby':
+ del_medias.extend(self.parse_emby_log(last_time))
+ elif media_server == 'jellyfin':
+ del_medias.extend(self.parse_jellyfin_log(last_time))
+ elif media_server == 'plex':
+ # TODO plex解析日志
+ return
+
+ if not del_medias:
+ logger.error("未解析到已删除媒体信息")
+ return
+
+ # 遍历删除
+ last_del_time = None
+ for del_media in del_medias:
+ # 删除时间
+ del_time = del_media.get("time")
+ last_del_time = del_time or datetime.datetime.now()
+ # 媒体类型 Movie|Series|Season|Episode
+ media_type = del_media.get("type")
+ # 媒体名称 蜀山战纪
+ media_name = del_media.get("name")
+ # 媒体年份 2015
+ media_year = del_media.get("year")
+ # 媒体路径 /data/series/国产剧/蜀山战纪 (2015)/Season 2/蜀山战纪 - S02E01 - 第1集.mp4
+ media_path = del_media.get("path")
+ # 季数 S02
+ media_season = del_media.get("season")
+ # 集数 E02
+ media_episode = del_media.get("episode")
+
+ # 排除路径不处理
+ if self._exclude_path and media_path and any(
+ os.path.abspath(media_path).startswith(os.path.abspath(path)) for path in
+ self._exclude_path.split(",")):
+ logger.info(f"媒体路径 {media_path} 已被排除,暂不处理")
+ self.save_data("last_time", last_del_time)
+ return
+
+ # 处理路径映射 (处理同一媒体多分辨率的情况)
+ if self._library_path:
+ paths = self._library_path.split("\n")
+ for path in paths:
+ sub_paths = path.split(":")
+ if len(sub_paths) < 2:
+ continue
+ media_path = media_path.replace(sub_paths[0], sub_paths[1]).replace('\\', '/')
+
+ # 获取删除的记录
+ # 删除电影
+ if media_type == "Movie":
+ msg = f'电影 {media_name}'
+ transfer_history: List[TransferHistory] = self._transferhis.get_by(
+ title=media_name,
+ year=media_year,
+ dest=media_path)
+ # 删除电视剧
+ elif media_type == "Series":
+ msg = f'剧集 {media_name}'
+ transfer_history: List[TransferHistory] = self._transferhis.get_by(
+ title=media_name,
+ year=media_year)
+ # 删除季 S02
+ elif media_type == "Season":
+ msg = f'剧集 {media_name} {media_season}'
+ transfer_history: List[TransferHistory] = self._transferhis.get_by(
+ title=media_name,
+ year=media_year,
+ season=media_season)
+ # 删除剧集S02E02
+ elif media_type == "Episode":
+ msg = f'剧集 {media_name} {media_season}{media_episode}'
+ transfer_history: List[TransferHistory] = self._transferhis.get_by(
+ title=media_name,
+ year=media_year,
+ season=media_season,
+ episode=media_episode,
+ dest=media_path)
+ else:
+ self.save_data("last_time", last_del_time)
+ continue
+
+ logger.info(f"正在同步删除 {msg}")
+
+ if not transfer_history:
+ logger.info(f"未获取到 {msg} 转移记录,请检查路径映射是否配置错误,请检查tmdbid获取是否正确")
+ self.save_data("last_time", last_del_time)
+ continue
+
+ logger.info(f"获取到删除历史记录数量 {len(transfer_history)}")
+
+ # 开始删除
+ image = 'https://emby.media/notificationicon.png'
+ del_torrent_hashs = []
+ stop_torrent_hashs = []
+ error_cnt = 0
+ for transferhis in transfer_history:
+ title = transferhis.title
+ if title not in media_name:
+ logger.warn(
+ f"当前转移记录 {transferhis.id} {title} {transferhis.tmdbid} 与删除媒体{media_name}不符,防误删,暂不自动删除")
+ self.save_data("last_time", last_del_time)
+ continue
+ image = transferhis.image or image
+ # 0、删除转移记录
+ self._transferhis.delete(transferhis.id)
+
+ # 删除种子任务
+ if self._del_source:
+ # 1、直接删除源文件
+ if transferhis.src and Path(transferhis.src).suffix in settings.RMT_MEDIAEXT:
+ self._transferchain.delete_files(Path(transferhis.src))
+ if transferhis.download_hash:
+ try:
+ # 2、判断种子是否被删除完
+ delete_flag, success_flag, handle_torrent_hashs = self.handle_torrent(
+ type=transferhis.type,
+ src=transferhis.src,
+ torrent_hash=transferhis.download_hash)
+ if not success_flag:
+ error_cnt += 1
+ else:
+ if delete_flag:
+ del_torrent_hashs += handle_torrent_hashs
+ else:
+ stop_torrent_hashs += handle_torrent_hashs
+ except Exception as e:
+ logger.error("删除种子失败:%s" % str(e))
+
+ logger.info(f"同步删除 {msg} 完成!")
+
+ # 发送消息
+ if self._notify:
+ torrent_cnt_msg = ""
+ if del_torrent_hashs:
+ torrent_cnt_msg += f"删除种子{len(set(del_torrent_hashs))}个\n"
+ if stop_torrent_hashs:
+ stop_cnt = 0
+ # 排除已删除
+ for stop_hash in set(stop_torrent_hashs):
+ if stop_hash not in set(del_torrent_hashs):
+ stop_cnt += 1
+ if stop_cnt > 0:
+ torrent_cnt_msg += f"暂停种子{stop_cnt}个\n"
+ self.post_message(
+ mtype=NotificationType.MediaServer,
+ title="媒体库同步删除任务完成",
+ text=f"{msg}\n"
+ f"删除记录{len(transfer_history)}个\n"
+ f"{torrent_cnt_msg}"
+ f"时间 {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}",
+ image=image)
+
+ history.append({
+ "type": "电影" if media_type == "Movie" else "电视剧",
+ "title": media_name,
+ "year": media_year,
+ "path": media_path,
+ "season": media_season,
+ "episode": media_episode,
+ "image": image,
+ "del_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time()))
+ })
+
+ # 保存历史
+ self.save_data("history", history)
+
+ self.save_data("last_time", last_del_time)
+
+ def handle_torrent(self, type: str, src: str, torrent_hash: str):
+ """
+ 判断种子是否局部删除
+ 局部删除则暂停种子
+ 全部删除则删除种子
+ """
+ download_id = torrent_hash
+ download = settings.DEFAULT_DOWNLOADER
+ history_key = "%s-%s" % (download, torrent_hash)
+ plugin_id = "TorrentTransfer"
+ transfer_history = self.get_data(key=history_key,
+ plugin_id=plugin_id)
+ logger.info(f"查询到 {history_key} 转种历史 {transfer_history}")
+
+ handle_torrent_hashs = []
+ try:
+ # 删除本次种子记录
+ self._downloadhis.delete_file_by_fullpath(fullpath=src)
+
+ # 根据种子hash查询所有下载器文件记录
+ download_files = self._downloadhis.get_files_by_hash(download_hash=torrent_hash)
+ if not download_files:
+ logger.error(
+ f"未查询到种子任务 {torrent_hash} 存在文件记录,未执行下载器文件同步或该种子已被删除")
+ return False, False, 0
+
+ # 查询未删除数
+ no_del_cnt = 0
+ for download_file in download_files:
+ if download_file and download_file.state and int(download_file.state) == 1:
+ no_del_cnt += 1
+
+ if no_del_cnt > 0:
+ logger.info(
+ f"查询种子任务 {torrent_hash} 存在 {no_del_cnt} 个未删除文件,执行暂停种子操作")
+ delete_flag = False
+ else:
+ logger.info(
+ f"查询种子任务 {torrent_hash} 文件已全部删除,执行删除种子操作")
+ delete_flag = True
+
+ # 如果有转种记录,则删除转种后的下载任务
+ if transfer_history and isinstance(transfer_history, dict):
+ download = transfer_history['to_download']
+ download_id = transfer_history['to_download_id']
+ delete_source = transfer_history['delete_source']
+
+ # 删除种子
+ if delete_flag:
+ # 删除转种记录
+ self.del_data(key=history_key, plugin_id=plugin_id)
+
+ # 转种后未删除源种时,同步删除源种
+ if not delete_source:
+ logger.info(f"{history_key} 转种时未删除源下载任务,开始删除源下载任务…")
+
+ # 删除源种子
+ logger.info(f"删除源下载器下载任务:{settings.DEFAULT_DOWNLOADER} - {torrent_hash}")
+ self.chain.remove_torrents(torrent_hash)
+ handle_torrent_hashs.append(torrent_hash)
+
+ # 删除转种后任务
+ logger.info(f"删除转种后下载任务:{download} - {download_id}")
+ # 删除转种后下载任务
+ self.chain.remove_torrents(hashs=torrent_hash,
+ downloader=download)
+ handle_torrent_hashs.append(download_id)
+ else:
+ # 暂停种子
+ # 转种后未删除源种时,同步暂停源种
+ if not delete_source:
+ logger.info(f"{history_key} 转种时未删除源下载任务,开始暂停源下载任务…")
+
+ # 暂停源种子
+ logger.info(f"暂停源下载器下载任务:{settings.DEFAULT_DOWNLOADER} - {torrent_hash}")
+ self.chain.stop_torrents(torrent_hash)
+ handle_torrent_hashs.append(torrent_hash)
+
+ logger.info(f"暂停转种后下载任务:{download} - {download_id}")
+ # 删除转种后下载任务
+ self.chain.stop_torrents(hashs=download_id, downloader=download)
+ handle_torrent_hashs.append(download_id)
+ else:
+ # 未转种de情况
+ if delete_flag:
+ # 删除源种子
+ logger.info(f"删除源下载器下载任务:{download} - {download_id}")
+ self.chain.remove_torrents(download_id)
+ else:
+ # 暂停源种子
+ logger.info(f"暂停源下载器下载任务:{download} - {download_id}")
+ self.chain.stop_torrents(download_id)
+ handle_torrent_hashs.append(download_id)
+
+ # 处理辅种
+ handle_torrent_hashs = self.__del_seed(download_id=download_id,
+ delete_flag=delete_flag,
+ handle_torrent_hashs=handle_torrent_hashs)
+ # 处理合集
+ if str(type) == "电视剧":
+ handle_torrent_hashs = self.__del_collection(src=src,
+ delete_flag=delete_flag,
+ torrent_hash=torrent_hash,
+ download_files=download_files,
+ handle_torrent_hashs=handle_torrent_hashs)
+ return delete_flag, True, handle_torrent_hashs
+ except Exception as e:
+ logger.error(f"删种失败: {str(e)}")
+ return False, False, 0
+
+ def __del_collection(self, src: str, delete_flag: bool, torrent_hash: str, download_files: list,
+ handle_torrent_hashs: list):
+ """
+ 处理合集
+ """
+ try:
+ src_download_files = self._downloadhis.get_files_by_fullpath(fullpath=src)
+ if src_download_files:
+ for download_file in src_download_files:
+ # src查询记录 判断download_hash是否不一致
+ if download_file and download_file.download_hash and str(download_file.download_hash) != str(
+ torrent_hash):
+ # 查询新download_hash对应files数量
+ hash_download_files = self._downloadhis.get_files_by_hash(
+ download_hash=download_file.download_hash)
+ # 新download_hash对应files数量 > 删种download_hash对应files数量 = 合集种子
+ if hash_download_files \
+ and len(hash_download_files) > len(download_files) \
+ and hash_download_files[0].id > download_files[-1].id:
+ # 查询未删除数
+ no_del_cnt = 0
+ for hash_download_file in hash_download_files:
+ if hash_download_file and hash_download_file.state and int(
+ hash_download_file.state) == 1:
+ no_del_cnt += 1
+ if no_del_cnt > 0:
+ logger.info(f"合集种子 {download_file.download_hash} 文件未完全删除,执行暂停种子操作")
+ delete_flag = False
+
+ # 删除合集种子
+ if delete_flag:
+ self.chain.remove_torrents(hashs=download_file.download_hash,
+ downloader=download_file.downloader)
+ logger.info(f"删除合集种子 {download_file.downloader} {download_file.download_hash}")
+ else:
+ # 暂停合集种子
+ self.chain.stop_torrents(hashs=download_file.download_hash,
+ downloader=download_file.downloader)
+ logger.info(f"暂停合集种子 {download_file.downloader} {download_file.download_hash}")
+ # 已处理种子+1
+ handle_torrent_hashs.append(download_file.download_hash)
+
+ # 处理合集辅种
+ handle_torrent_hashs = self.__del_seed(download_id=download_file.download_hash,
+ delete_flag=delete_flag,
+ handle_torrent_hashs=handle_torrent_hashs)
+ except Exception as e:
+ logger.error(f"处理 {torrent_hash} 合集失败")
+ print(str(e))
+
+ return handle_torrent_hashs
+
+ def __del_seed(self, download_id, delete_flag, handle_torrent_hashs):
+ """
+ 删除辅种
+ """
+ # 查询是否有辅种记录
+ history_key = download_id
+ plugin_id = "IYUUAutoSeed"
+ seed_history = self.get_data(key=history_key,
+ plugin_id=plugin_id) or []
+ logger.info(f"查询到 {history_key} 辅种历史 {seed_history}")
+
+ # 有辅种记录则处理辅种
+ if seed_history and isinstance(seed_history, list):
+ for history in seed_history:
+ downloader = history.get("downloader")
+ torrents = history.get("torrents")
+ if not downloader or not torrents:
+ return
+ if not isinstance(torrents, list):
+ torrents = [torrents]
+
+ # 删除辅种历史
+ for torrent in torrents:
+ handle_torrent_hashs.append(torrent)
+ # 删除辅种
+ if delete_flag:
+ logger.info(f"删除辅种:{downloader} - {torrent}")
+ self.chain.remove_torrents(hashs=torrent,
+ downloader=downloader)
+ # 暂停辅种
+ else:
+ self.chain.stop_torrents(hashs=torrent, download=downloader)
+ logger.info(f"辅种:{downloader} - {torrent} 暂停")
+
+ # 处理辅种的辅种
+ handle_torrent_hashs = self.__del_seed(download_id=torrent,
+ delete_flag=delete_flag,
+ handle_torrent_hashs=handle_torrent_hashs)
+
+ # 删除辅种历史
+ if delete_flag:
+ self.del_data(key=history_key,
+ plugin_id=plugin_id)
+ return handle_torrent_hashs
+
+ @staticmethod
+ def parse_emby_log(last_time):
+ """
+ 获取emby日志列表、解析emby日志
+ """
+
+ def __parse_log(file_name: str, del_list: list):
+ """
+ 解析emby日志
+ """
+ log_url = f"[HOST]System/Logs/{file_name}?api_key=[APIKEY]"
+ log_res = Emby().get_data(log_url)
+ if not log_res or log_res.status_code != 200:
+ logger.error("获取emby日志失败,请检查服务器配置")
+ return del_list
+
+ # 正则解析删除的媒体信息
+ pattern = r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}.\d{3}) Info App: Removing item from database, Type: (\w+), Name: (.*), Path: (.*), Id: (\d+)'
+ matches = re.findall(pattern, log_res.text)
+
+ # 循环获取媒体信息
+ for match in matches:
+ mtime = match[0]
+ # 排除已处理的媒体信息
+ if last_time and mtime < last_time:
+ continue
+
+ mtype = match[1]
+ name = match[2]
+ path = match[3]
+
+ year = None
+ year_pattern = r'\(\d+\)'
+ year_match = re.search(year_pattern, path)
+ if year_match:
+ year = year_match.group()[1:-1]
+
+ season = None
+ episode = None
+ if mtype == 'Episode' or mtype == 'Season':
+ name_pattern = r"\/([\u4e00-\u9fa5]+)(?= \()"
+ season_pattern = r"Season\s*(\d+)"
+ episode_pattern = r"S\d+E(\d+)"
+ name_match = re.search(name_pattern, path)
+ season_match = re.search(season_pattern, path)
+ episode_match = re.search(episode_pattern, path)
+
+ if name_match:
+ name = name_match.group(1)
+
+ if season_match:
+ season = season_match.group(1)
+ if int(season) < 10:
+ season = f'S0{season}'
+ else:
+ season = f'S{season}'
+ else:
+ season = None
+
+ if episode_match:
+ episode = episode_match.group(1)
+ episode = f'E{episode}'
+ else:
+ episode = None
+
+ media = {
+ "time": mtime,
+ "type": mtype,
+ "name": name,
+ "year": year,
+ "path": path,
+ "season": season,
+ "episode": episode,
+ }
+ logger.debug(f"解析到删除媒体:{json.dumps(media)}")
+ del_list.append(media)
+
+ return del_list
+
+ log_files = []
+ try:
+ # 获取所有emby日志
+ log_list_url = "[HOST]System/Logs/Query?Limit=3&api_key=[APIKEY]"
+ log_list_res = Emby().get_data(log_list_url)
+
+ if log_list_res and log_list_res.status_code == 200:
+ log_files_dict = json.loads(log_list_res.text)
+ for item in log_files_dict.get("Items"):
+ if str(item.get('Name')).startswith("embyserver"):
+ log_files.append(str(item.get('Name')))
+ except Exception as e:
+ print(str(e))
+
+ if not log_files:
+ log_files.append("embyserver.txt")
+
+ del_medias = []
+ log_files.reverse()
+ for log_file in log_files:
+ del_medias = __parse_log(file_name=log_file,
+ del_list=del_medias)
+
+ return del_medias
+
+ @staticmethod
+ def parse_jellyfin_log(last_time: datetime):
+ """
+ 获取jellyfin日志列表、解析jellyfin日志
+ """
+
+ def __parse_log(file_name: str, del_list: list):
+ """
+ 解析jellyfin日志
+ """
+ log_url = f"[HOST]System/Logs/Log?name={file_name}&api_key=[APIKEY]"
+ log_res = Jellyfin().get_data(log_url)
+ if not log_res or log_res.status_code != 200:
+ logger.error("获取jellyfin日志失败,请检查服务器配置")
+ return del_list
+
+ # 正则解析删除的媒体信息
+ pattern = r'\[(.*?)\].*?Removing item, Type: "(.*?)", Name: "(.*?)", Path: "(.*?)"'
+ matches = re.findall(pattern, log_res.text)
+
+ # 循环获取媒体信息
+ for match in matches:
+ mtime = match[0]
+ # 排除已处理的媒体信息
+ if last_time and mtime < last_time:
+ continue
+
+ mtype = match[1]
+ name = match[2]
+ path = match[3]
+
+ year = None
+ year_pattern = r'\(\d+\)'
+ year_match = re.search(year_pattern, path)
+ if year_match:
+ year = year_match.group()[1:-1]
+
+ season = None
+ episode = None
+ if mtype == 'Episode' or mtype == 'Season':
+ name_pattern = r"\/([\u4e00-\u9fa5]+)(?= \()"
+ season_pattern = r"Season\s*(\d+)"
+ episode_pattern = r"S\d+E(\d+)"
+ name_match = re.search(name_pattern, path)
+ season_match = re.search(season_pattern, path)
+ episode_match = re.search(episode_pattern, path)
+
+ if name_match:
+ name = name_match.group(1)
+
+ if season_match:
+ season = season_match.group(1)
+ if int(season) < 10:
+ season = f'S0{season}'
+ else:
+ season = f'S{season}'
+ else:
+ season = None
+
+ if episode_match:
+ episode = episode_match.group(1)
+ episode = f'E{episode}'
+ else:
+ episode = None
+
+ media = {
+ "time": mtime,
+ "type": mtype,
+ "name": name,
+ "year": year,
+ "path": path,
+ "season": season,
+ "episode": episode,
+ }
+ logger.debug(f"解析到删除媒体:{json.dumps(media)}")
+ del_list.append(media)
+
+ return del_list
+
+ log_files = []
+ try:
+ # 获取所有jellyfin日志
+ log_list_url = "[HOST]System/Logs?api_key=[APIKEY]"
+ log_list_res = Jellyfin().get_data(log_list_url)
+
+ if log_list_res and log_list_res.status_code == 200:
+ log_files_dict = json.loads(log_list_res.text)
+ for item in log_files_dict:
+ if str(item.get('Name')).startswith("log_"):
+ log_files.append(str(item.get('Name')))
+ except Exception as e:
+ print(str(e))
+
+ if not log_files:
+ log_files.append("log_%s.log" % datetime.date.today().strftime("%Y%m%d"))
+
+ del_medias = []
+ log_files.reverse()
+ for log_file in log_files:
+ del_medias = __parse_log(file_name=log_file,
+ del_list=del_medias)
+
+ return del_medias
+
+ def get_state(self):
+ return self._enabled
+
+ 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))
+
+ @eventmanager.register(EventType.DownloadFileDeleted)
+ def downloadfile_del_sync(self, event: Event):
+ """
+ 下载文件删除处理事件
+ """
+ if not event:
+ return
+ event_data = event.event_data
+ src = event_data.get("src")
+ if not src:
+ return
+ # 查询下载hash
+ download_hash = self._downloadhis.get_hash_by_fullpath(src)
+ if download_hash:
+ download_history = self._downloadhis.get_by_hash(download_hash)
+ self.handle_torrent(type=download_history.type, src=src, torrent_hash=download_hash)
+ else:
+ logger.warn(f"未查询到文件 {src} 对应的下载记录")
+
+ @staticmethod
+ def get_tmdbimage_url(path: str, prefix="w500"):
+ if not path:
+ return ""
+ tmdb_image_url = f"https://{settings.TMDB_IMAGE_DOMAIN}"
+ return tmdb_image_url + f"/t/p/{prefix}{path}"
diff --git a/plugins.v2/messageforward/__init__.py b/plugins.v2/messageforward/__init__.py
new file mode 100644
index 0000000..7a6b940
--- /dev/null
+++ b/plugins.v2/messageforward/__init__.py
@@ -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
diff --git a/plugins.v2/personmeta/__init__.py b/plugins.v2/personmeta/__init__.py
new file mode 100644
index 0000000..1c5978c
--- /dev/null
+++ b/plugins.v2/personmeta/__init__.py
@@ -0,0 +1,1026 @@
+import base64
+import copy
+import datetime
+import json
+import re
+import threading
+import time
+from pathlib import Path
+from typing import Any, List, Dict, Tuple, Optional
+
+import pytz
+import zhconv
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.triggers.cron import CronTrigger
+from requests import RequestException
+
+from app import schemas
+from app.chain.mediaserver import MediaServerChain
+from app.chain.tmdb import TmdbChain
+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.plugins import _PluginBase
+from app.schemas import MediaInfo, MediaServerItem
+from app.schemas.types import EventType, MediaType
+from app.utils.common import retry
+from app.utils.http import RequestUtils
+from app.utils.string import StringUtils
+
+
+class PersonMeta(_PluginBase):
+ # 插件名称
+ plugin_name = "演职人员刮削"
+ # 插件描述
+ plugin_desc = "刮削演职人员图片以及中文名称。"
+ # 插件图标
+ plugin_icon = "actor.png"
+ # 插件版本
+ plugin_version = "1.4"
+ # 插件作者
+ plugin_author = "jxxghp"
+ # 作者主页
+ author_url = "https://github.com/jxxghp"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "personmeta_"
+ # 加载顺序
+ plugin_order = 24
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 退出事件
+ _event = threading.Event()
+
+ # 私有属性
+ _scheduler = None
+ tmdbchain = None
+ mschain = None
+ _enabled = False
+ _onlyonce = False
+ _cron = None
+ _delay = 0
+ _type = "all"
+ _remove_nozh = False
+
+ def init_plugin(self, config: dict = None):
+ self.tmdbchain = TmdbChain()
+ self.mschain = MediaServerChain()
+ if config:
+ self._enabled = config.get("enabled")
+ self._onlyonce = config.get("onlyonce")
+ self._cron = config.get("cron")
+ self._type = config.get("type") or "all"
+ self._delay = config.get("delay") or 0
+ self._remove_nozh = config.get("remove_nozh") or False
+
+ # 停止现有任务
+ self.stop_service()
+
+ # 启动服务
+ if self._onlyonce:
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+ self._scheduler.add_job(func=self.scrap_library, trigger='date',
+ run_date=datetime.datetime.now(
+ tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3)
+ )
+ logger.info(f"演职人员刮削服务启动,立即运行一次")
+ # 关闭一次性开关
+ self._onlyonce = False
+ # 保存配置
+ self.__update_config()
+ # 启动服务
+ if self._scheduler.get_jobs():
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+
+ def __update_config(self):
+ """
+ 更新配置
+ """
+ self.update_config({
+ "enabled": self._enabled,
+ "onlyonce": self._onlyonce,
+ "cron": self._cron,
+ "type": self._type,
+ "delay": self._delay,
+ "remove_nozh": self._remove_nozh
+ })
+
+ 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": "PersonMeta",
+ "name": "演职人员刮削服务",
+ "trigger": CronTrigger.from_crontab(self._cron),
+ "func": self.scrap_library,
+ "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': 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': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'cron',
+ 'label': '媒体库扫描周期',
+ 'placeholder': '5位cron表达式'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'delay',
+ 'label': '入库延迟时间(秒)',
+ 'placeholder': '30'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'model': 'type',
+ 'label': '刮削条件',
+ 'items': [
+ {'title': '全部', 'value': 'all'},
+ {'title': '演员非中文', 'value': 'name'},
+ {'title': '角色非中文', 'value': 'role'},
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 6
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'remove_nozh',
+ 'label': '删除非中文演员',
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ], {
+ "enabled": False,
+ "onlyonce": False,
+ "cron": "",
+ "type": "all",
+ "delay": 30,
+ "remove_nozh": False
+ }
+
+ def get_page(self) -> List[dict]:
+ pass
+
+ @eventmanager.register(EventType.TransferComplete)
+ def scrap_rt(self, event: Event):
+ """
+ 根据事件实时刮削演员信息
+ """
+ if not self._enabled:
+ return
+ # 事件数据
+ mediainfo: MediaInfo = event.event_data.get("mediainfo")
+ meta: MetaBase = event.event_data.get("meta")
+ if not mediainfo or not meta:
+ return
+ # 延迟
+ if self._delay:
+ time.sleep(int(self._delay))
+ # 查询媒体服务器中的条目
+ existsinfo = self.chain.media_exists(mediainfo=mediainfo)
+ if not existsinfo or not existsinfo.itemid:
+ logger.warn(f"演职人员刮削 {mediainfo.title_year} 在媒体库中不存在")
+ return
+ # 查询条目详情
+ iteminfo = self.mschain.iteminfo(server=existsinfo.server, item_id=existsinfo.itemid)
+ if not iteminfo:
+ logger.warn(f"演职人员刮削 {mediainfo.title_year} 条目详情获取失败")
+ return
+ # 刮削演职人员信息
+ self.__update_item(server=existsinfo.server, item=iteminfo,
+ mediainfo=mediainfo, season=meta.begin_season)
+
+ def scrap_library(self):
+ """
+ 扫描整个媒体库,刮削演员信息
+ """
+ # 所有媒体服务器
+ if not settings.MEDIASERVER:
+ return
+ for server in settings.MEDIASERVER.split(","):
+ # 扫描所有媒体库
+ logger.info(f"开始刮削服务器 {server} 的演员信息 ...")
+ for library in self.mschain.librarys(server):
+ logger.info(f"开始刮削媒体库 {library.name} 的演员信息 ...")
+ for item in self.mschain.items(server, library.id):
+ if not item:
+ continue
+ if not item.item_id:
+ continue
+ if "Series" not in item.item_type \
+ and "Movie" not in item.item_type:
+ continue
+ if self._event.is_set():
+ logger.info(f"演职人员刮削服务停止")
+ return
+ # 处理条目
+ logger.info(f"开始刮削 {item.title} 的演员信息 ...")
+ self.__update_item(server=server, item=item)
+ logger.info(f"{item.title} 的演员信息刮削完成")
+ logger.info(f"媒体库 {library.name} 的演员信息刮削完成")
+ logger.info(f"服务器 {server} 的演员信息刮削完成")
+
+ def __update_peoples(self, server: str, itemid: str, iteminfo: dict, douban_actors):
+ # 处理媒体项中的人物信息
+ """
+ "People": [
+ {
+ "Name": "丹尼尔·克雷格",
+ "Id": "33625",
+ "Role": "James Bond",
+ "Type": "Actor",
+ "PrimaryImageTag": "bef4f764540f10577f804201d8d27918"
+ }
+ ]
+ """
+ peoples = []
+ # 更新当前媒体项人物
+ for people in iteminfo["People"] or []:
+ if self._event.is_set():
+ logger.info(f"演职人员刮削服务停止")
+ return
+ if not people.get("Name"):
+ continue
+ if StringUtils.is_chinese(people.get("Name")) \
+ and StringUtils.is_chinese(people.get("Role")):
+ peoples.append(people)
+ continue
+ info = self.__update_people(server=server, people=people,
+ douban_actors=douban_actors)
+ if info:
+ peoples.append(info)
+ elif not self._remove_nozh:
+ peoples.append(people)
+ # 保存媒体项信息
+ if peoples:
+ iteminfo["People"] = peoples
+ self.set_iteminfo(server=server, itemid=itemid, iteminfo=iteminfo)
+
+ def __update_item(self, server: str, item: MediaServerItem,
+ mediainfo: MediaInfo = None, season: int = None):
+ """
+ 更新媒体服务器中的条目
+ """
+
+ def __need_trans_actor(_item):
+ """
+ 是否需要处理人物信息
+ """
+ if self._type == "name":
+ # 是否需要处理人物名称
+ _peoples = [x for x in _item.get("People", []) if
+ (x.get("Name") and not StringUtils.is_chinese(x.get("Name")))]
+ elif self._type == "role":
+ # 是否需要处理人物角色
+ _peoples = [x for x in _item.get("People", []) if
+ (x.get("Role") and not StringUtils.is_chinese(x.get("Role")))]
+ else:
+ _peoples = [x for x in _item.get("People", []) if
+ (x.get("Name") and not StringUtils.is_chinese(x.get("Name")))
+ or (x.get("Role") and not StringUtils.is_chinese(x.get("Role")))]
+ if _peoples:
+ return True
+ return False
+
+ # 识别媒体信息
+ if not mediainfo:
+ if not item.tmdbid:
+ logger.warn(f"{item.title} 未找到tmdbid,无法识别媒体信息")
+ return
+ mtype = MediaType.TV if item.item_type in ['Series', 'show'] else MediaType.MOVIE
+ mediainfo = self.chain.recognize_media(mtype=mtype, tmdbid=item.tmdbid)
+ if not mediainfo:
+ logger.warn(f"{item.title} 未识别到媒体信息")
+ return
+
+ # 获取媒体项
+ iteminfo = self.get_iteminfo(server=server, itemid=item.item_id)
+ if not iteminfo:
+ logger.warn(f"{item.title} 未找到媒体项")
+ return
+
+ if __need_trans_actor(iteminfo):
+ # 获取豆瓣演员信息
+ logger.info(f"开始获取 {item.title} 的豆瓣演员信息 ...")
+ douban_actors = self.__get_douban_actors(mediainfo=mediainfo, season=season)
+ self.__update_peoples(server=server, itemid=item.item_id, iteminfo=iteminfo, douban_actors=douban_actors)
+ else:
+ logger.info(f"{item.title} 的人物信息已是中文,无需更新")
+
+ # 处理季和集人物
+ if iteminfo.get("Type") and "Series" in iteminfo["Type"]:
+ # 获取季媒体项
+ seasons = self.get_items(server=server, parentid=item.item_id, mtype="Season")
+ if not seasons:
+ logger.warn(f"{item.title} 未找到季媒体项")
+ return
+ for season in seasons["Items"]:
+ # 获取豆瓣演员信息
+ season_actors = self.__get_douban_actors(mediainfo=mediainfo, season=season.get("IndexNumber"))
+ # 如果是Jellyfin,更新季的人物,Emby/Plex季没有人物
+ if server == "jellyfin":
+ seasoninfo = self.get_iteminfo(server=server, itemid=season.get("Id"))
+ if not seasoninfo:
+ logger.warn(f"{item.title} 未找到季媒体项:{season.get('Id')}")
+ continue
+
+ if __need_trans_actor(seasoninfo):
+ # 更新季媒体项人物
+ self.__update_peoples(server=server, itemid=season.get("Id"), iteminfo=seasoninfo,
+ douban_actors=season_actors)
+ logger.info(f"季 {seasoninfo.get('Id')} 的人物信息更新完成")
+ else:
+ logger.info(f"季 {seasoninfo.get('Id')} 的人物信息已是中文,无需更新")
+ # 获取集媒体项
+ episodes = self.get_items(server=server, parentid=season.get("Id"), mtype="Episode")
+ if not episodes:
+ logger.warn(f"{item.title} 未找到集媒体项")
+ continue
+ # 更新集媒体项人物
+ for episode in episodes["Items"]:
+ # 获取集媒体项详情
+ episodeinfo = self.get_iteminfo(server=server, itemid=episode.get("Id"))
+ if not episodeinfo:
+ logger.warn(f"{item.title} 未找到集媒体项:{episode.get('Id')}")
+ continue
+ if __need_trans_actor(episodeinfo):
+ # 更新集媒体项人物
+ self.__update_peoples(server=server, itemid=episode.get("Id"), iteminfo=episodeinfo,
+ douban_actors=season_actors)
+ logger.info(f"集 {episodeinfo.get('Id')} 的人物信息更新完成")
+ else:
+ logger.info(f"集 {episodeinfo.get('Id')} 的人物信息已是中文,无需更新")
+
+ def __update_people(self, server: str, people: dict, douban_actors: list = None) -> Optional[dict]:
+ """
+ 更新人物信息,返回替换后的人物信息
+ """
+
+ def __get_peopleid(p: dict) -> Tuple[Optional[str], Optional[str]]:
+ """
+ 获取人物的TMDBID、IMDBID
+ """
+ if not p.get("ProviderIds"):
+ return None, None
+ peopletmdbid, peopleimdbid = None, None
+ if "Tmdb" in p["ProviderIds"]:
+ peopletmdbid = p["ProviderIds"]["Tmdb"]
+ if "tmdb" in p["ProviderIds"]:
+ peopletmdbid = p["ProviderIds"]["tmdb"]
+ if "Imdb" in p["ProviderIds"]:
+ peopleimdbid = p["ProviderIds"]["Imdb"]
+ if "imdb" in p["ProviderIds"]:
+ peopleimdbid = p["ProviderIds"]["imdb"]
+ return peopletmdbid, peopleimdbid
+
+ # 返回的人物信息
+ ret_people = copy.deepcopy(people)
+
+ try:
+ # 查询媒体库人物详情
+ personinfo = self.get_iteminfo(server=server, itemid=people.get("Id"))
+ if not personinfo:
+ logger.debug(f"未找到人物 {people.get('Name')} 的信息")
+ return None
+
+ # 是否更新标志
+ updated_name = False
+ updated_overview = False
+ update_character = False
+ profile_path = None
+
+ # 从TMDB信息中更新人物信息
+ person_tmdbid, person_imdbid = __get_peopleid(personinfo)
+ if person_tmdbid:
+ person_detail = self.tmdbchain.person_detail(int(person_tmdbid))
+ if person_detail:
+ cn_name = self.__get_chinese_name(person_detail)
+ # 图片优先从TMDB获取
+ profile_path = person_detail.profile_path
+ if profile_path:
+ logger.debug(f"{people.get('Name')} 从TMDB获取到图片:{profile_path}")
+ profile_path = f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{profile_path}"
+ if cn_name:
+ # 更新中文名
+ logger.debug(f"{people.get('Name')} 从TMDB获取到中文名:{cn_name}")
+ personinfo["Name"] = cn_name
+ ret_people["Name"] = cn_name
+ updated_name = True
+ # 更新中文描述
+ biography = person_detail.biography
+ if biography and StringUtils.is_chinese(biography):
+ logger.debug(f"{people.get('Name')} 从TMDB获取到中文描述")
+ personinfo["Overview"] = biography
+ updated_overview = True
+
+ # 从豆瓣信息中更新人物信息
+ """
+ {
+ "name": "丹尼尔·克雷格",
+ "roles": [
+ "演员",
+ "制片人",
+ "配音"
+ ],
+ "title": "丹尼尔·克雷格(同名)英国,英格兰,柴郡,切斯特影视演员",
+ "url": "https://movie.douban.com/celebrity/1025175/",
+ "user": null,
+ "character": "饰 詹姆斯·邦德 James Bond 007",
+ "uri": "douban://douban.com/celebrity/1025175?subject_id=27230907",
+ "avatar": {
+ "large": "https://qnmob3.doubanio.com/view/celebrity/raw/public/p42588.jpg?imageView2/2/q/80/w/600/h/3000/format/webp",
+ "normal": "https://qnmob3.doubanio.com/view/celebrity/raw/public/p42588.jpg?imageView2/2/q/80/w/200/h/300/format/webp"
+ },
+ "sharing_url": "https://www.douban.com/doubanapp/dispatch?uri=/celebrity/1025175/",
+ "type": "celebrity",
+ "id": "1025175",
+ "latin_name": "Daniel Craig"
+ }
+ """
+ if douban_actors and (not updated_name
+ or not updated_overview
+ or not update_character):
+ # 从豆瓣演员中匹配中文名称、角色和简介
+ for douban_actor in douban_actors:
+ if douban_actor.get("latin_name") == people.get("Name") \
+ or douban_actor.get("name") == people.get("Name"):
+ # 名称
+ if not updated_name:
+ logger.debug(f"{people.get('Name')} 从豆瓣中获取到中文名:{douban_actor.get('name')}")
+ personinfo["Name"] = douban_actor.get("name")
+ ret_people["Name"] = douban_actor.get("name")
+ updated_name = True
+ # 描述
+ if not updated_overview:
+ if douban_actor.get("title"):
+ logger.debug(f"{people.get('Name')} 从豆瓣中获取到中文描述:{douban_actor.get('title')}")
+ personinfo["Overview"] = douban_actor.get("title")
+ updated_overview = True
+ # 饰演角色
+ if not update_character:
+ if douban_actor.get("character"):
+ # "饰 詹姆斯·邦德 James Bond 007"
+ character = re.sub(r"饰\s+", "",
+ douban_actor.get("character"))
+ character = re.sub("演员", "",
+ character)
+ if character:
+ logger.debug(f"{people.get('Name')} 从豆瓣中获取到饰演角色:{character}")
+ ret_people["Role"] = character
+ update_character = True
+ # 图片
+ if not profile_path:
+ avatar = douban_actor.get("avatar") or {}
+ if avatar.get("large"):
+ logger.debug(f"{people.get('Name')} 从豆瓣中获取到图片:{avatar.get('large')}")
+ profile_path = avatar.get("large")
+ break
+
+ # 更新人物图片
+ if profile_path:
+ logger.debug(f"更新人物 {people.get('Name')} 的图片:{profile_path}")
+ self.set_item_image(server=server, itemid=people.get("Id"), imageurl=profile_path)
+
+ # 锁定人物信息
+ if updated_name:
+ if "Name" not in personinfo["LockedFields"]:
+ personinfo["LockedFields"].append("Name")
+ if updated_overview:
+ if "Overview" not in personinfo["LockedFields"]:
+ personinfo["LockedFields"].append("Overview")
+
+ # 更新人物信息
+ if updated_name or updated_overview or update_character:
+ logger.debug(f"更新人物 {people.get('Name')} 的信息:{personinfo}")
+ ret = self.set_iteminfo(server=server, itemid=people.get("Id"), iteminfo=personinfo)
+ if ret:
+ return ret_people
+ else:
+ logger.debug(f"人物 {people.get('Name')} 未找到中文数据")
+ except Exception as err:
+ logger.error(f"更新人物信息失败:{str(err)}")
+ return None
+
+ def __get_douban_actors(self, mediainfo: MediaInfo, season: int = None) -> List[dict]:
+ """
+ 获取豆瓣演员信息
+ """
+ # 随机休眠 3-10 秒
+ sleep_time = 3 + int(time.time()) % 7
+ logger.debug(f"随机休眠 {sleep_time}秒 ...")
+ time.sleep(sleep_time)
+ # 匹配豆瓣信息
+ doubaninfo = self.chain.match_doubaninfo(name=mediainfo.title,
+ imdbid=mediainfo.imdb_id,
+ mtype=mediainfo.type,
+ year=mediainfo.year,
+ season=season)
+ # 豆瓣演员
+ if doubaninfo:
+ doubanitem = self.chain.douban_info(doubaninfo.get("id")) or {}
+ return (doubanitem.get("actors") or []) + (doubanitem.get("directors") or [])
+ else:
+ logger.debug(f"未找到豆瓣信息:{mediainfo.title_year}")
+ return []
+
+ @staticmethod
+ def get_iteminfo(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 = Emby().get_data(url=url)
+ if res:
+ return res.json()
+ except Exception as err:
+ logger.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 = Jellyfin().get_data(url=url)
+ if res:
+ result = res.json()
+ if result:
+ result['FileName'] = Path(result['Path']).name
+ return result
+ except Exception as err:
+ logger.error(f"获取Jellyfin媒体项详情失败:{str(err)}")
+ return {}
+
+ def __get_plex_iteminfo() -> dict:
+ """
+ 获得Plex媒体项详情
+ """
+ iteminfo = {}
+ try:
+ plexitem = 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
+ return iteminfo
+ except Exception as err:
+ logger.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()
+
+ @staticmethod
+ def get_items(server: str, parentid: str, mtype: str = None) -> dict:
+ """
+ 获得媒体的所有子媒体项
+ """
+ pass
+
+ def __get_emby_items() -> dict:
+ """
+ 获得Emby媒体的所有子媒体项
+ """
+ try:
+ if parentid:
+ url = f'[HOST]emby/Users/[USER]/Items?ParentId={parentid}&api_key=[APIKEY]'
+ else:
+ url = '[HOST]emby/Users/[USER]/Items?api_key=[APIKEY]'
+ res = Emby().get_data(url=url)
+ if res:
+ return res.json()
+ except Exception as err:
+ logger.error(f"获取Emby媒体的所有子媒体项失败:{str(err)}")
+ return {}
+
+ def __get_jellyfin_items() -> dict:
+ """
+ 获得Jellyfin媒体的所有子媒体项
+ """
+ try:
+ if parentid:
+ url = f'[HOST]Users/[USER]/Items?ParentId={parentid}&api_key=[APIKEY]'
+ else:
+ url = '[HOST]Users/[USER]/Items?api_key=[APIKEY]'
+ res = Jellyfin().get_data(url=url)
+ if res:
+ return res.json()
+ except Exception as err:
+ logger.error(f"获取Jellyfin媒体的所有子媒体项失败:{str(err)}")
+ return {}
+
+ def __get_plex_items() -> dict:
+ """
+ 获得Plex媒体的所有子媒体项
+ """
+ items = {}
+ try:
+ plex = Plex().get_plex()
+ items['Items'] = []
+ if parentid:
+ if mtype and 'Season' in mtype:
+ plexitem = plex.library.fetchItem(ekey=parentid)
+ items['Items'] = []
+ for season in plexitem.seasons():
+ item = {
+ 'Name': season.title,
+ 'Id': season.key,
+ 'IndexNumber': season.seasonNumber,
+ 'Overview': season.summary
+ }
+ items['Items'].append(item)
+ elif mtype and 'Episode' in mtype:
+ plexitem = plex.library.fetchItem(ekey=parentid)
+ items['Items'] = []
+ for episode in plexitem.episodes():
+ item = {
+ 'Name': episode.title,
+ 'Id': episode.key,
+ 'IndexNumber': episode.episodeNumber,
+ 'Overview': episode.summary,
+ 'CommunityRating': episode.audienceRating
+ }
+ items['Items'].append(item)
+ else:
+ plexitems = plex.library.sectionByID(sectionID=parentid)
+ for plexitem in plexitems.all():
+ item = {}
+ if 'movie' in plexitem.METADATA_TYPE:
+ item['Type'] = 'Movie'
+ item['IsFolder'] = False
+ elif 'episode' in plexitem.METADATA_TYPE:
+ item['Type'] = 'Series'
+ item['IsFolder'] = False
+ item['Name'] = plexitem.title
+ item['Id'] = plexitem.key
+ items['Items'].append(item)
+ else:
+ plexitems = plex.library.sections()
+ for plexitem in plexitems:
+ item = {}
+ if 'Directory' in plexitem.TAG:
+ item['Type'] = 'Folder'
+ item['IsFolder'] = True
+ elif 'movie' in plexitem.METADATA_TYPE:
+ item['Type'] = 'Movie'
+ item['IsFolder'] = False
+ elif 'episode' in plexitem.METADATA_TYPE:
+ item['Type'] = 'Series'
+ item['IsFolder'] = False
+ item['Name'] = plexitem.title
+ item['Id'] = plexitem.key
+ items['Items'].append(item)
+ return items
+ except Exception as err:
+ logger.error(f"获取Plex媒体的所有子媒体项失败:{str(err)}")
+ return {}
+
+ if server == "emby":
+ return __get_emby_items()
+ elif server == "jellyfin":
+ return __get_jellyfin_items()
+ else:
+ return __get_plex_items()
+
+ @staticmethod
+ def set_iteminfo(server: str, itemid: str, iteminfo: dict):
+ """
+ 更新媒体项详情
+ """
+
+ def __set_emby_iteminfo():
+ """
+ 更新Emby媒体项详情
+ """
+ try:
+ res = 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:
+ logger.error(f"更新Emby媒体项详情失败,错误码:{res.status_code}")
+ return False
+ except Exception as err:
+ logger.error(f"更新Emby媒体项详情失败:{str(err)}")
+ return False
+
+ def __set_jellyfin_iteminfo():
+ """
+ 更新Jellyfin媒体项详情
+ """
+ try:
+ res = 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:
+ logger.error(f"更新Jellyfin媒体项详情失败,错误码:{res.status_code}")
+ return False
+ except Exception as err:
+ logger.error(f"更新Jellyfin媒体项详情失败:{str(err)}")
+ return False
+
+ def __set_plex_iteminfo():
+ """
+ 更新Plex媒体项详情
+ """
+ try:
+ plexitem = Plex().get_plex().library.fetchItem(ekey=itemid)
+ if 'CommunityRating' in iteminfo:
+ 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:
+ logger.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()
+
+ @staticmethod
+ @retry(RequestException, logger=logger)
+ def set_item_image(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:
+ logger.warn(f"{imageurl} 图片下载失败,请检查网络连通性")
+ except Exception as err:
+ logger.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 = Emby().post_data(
+ url=url,
+ data=_base64,
+ headers={
+ "Content-Type": "image/png"
+ }
+ )
+ if res and res.status_code in [200, 204]:
+ return True
+ else:
+ logger.error(f"更新Emby媒体项图片失败,错误码:{res.status_code}")
+ return False
+ except Exception as result:
+ logger.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 = Jellyfin().post_data(url=url)
+ if res and res.status_code in [200, 204]:
+ return True
+ else:
+ logger.error(f"更新Jellyfin媒体项图片失败,错误码:{res.status_code}")
+ return False
+ except Exception as err:
+ logger.error(f"更新Jellyfin媒体项图片失败:{err}")
+ return False
+
+ def __set_plex_item_image():
+ """
+ 更新Plex媒体项图片
+ # FIXME 改为预下载图片
+ """
+ try:
+ plexitem = Plex().get_plex().library.fetchItem(ekey=itemid)
+ plexitem.uploadPoster(url=imageurl)
+ return True
+ except Exception as err:
+ logger.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
+
+ @staticmethod
+ def __get_chinese_name(personinfo: schemas.MediaPerson) -> str:
+ """
+ 获取TMDB别名中的中文名
+ """
+ try:
+ also_known_as = personinfo.also_known_as or []
+ if also_known_as:
+ for name in also_known_as:
+ if name and StringUtils.is_chinese(name):
+ # 使用cn2an将繁体转化为简体
+ return zhconv.convert(name, "zh-hans")
+ except Exception as err:
+ logger.error(f"获取人物中文名失败:{err}")
+ return ""
+
+ 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))
diff --git a/plugins.v2/qbcommand/__init__.py b/plugins.v2/qbcommand/__init__.py
new file mode 100644
index 0000000..78f6916
--- /dev/null
+++ b/plugins.v2/qbcommand/__init__.py
@@ -0,0 +1,1171 @@
+from typing import List, Tuple, Dict, Any
+from enum import Enum
+from urllib.parse import urlparse
+import urllib
+from app.log import logger
+from app.modules.qbittorrent import Qbittorrent
+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
+from apscheduler.schedulers.background import BackgroundScheduler
+from app.core.config import settings
+from app.helper.sites import SitesHelper
+from app.db.site_oper import SiteOper
+from app.utils.string import StringUtils
+from datetime import datetime, timedelta
+import pytz
+import time
+
+
+class QbCommand(_PluginBase):
+ # 插件名称
+ plugin_name = "QB远程操作"
+ # 插件描述
+ plugin_desc = "通过定时任务或交互命令远程操作QB暂停/开始/限速等"
+ # 插件图标
+ plugin_icon = "Qbittorrent_A.png"
+ # 插件版本
+ plugin_version = "1.5"
+ # 插件作者
+ plugin_author = "DzAvril"
+ # 作者主页
+ author_url = "https://github.com/DzAvril"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "qbcommand_"
+ # 加载顺序
+ plugin_order = 1
+ # 可使用的用户级别
+ auth_level = 1
+
+ # 私有属性
+ _sites = None
+ _siteoper = None
+ _qb = None
+ _enabled: bool = False
+ _notify: bool = False
+ _pause_cron = None
+ _resume_cron = None
+ _only_pause_once = False
+ _only_resume_once = False
+ _only_pause_upload = False
+ _only_pause_download = False
+ _only_pause_checking = False
+ _upload_limit = 0
+ _enable_upload_limit = False
+ _download_limit = 0
+ _enable_download_limit = False
+ _op_site_ids = []
+ _op_sites = []
+ _multi_level_root_domain = ["edu.cn", "com.cn", "net.cn", "org.cn"]
+ _scheduler = None
+ _exclude_dirs = ""
+ def init_plugin(self, config: dict = None):
+ self._sites = SitesHelper()
+ self._siteoper = SiteOper()
+ # 停止现有任务
+ 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._only_pause_upload = config.get("onlypauseupload")
+ self._only_pause_download = config.get("onlypausedownload")
+ self._only_pause_checking = config.get("onlypausechecking")
+ 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._qb = Qbittorrent()
+ self._op_site_ids = config.get("op_site_ids") or []
+ # 查询所有站点
+ all_sites = [site for site in self._sites.get_indexers() if not site.get("public")] + self.__custom_sites()
+ # 过滤掉没有选中的站点
+ self._op_sites = [site for site in all_sites if site.get("id") in self._op_site_ids]
+ self._exclude_dirs = config.get("exclude_dirs") or ""
+
+ 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._scheduler = BackgroundScheduler(timezone=settings.TZ)
+ logger.info(f"立即运行一次暂停所有任务")
+ self._scheduler.add_job(
+ self.pause_torrent,
+ "date",
+ run_date=datetime.now(tz=pytz.timezone(settings.TZ))
+ + timedelta(seconds=3),
+ )
+ elif self._only_resume_once:
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+ logger.info(f"立即运行一次开始所有任务")
+ self._scheduler.add_job(
+ self.resume_torrent,
+ "date",
+ run_date=datetime.now(tz=pytz.timezone(settings.TZ))
+ + timedelta(seconds=3),
+ )
+
+ 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,
+ "op_site_ids": self._op_site_ids,
+ "exclude_dirs": self._exclude_dirs,
+ }
+ )
+
+ # 启动任务
+ if self._scheduler.get_jobs():
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+
+ if (
+ self._only_pause_upload
+ or self._only_pause_download
+ or self._only_pause_checking
+ ):
+ if self._only_pause_upload:
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+ logger.info(f"立即运行一次暂停所有上传任务")
+ self._scheduler.add_job(
+ self.pause_torrent,
+ "date",
+ run_date=datetime.now(tz=pytz.timezone(settings.TZ))
+ + timedelta(seconds=3),
+ kwargs={
+ 'type': self.TorrentType.UPLOADING
+ }
+ )
+ if self._only_pause_download:
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+ logger.info(f"立即运行一次暂停所有下载任务")
+ self._scheduler.add_job(
+ self.pause_torrent,
+ "date",
+ run_date=datetime.now(tz=pytz.timezone(settings.TZ))
+ + timedelta(seconds=3),
+ kwargs={
+ 'type': self.TorrentType.DOWNLOADING
+ }
+ )
+ if self._only_pause_checking:
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+ logger.info(f"立即运行一次暂停所有检查任务")
+ self._scheduler.add_job(
+ self.pause_torrent,
+ "date",
+ run_date=datetime.now(tz=pytz.timezone(settings.TZ))
+ + timedelta(seconds=3),
+ kwargs={
+ 'type': self.TorrentType.CHECKING
+ }
+ )
+
+ self._only_pause_upload = False
+ self._only_pause_download = False
+ self._only_pause_checking = False
+ self.update_config(
+ {
+ "onlypauseupload": False,
+ "onlypausedownload": False,
+ "onlypausechecking": False,
+ "enabled": self._enabled,
+ "notify": self._notify,
+ "pause_cron": self._pause_cron,
+ "resume_cron": self._resume_cron,
+ "op_site_ids": self._op_site_ids,
+ }
+ )
+
+ # 启动任务
+ if self._scheduler.get_jobs():
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+
+ self.set_limit(self._upload_limit, self._download_limit)
+
+ def get_state(self) -> bool:
+ return self._enabled
+
+ class TorrentType(Enum):
+ ALL = 1
+ DOWNLOADING = 2
+ UPLOADING = 3
+ CHECKING = 4
+
+ @staticmethod
+ def get_command() -> List[Dict[str, Any]]:
+ """
+ 定义远程控制命令
+ :return: 命令关键字、事件、描述、附带数据
+ """
+ return [
+ {
+ "cmd": "/pause_torrents",
+ "event": EventType.PluginAction,
+ "desc": "暂停QB所有任务",
+ "category": "QB",
+ "data": {"action": "pause_torrents"},
+ },
+ {
+ "cmd": "/pause_upload_torrents",
+ "event": EventType.PluginAction,
+ "desc": "暂停QB上传任务",
+ "category": "QB",
+ "data": {"action": "pause_upload_torrents"},
+ },
+ {
+ "cmd": "/pause_download_torrents",
+ "event": EventType.PluginAction,
+ "desc": "暂停QB下载任务",
+ "category": "QB",
+ "data": {"action": "pause_download_torrents"},
+ },
+ {
+ "cmd": "/pause_checking_torrents",
+ "event": EventType.PluginAction,
+ "desc": "暂停QB检查任务",
+ "category": "QB",
+ "data": {"action": "pause_checking_torrents"},
+ },
+ {
+ "cmd": "/resume_torrents",
+ "event": EventType.PluginAction,
+ "desc": "开始QB所有任务",
+ "category": "QB",
+ "data": {"action": "resume_torrents"},
+ },
+ {
+ "cmd": "/qb_status",
+ "event": EventType.PluginAction,
+ "desc": "QB当前任务状态",
+ "category": "QB",
+ "data": {"action": "qb_status"},
+ },
+ {
+ "cmd": "/toggle_upload_limit",
+ "event": EventType.PluginAction,
+ "desc": "QB切换上传限速状态",
+ "category": "QB",
+ "data": {"action": "toggle_upload_limit"},
+ },
+ {
+ "cmd": "/toggle_download_limit",
+ "event": EventType.PluginAction,
+ "desc": "QB切换下载限速状态",
+ "category": "QB",
+ "data": {"action": "toggle_download_limit"},
+ },
+ ]
+
+ def __custom_sites(self) -> List[Any]:
+ custom_sites = []
+ custom_sites_config = self.get_config("CustomSites")
+ if custom_sites_config and custom_sites_config.get("enabled"):
+ custom_sites = custom_sites_config.get("sites")
+ return custom_sites
+
+ 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": "QbPause",
+ "name": "暂停QB所有任务",
+ "trigger": CronTrigger.from_crontab(self._pause_cron),
+ "func": self.pause_torrent,
+ "kwargs": {},
+ },
+ {
+ "id": "QbResume",
+ "name": "开始QB所有任务",
+ "trigger": CronTrigger.from_crontab(self._resume_cron),
+ "func": self.resume_torrent,
+ "kwargs": {},
+ },
+ ]
+ if self._enabled and self._pause_cron:
+ return [
+ {
+ "id": "QbPause",
+ "name": "暂停QB所有任务",
+ "trigger": CronTrigger.from_crontab(self._pause_cron),
+ "func": self.pause_torrent,
+ "kwargs": {},
+ }
+ ]
+ if self._enabled and self._resume_cron:
+ return [
+ {
+ "id": "QbResume",
+ "name": "开始QB所有任务",
+ "trigger": CronTrigger.from_crontab(self._resume_cron),
+ "func": self.resume_torrent,
+ "kwargs": {},
+ }
+ ]
+ return []
+
+ 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
+
+ @staticmethod
+ def get_torrents_status(torrents):
+ downloading_torrents = []
+ uploading_torrents = []
+ paused_torrents = []
+ checking_torrents = []
+ error_torrents = []
+ for torrent in torrents:
+ if torrent.state_enum.is_uploading and not torrent.state_enum.is_paused:
+ uploading_torrents.append(torrent.get("hash"))
+ elif (
+ torrent.state_enum.is_downloading
+ and not torrent.state_enum.is_paused
+ and not torrent.state_enum.is_checking
+ ):
+ downloading_torrents.append(torrent.get("hash"))
+ elif torrent.state_enum.is_checking:
+ checking_torrents.append(torrent.get("hash"))
+ elif torrent.state_enum.is_paused:
+ paused_torrents.append(torrent.get("hash"))
+ elif torrent.state_enum.is_errored:
+ error_torrents.append(torrent.get("hash"))
+
+ 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()
+
+ @eventmanager.register(EventType.PluginAction)
+ def handle_pause_upload_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_upload_torrents":
+ return
+ self.pause_torrent(self.TorrentType.UPLOADING)
+
+ @eventmanager.register(EventType.PluginAction)
+ def handle_pause_download_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_download_torrents":
+ return
+ self.pause_torrent(self.TorrentType.DOWNLOADING)
+
+ @eventmanager.register(EventType.PluginAction)
+ def handle_pause_checking_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_checking_torrents":
+ return
+ self.pause_torrent(self.TorrentType.CHECKING)
+
+ def pause_torrent(self, type: TorrentType = TorrentType.ALL):
+ 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"暂定任务启动 \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"【QB暂停任务启动】",
+ 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",
+ )
+ pause_torrents = self.filter_pause_torrents(all_torrents)
+ hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = (
+ self.get_torrents_status(pause_torrents)
+ )
+ if type == self.TorrentType.DOWNLOADING:
+ to_be_paused = hash_downloading
+ elif type == self.TorrentType.UPLOADING:
+ to_be_paused = hash_uploading
+ elif type == self.TorrentType.CHECKING:
+ to_be_paused = hash_checking
+ else:
+ to_be_paused = hash_downloading + hash_uploading + hash_checking
+
+ if len(to_be_paused) > 0:
+ if self._qb.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"【QB远程操作】",
+ 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"【QB暂停任务完成】",
+ 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",
+ )
+
+ def __is_excluded(self, file_path) -> bool:
+ """
+ 是否排除目录
+ """
+ for exclude_dir in self._exclude_dirs.split("\n"):
+ if exclude_dir and exclude_dir in str(file_path):
+ return True
+ return False
+ def filter_pause_torrents(self, all_torrents):
+ torrents = []
+ for torrent in all_torrents:
+ if self.__is_excluded(torrent.get("content_path")):
+ continue
+ torrents.append(torrent)
+ return torrents
+
+ @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"QB开始任务启动 \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"【QB开始任务启动】",
+ 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",
+ )
+
+ resume_torrents = self.filter_resume_torrents(all_torrents)
+ hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = (
+ self.get_torrents_status(resume_torrents)
+ )
+ if not self._qb.start_torrents(ids=hash_paused):
+ logger.error(f"开始种子失败")
+ if self._notify:
+ self.post_message(
+ mtype=NotificationType.SiteMessage,
+ title=f"【QB远程操作】",
+ 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"【QB开始任务完成】",
+ 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",
+ )
+
+ def filter_resume_torrents(self, all_torrents):
+ """
+ 过滤掉不参与保种的种子
+ """
+ if len(self._op_sites) == 0:
+ return all_torrents
+
+ urls = [site.get("url") for site in self._op_sites]
+ op_sites_main_domains = []
+ for url in urls:
+ domain = StringUtils.get_url_netloc(url)
+ main_domain = self.get_main_domain(domain[1])
+ op_sites_main_domains.append(main_domain)
+
+ torrents = []
+ for torrent in all_torrents:
+ if torrent.get("state") == "pausedUP":
+ tracker_url = self.get_torrent_tracker(torrent)
+ if not tracker_url:
+ logger.info(f"获取种子 {torrent.name} Tracker失败,不过滤该种子")
+ torrents.append(torrent)
+ _, tracker_domain = StringUtils.get_url_netloc(tracker_url)
+ if not tracker_domain:
+ logger.info(f"获取种子 {torrent.name} Tracker失败,不过滤该种子")
+ torrents.append(torrent)
+ tracker_main_domain = self.get_main_domain(domain=tracker_domain)
+ if tracker_main_domain in op_sites_main_domains:
+ logger.info(
+ f"种子 {torrent.name} 属于站点{tracker_main_domain},不执行操作"
+ )
+ continue
+
+ torrents.append(torrent)
+ return torrents
+
+ @eventmanager.register(EventType.PluginAction)
+ def handle_qb_status(self, event: Event):
+ if not self._enabled:
+ return
+ if event:
+ event_data = event.event_data
+ if not event_data or event_data.get("action") != "qb_status":
+ return
+ self.qb_status()
+
+ def qb_status(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"QB任务状态 \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"【QB任务状态】",
+ 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"【QB远程操作】",
+ text=f"设置QB限速失败,download_limit或upload_limit不是一个数值",
+ )
+ return False
+
+ return self._qb.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"【QB远程操作】",
+ text=f"设置QB限速失败,upload_limit不是一个数值",
+ )
+ return False
+
+ download_limit_current_val, _ = self._qb.get_speed_limit()
+ return self._qb.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"【QB远程操作】",
+ text=f"设置QB限速失败,download_limit不是一个数值",
+ )
+ return False
+
+ _, upload_limit_current_val = self._qb.get_speed_limit()
+ return self._qb.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 == True:
+ logger.info(f"设置QB限速成功")
+ 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"【QB远程操作】",
+ text=text,
+ )
+ elif flag == False:
+ logger.error(f"QB设置限速失败")
+ if self._notify:
+ self.post_message(
+ mtype=NotificationType.SiteMessage,
+ title=f"【QB远程操作】",
+ text=f"设置QB限速失败",
+ )
+
+ def get_torrent_tracker(self, torrent):
+ """
+ qb解析 tracker
+ :return: tracker url
+ """
+ if not torrent:
+ return None
+ tracker = torrent.get("tracker")
+ if tracker and len(tracker) > 0:
+ return tracker
+ magnet_uri = torrent.get("magnet_uri")
+ if not magnet_uri or len(magnet_uri) <= 0:
+ return None
+ magnet_uri_obj = urlparse(magnet_uri)
+ query = urllib.parse.parse_qs(magnet_uri_obj.query)
+ tr = query["tr"]
+ if not tr or len(tr) <= 0:
+ return None
+ return tr[0]
+
+ def get_main_domain(self, domain):
+ """
+ 获取域名的主域名
+ :param domain: 原域名
+ :return: 主域名
+ """
+ if not domain:
+ return None
+ domain_arr = domain.split(".")
+ domain_len = len(domain_arr)
+ if domain_len < 2:
+ return None
+ root_domain, root_domain_len = self.match_multi_level_root_domain(domain=domain)
+ if root_domain:
+ return f"{domain_arr[-root_domain_len - 1]}.{root_domain}"
+ else:
+ return f"{domain_arr[-2]}.{domain_arr[-1]}"
+
+ def match_multi_level_root_domain(self, domain):
+ """
+ 匹配多级根域名
+ :param domain: 被匹配的域名
+ :return: 匹配的根域名, 匹配的根域名长度
+ """
+ if not domain or not self._multi_level_root_domain:
+ return None, 0
+ for root_domain in self._multi_level_root_domain:
+ if domain.endswith("." + root_domain):
+ root_domain_len = len(root_domain.split("."))
+ return root_domain, root_domain_len
+ return None, 0
+
+ def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
+ customSites = self.__custom_sites()
+
+ site_options = [
+ {"title": site.name, "value": site.id}
+ for site in self._siteoper.list_order_by_pri()
+ ] + [
+ {"title": site.get("name"), "value": site.get("id")} for site in customSites
+ ]
+ 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, "md": 4},
+ "content": [
+ {
+ "component": "VSwitch",
+ "props": {
+ "model": "onlypauseupload",
+ "label": "暂停上传任务",
+ },
+ }
+ ],
+ },
+ {
+ "component": "VCol",
+ "props": {"cols": 12, "md": 4},
+ "content": [
+ {
+ "component": "VSwitch",
+ "props": {
+ "model": "onlypausedownload",
+ "label": "暂停下载任务",
+ },
+ }
+ ],
+ },
+ {
+ "component": "VCol",
+ "props": {"cols": 12, "md": 4},
+ "content": [
+ {
+ "component": "VSwitch",
+ "props": {
+ "model": "onlypausechecking",
+ "label": "暂停检查任务",
+ },
+ }
+ ],
+ },
+ ],
+ },
+ {
+ "component": "VRow",
+ "content": [
+ {
+ "component": "VCol",
+ "props": {"cols": 12},
+ "content": [
+ {
+ "component": "VSelect",
+ "props": {
+ "chips": True,
+ "multiple": True,
+ "model": "op_site_ids",
+ "label": "停止保种站点(暂停保种后不会被恢复)",
+ "items": site_options,
+ },
+ }
+ ],
+ }
+ ],
+ },
+ {
+ "component": "VRow",
+ "content": [
+ {
+ "component": "VCol",
+ "props": {"cols": 12},
+ "content": [
+ {
+ "component": "VTextarea",
+ "props": {
+ "model": "exclude_dirs",
+ "label": "不暂停保种目录",
+ "rows": 5,
+ "placeholder": "该目录下的做种不会暂停,一行一个目录",
+ },
+ }
+ ],
+ }
+ ],
+ },
+ {
+ "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": "交互命令有暂停QB种子、开始QB种子、QB切换上传限速状态、QB切换下载限速状态",
+ },
+ }
+ ],
+ },
+ ],
+ },
+ ],
+ }
+ ], {
+ "enabled": False,
+ "notify": True,
+ "onlypauseonce": False,
+ "onlyresumeonce": False,
+ "onlypauseupload": False,
+ "onlypausedownload": False,
+ "onlypausechecking": False,
+ "upload_limit": 0,
+ "download_limit": 0,
+ "enable_upload_limit": False,
+ "enable_download_limit": False,
+ "op_site_ids": [],
+ }
+
+ 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))
diff --git a/plugins.v2/rsssubscribe/__init__.py b/plugins.v2/rsssubscribe/__init__.py
new file mode 100644
index 0000000..2fdc278
--- /dev/null
+++ b/plugins.v2/rsssubscribe/__init__.py
@@ -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))
\ No newline at end of file
diff --git a/plugins.v2/speedlimiter/__init__.py b/plugins.v2/speedlimiter/__init__.py
new file mode 100644
index 0000000..517f683
--- /dev/null
+++ b/plugins.v2/speedlimiter/__init__.py
@@ -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
diff --git a/plugins.v2/syncdownloadfiles/__init__.py b/plugins.v2/syncdownloadfiles/__init__.py
new file mode 100644
index 0000000..15c8a42
--- /dev/null
+++ b/plugins.v2/syncdownloadfiles/__init__.py
@@ -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))
diff --git a/plugins.v2/torrentremover/__init__.py b/plugins.v2/torrentremover/__init__.py
new file mode 100644
index 0000000..73848e0
--- /dev/null
+++ b/plugins.v2/torrentremover/__init__.py
@@ -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
diff --git a/plugins.v2/torrenttransfer/__init__.py b/plugins.v2/torrenttransfer/__init__.py
new file mode 100644
index 0000000..fa22714
--- /dev/null
+++ b/plugins.v2/torrenttransfer/__init__.py
@@ -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))
diff --git a/plugins.v2/trackereditor/__init__.py b/plugins.v2/trackereditor/__init__.py
new file mode 100644
index 0000000..872e657
--- /dev/null
+++ b/plugins.v2/trackereditor/__init__.py
@@ -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
+ )
diff --git a/plugins.v2/trcommand/__init__.py b/plugins.v2/trcommand/__init__.py
new file mode 100644
index 0000000..ff8af5c
--- /dev/null
+++ b/plugins.v2/trcommand/__init__.py
@@ -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
diff --git a/plugins.v2/vcbanimemonitor/__init__.py b/plugins.v2/vcbanimemonitor/__init__.py
new file mode 100644
index 0000000..f81f257
--- /dev/null
+++ b/plugins.v2/vcbanimemonitor/__init__.py
@@ -0,0 +1,1124 @@
+import datetime
+import re
+import shutil
+import threading
+import time
+import traceback
+from pathlib import Path
+from time import sleep
+from typing import List, Tuple, Dict, Any, Optional
+import pytz
+import qbittorrentapi
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.triggers.cron import CronTrigger
+from watchdog.events import FileSystemEventHandler
+from watchdog.observers import Observer
+from watchdog.observers.polling import PollingObserver
+from app import schemas
+from app.chain.media import MediaChain
+from app.chain.tmdb import TmdbChain
+from app.chain.transfer import TransferChain
+from app.core.config import settings
+from app.core.context import MediaInfo
+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.plugins import _PluginBase
+from app.plugins.vcbanimemonitor.remeta import ReMeta
+from app.schemas import Notification, NotificationType, TransferInfo
+from app.schemas.types import EventType, MediaType, SystemConfigKey
+from app.utils.string import StringUtils
+from app.utils.system import SystemUtils
+
+lock = threading.Lock()
+
+
+class FileMonitorHandler(FileSystemEventHandler):
+ """
+ 目录监控响应类
+ """
+
+ def __init__(self, monpath: str, sync: Any, **kwargs):
+ super(FileMonitorHandler, self).__init__(**kwargs)
+ self._watch_path = monpath
+ self.sync = sync
+
+ def on_created(self, event):
+ self.sync.event_handler(event=event, text="创建",
+ mon_path=self._watch_path, event_path=event.src_path)
+
+ def on_moved(self, event):
+ self.sync.event_handler(event=event, text="移动",
+ mon_path=self._watch_path, event_path=event.dest_path)
+
+
+class TorrentHandler(FileSystemEventHandler):
+ def __init__(self, monpath: str, sync: Any, **kwargs):
+ super(TorrentHandler, self).__init__(**kwargs)
+ self._watch_path = monpath
+ self.sync = sync
+
+ def on_created(self, event):
+ self.sync.torrent_event(event=event, text="创建",
+ mon_path=self._watch_path)
+
+ def on_moved(self, event):
+ self.sync.torrent_event(event=event, text="移动",
+ mon_path=self._watch_path)
+
+
+class VCBAnimeMonitor(_PluginBase):
+ # 插件名称
+ plugin_name = "整理VCB动漫压制组作品"
+ # 插件描述
+ plugin_desc = "一款辅助整理&提高识别VCB-Stuido动漫压制组作品的插件"
+ # 插件图标
+ plugin_icon = "vcbmonitor.png"
+ # 插件版本
+ plugin_version = "1.8.2.2"
+ # 插件作者
+ plugin_author = "pixel@qingwa"
+ # 作者主页
+ author_url = "https://github.com/Pixel-LH"
+ # 插件配置项ID前缀
+ plugin_config_prefix = "vcbanimemonitor_"
+ # 加载顺序
+ plugin_order = 4
+ # 可使用的用户级别
+ auth_level = 2
+
+ # 私有属性
+ _switch_ova = False
+ _torrents_path = None
+ new_save_path = None
+ qb = None
+ _scheduler = None
+ transferhis = None
+ downloadhis = None
+ transferchian = None
+ tmdbchain = None
+ mediaChain = None
+ _observer = []
+ _enabled = False
+ _notify = False
+ _onlyonce = False
+ _cron = None
+ _size = 0
+ _scrape = True
+ # 模式 compatibility/fast
+ _mode = "fast"
+ # 转移方式
+ _transfer_type = settings.TRANSFER_TYPE
+ _monitor_dirs = ""
+ _exclude_keywords = ""
+ _interval: int = 10
+ # 存储源目录与目的目录关系
+ _dirconf: Dict[str, Optional[Path]] = {}
+ # 存储源目录转移方式
+ _transferconf: Dict[str, Optional[str]] = {}
+ _medias = {}
+ # 退出事件
+ _event = threading.Event()
+
+ def init_plugin(self, config: dict = None):
+ self.transferhis = TransferHistoryOper()
+ self.downloadhis = DownloadHistoryOper()
+ self.transferchian = TransferChain()
+ self.mediaChain = MediaChain()
+ self.tmdbchain = TmdbChain()
+ # 清空配置
+ self._dirconf = {}
+ self._transferconf = {}
+
+ # 读取配置
+ if config:
+ self._enabled = config.get("enabled")
+ self._notify = config.get("notify")
+ self._onlyonce = config.get("onlyonce")
+ self._mode = config.get("mode")
+ self._transfer_type = config.get("transfer_type")
+ self._monitor_dirs = config.get("monitor_dirs") or ""
+ self._exclude_keywords = config.get("exclude_keywords") or ""
+ self._interval = config.get("interval") or 10
+ self._cron = config.get("cron")
+ self._size = config.get("size") or 0
+ self._scrape = config.get("scrape")
+ self._switch_ova = config.get("ova")
+ self._torrents_path = config.get("torrents_path") or ""
+
+ # 停止现有任务
+ self.stop_service()
+
+ if self._enabled or self._onlyonce:
+ # 定时服务管理器
+ self._scheduler = BackgroundScheduler(timezone=settings.TZ)
+ # 追加入库消息统一发送服务
+ self._scheduler.add_job(self.send_msg, trigger='interval', seconds=15)
+ self.qb = Qbittorrent()
+
+ # 读取目录配置
+ monitor_dirs = self._monitor_dirs.split("\n")
+ if not monitor_dirs:
+ return
+
+ # 启用种子目录监控
+ if self._torrents_path and Path(self._torrents_path).exists() and self._enabled:
+ # 只取第一个目录作为新的保存
+ try:
+ first_path = monitor_dirs[0]
+ if SystemUtils.is_windows():
+ self.new_save_path = first_path.split(':')[0] + ":" + first_path.split(':')[1]
+ else:
+ self.new_save_path = first_path.split(':')[0]
+ except Exception:
+ logger.error(f"目录保存失败,请检查输入目录是否合法")
+ # print(self.new_save_path)
+ try:
+ observer = Observer()
+ self._observer.append(observer)
+ observer.schedule(TorrentHandler(monpath=self._torrents_path, sync=self), path=self._torrents_path,
+ recursive=True)
+ observer.daemon = True
+ observer.start()
+ logger.info(f"{self._torrents_path} 的种子目录监控服务启动,开启监控新增的VCB-Studio种子文件")
+ except Exception as e:
+ logger.debug(f"{self._torrents_path} 启动种子目录监控失败:{str(e)}")
+ else:
+ logger.info("种子目录为空,不转移qb中正在下载的VCB-Studio文件")
+
+ for mon_path in monitor_dirs:
+ # 格式源目录:目的目录
+ if not mon_path:
+ continue
+
+ # 自定义转移方式
+ _transfer_type = self._transfer_type
+ if mon_path.count("#") == 1:
+ _transfer_type = mon_path.split("#")[1]
+ mon_path = mon_path.split("#")[0]
+
+ # 存储目的目录
+ if SystemUtils.is_windows():
+ if mon_path.count(":") > 1:
+ paths = [mon_path.split(":")[0] + ":" + mon_path.split(":")[1],
+ mon_path.split(":")[2] + ":" + mon_path.split(":")[3]]
+ else:
+ paths = [mon_path]
+ else:
+ paths = mon_path.split(":")
+
+ # 目的目录
+ target_path = None
+ if len(paths) > 1:
+ mon_path = paths[0]
+ target_path = Path(paths[1])
+ self._dirconf[mon_path] = target_path
+ else:
+ self._dirconf[mon_path] = None
+
+ # 转移方式
+ self._transferconf[mon_path] = _transfer_type
+
+ # 启用目录监控
+ if self._enabled:
+ # 检查媒体库目录是不是下载目录的子目录
+ try:
+ if target_path and target_path.is_relative_to(Path(mon_path)):
+ logger.warn(f"{target_path} 是监控目录 {mon_path} 的子目录,无法监控")
+ self.systemmessage.put(f"{target_path} 是下载目录 {mon_path} 的子目录,无法监控",
+ title="整理VCB动漫压制组作品")
+ continue
+ except Exception as e:
+ logger.debug(str(e))
+ pass
+
+ try:
+ if self._mode == "compatibility":
+ # 兼容模式,目录同步性能降低且NAS不能休眠,但可以兼容挂载的远程共享目录如SMB
+ observer = PollingObserver(timeout=10)
+ else:
+ # 内部处理系统操作类型选择最优解
+ observer = Observer(timeout=10)
+ self._observer.append(observer)
+ observer.schedule(FileMonitorHandler(mon_path, self), path=mon_path, recursive=True)
+ observer.daemon = True
+ observer.start()
+ logger.info(f"{mon_path} 的目录监控服务启动")
+ except Exception as e:
+ err_msg = str(e)
+ if "inotify" in err_msg and "reached" in err_msg:
+ logger.warn(
+ f"目录监控服务启动出现异常:{err_msg},请在宿主机上(不是docker容器内)执行以下命令并重启:"
+ + """
+ echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf
+ echo fs.inotify.max_user_instances=524288 | sudo tee -a /etc/sysctl.conf
+ sudo sysctl -p
+ """)
+ else:
+ logger.error(f"{mon_path} 启动目录监控失败:{err_msg}")
+ self.systemmessage.put(f"{mon_path} 启动目录监控失败:{err_msg}", title="整理VCB动漫压制组作品")
+
+ # 运行一次定时服务
+ if self._onlyonce:
+ logger.info("目录监控服务启动,立即运行一次")
+ self._scheduler.add_job(func=self.sync_all, trigger='date',
+ run_date=datetime.datetime.now(
+ tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3)
+ )
+ # 关闭一次性开关
+ self._onlyonce = False
+ # 保存配置
+ self.__update_config()
+
+ # 启动定时服务
+ if self._scheduler.get_jobs():
+ self._scheduler.print_jobs()
+ self._scheduler.start()
+
+ def __update_config(self):
+ """
+ 更新配置
+ """
+ self.update_config({
+ "enabled": self._enabled,
+ "notify": self._notify,
+ "onlyonce": self._onlyonce,
+ "mode": self._mode,
+ "transfer_type": self._transfer_type,
+ "monitor_dirs": self._monitor_dirs,
+ "exclude_keywords": self._exclude_keywords,
+ "interval": self._interval,
+ "cron": self._cron,
+ "size": self._size,
+ "scrape": self._scrape,
+ "ova": self._switch_ova,
+ "torrents_path": self._torrents_path
+ })
+
+ def __save_data(self, key: str, value: Any):
+ self.save_data(key, value)
+
+ def __get_data(self, key: str):
+ return self.get_data(key)
+
+ def sync_all(self):
+ """
+ 立即运行一次,全量同步目录中所有文件
+ """
+ logger.info("开始全量同步监控目录 ...")
+ # 清空历史的ova记录
+ self.plugindata.truncate()
+
+ # 遍历所有监控目录
+ for mon_path in self._dirconf.keys():
+ # 遍历目录下所有文件
+ for file_path in SystemUtils.list_files(Path(mon_path), settings.RMT_MEDIAEXT):
+ self.__handle_file(event_path=str(file_path), mon_path=mon_path)
+
+ logger.info("全量同步监控目录完成!")
+
+ def event_handler(self, event, mon_path: str, text: str, event_path: str):
+ """
+ 处理文件变化
+ :param event: 事件
+ :param mon_path: 监控目录
+ :param text: 事件描述
+ :param event_path: 事件文件路径
+ """
+ if not event.is_directory:
+ # 文件发生变化
+ logger.debug("文件%s:%s" % (text, event_path))
+ self.__handle_file(event_path=event_path, mon_path=mon_path)
+
+ def __handle_file(self, event_path: str, mon_path: str):
+ """
+ 同步一个文件
+ :param event_path: 事件文件路径
+ :param mon_path: 监控目录
+ """
+ file_path = Path(event_path)
+ try:
+ if not file_path.exists():
+ return
+ # 全程加锁
+ with lock:
+ transfer_history = self.transferhis.get_by_src(event_path)
+ if transfer_history:
+ logger.debug("文件已处理过:%s" % event_path)
+ return
+
+ # 回收站及隐藏的文件不处理
+ if event_path.find('/@Recycle/') != -1 \
+ or event_path.find('/#recycle/') != -1 \
+ or event_path.find('/.') != -1 \
+ or event_path.find('/@eaDir') != -1:
+ logger.debug(f"{event_path} 是回收站或隐藏的文件")
+ return
+
+ # 命中过滤关键字不处理
+ if self._exclude_keywords:
+ for keyword in self._exclude_keywords.split("\n"):
+ if keyword and re.findall(keyword, event_path):
+ logger.info(f"{event_path} 命中过滤关键字 {keyword},不处理")
+ return
+
+ # 整理屏蔽词不处理
+ transfer_exclude_words = self.systemconfig.get(SystemConfigKey.TransferExcludeWords)
+ if transfer_exclude_words:
+ for keyword in transfer_exclude_words:
+ if not keyword:
+ continue
+ if keyword and re.search(r"%s" % keyword, event_path, re.IGNORECASE):
+ logger.info(f"{event_path} 命中整理屏蔽词 {keyword},不处理")
+ return
+
+ # 不是媒体文件不处理
+ if file_path.suffix not in settings.RMT_MEDIAEXT:
+ logger.debug(f"{event_path} 不是媒体文件")
+ return
+
+ # 判断是不是蓝光目录
+ bluray_flag = False
+ if re.search(r"BDMV[/\\]STREAM", event_path, re.IGNORECASE):
+ bluray_flag = True
+ # 截取BDMV前面的路径
+ blurray_dir = event_path[:event_path.find("BDMV")]
+ file_path = Path(blurray_dir)
+ logger.info(f"{event_path} 是蓝光目录,更正文件路径为:{str(file_path)}")
+
+ # 查询历史记录,已转移的不处理
+ if self.transferhis.get_by_src(str(file_path)):
+ logger.info(f"{file_path} 已整理过")
+ return
+
+ # 元数据
+ if file_path.parent.name.lower() in ["sps", "scans", "cds", "previews", "extras"]:
+ logger.warn("位于特典或其他特殊目录下,跳过处理")
+ return
+
+ if 'VCB-Studio' not in file_path.stem.strip():
+ logger.warn("不属于VCB的作品,不处理!")
+ return
+
+ remeta = ReMeta(ova_switch=self._switch_ova)
+ file_meta = remeta.handel_file(file_path=file_path)
+ if file_meta:
+ if not file_meta.name:
+ logger.error(f"{file_path.name} 无法识别有效信息")
+ return
+ if remeta.is_ova and not self._switch_ova:
+ logger.warn(f"{file_path.name} 为OVA资源,未开启OVA开关,不处理")
+ return
+ if remeta.is_ova and self._switch_ova:
+ logger.info(f"{file_path.name} 为OVA资源,开始历史记录处理")
+ ova_history_ep_list = self.get_data(file_meta.title)
+ if ova_history_ep_list and isinstance(ova_history_ep_list, list):
+ ep = file_meta.begin_episode
+ if ep in ova_history_ep_list:
+ for i in range(1, 100):
+ if ep + i not in ova_history_ep_list:
+ ova_history_ep_list.append(ep + i)
+ file_meta.begin_episode = ep + i
+ logger.info(
+ f"{file_path.name} 为OVA资源,历史记录中已存在,自动识别为第{ep + i}集")
+ break
+ else:
+ ova_history_ep_list.append(ep)
+ self.save_data(file_meta.title, ova_history_ep_list)
+ else:
+ self.save_data(file_meta.title, [file_meta.begin_episode])
+ else:
+ return
+
+ # 判断文件大小
+ if self._size and float(self._size) > 0 and file_path.stat().st_size < float(self._size) * 1024 ** 3:
+ logger.info(f"{file_path} 文件大小小于监控文件大小,不处理")
+ return
+
+ # 查询转移目的目录
+ target: Path = self._dirconf.get(mon_path)
+ # 查询转移方式
+ transfer_type = self._transferconf.get(mon_path)
+
+ # 根据父路径获取下载历史
+ download_history = None
+ if bluray_flag:
+ # 蓝光原盘,按目录名查询
+ # FIXME 理论上DownloadHistory表中的path应该是全路径,但实际表中登记的数据只有目录名,暂按目录名查询
+ download_history = self.downloadhis.get_by_path(file_path.name)
+ else:
+ # 按文件全路径查询
+ download_file = self.downloadhis.get_file_by_fullpath(str(file_path))
+ if download_file:
+ download_history = self.downloadhis.get_by_hash(download_file.download_hash)
+
+ # 识别媒体信息
+ if download_history and download_history.tmdbid:
+ mediainfo: MediaInfo = self.mediaChain.recognize_media(mtype=MediaType(download_history.type),
+ tmdbid=download_history.tmdbid,
+ doubanid=download_history.doubanid)
+ else:
+ mediainfo: MediaInfo = self.mediaChain.recognize_by_meta(file_meta)
+
+ if not mediainfo:
+ logger.warn(f'未识别到媒体信息,标题:{file_meta.name}')
+ # self.save_data(plugin_id="vcbanimemonitor", key=file_meta.title, value="null")
+ # 新增转移成功历史记录
+ his = self.transferhis.add_fail(
+ src_path=file_path,
+ mode=transfer_type,
+ meta=file_meta
+ )
+ if self._notify:
+ self.chain.post_message(Notification(
+ mtype=NotificationType.Manual,
+ title=f"{file_path.name} 未识别到媒体信息,无法入库!\n"
+ f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别转移。"
+ ))
+ 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
+ logger.info(f"{file_path.name} 识别为:{mediainfo.type.value} {mediainfo.title_year}")
+
+ # 更新媒体图片
+ self.chain.obtain_images(mediainfo=mediainfo)
+
+ # 获取集数据
+ if mediainfo.type == MediaType.TV:
+ episodes_info = self.tmdbchain.tmdb_episodes(tmdbid=mediainfo.tmdb_id,
+ season=file_meta.begin_season or 1)
+ else:
+ episodes_info = None
+
+ # 获取下载Hash
+ download_hash = None
+ if download_history:
+ download_hash = download_history.download_hash
+
+ # 转移
+ transferinfo: TransferInfo = self.chain.transfer(mediainfo=mediainfo,
+ path=file_path,
+ transfer_type=transfer_type,
+ target=target,
+ meta=file_meta,
+ episodes_info=episodes_info)
+
+ if not transferinfo:
+ logger.error("文件转移模块运行失败")
+ return
+
+ if not transferinfo.success:
+ # 转移失败
+ logger.warn(f"{file_path.name} 入库失败:{transferinfo.message}")
+ # 新增转移失败历史记录
+ self.transferhis.add_fail(
+ src_path=file_path,
+ mode=transfer_type,
+ download_hash=download_hash,
+ meta=file_meta,
+ mediainfo=mediainfo,
+ transferinfo=transferinfo
+ )
+ if self._notify:
+ self.chain.post_message(Notification(
+ mtype=NotificationType.Manual,
+ title=f"{mediainfo.title_year}{file_meta.season_episode} 入库失败!",
+ text=f"原因:{transferinfo.message or '未知'}",
+ image=mediainfo.get_message_image()
+ ))
+ return
+
+ # 新增转移成功历史记录
+ self.transferhis.add_success(
+ src_path=file_path,
+ mode=transfer_type,
+ download_hash=download_hash,
+ meta=file_meta,
+ mediainfo=mediainfo,
+ transferinfo=transferinfo
+ )
+
+ # 刮削单个文件
+ if self._scrape:
+ self.chain.scrape_metadata(path=transferinfo.target_path,
+ mediainfo=mediainfo,
+ transfer_type=transfer_type)
+
+ """
+ {
+ "title_year season": {
+ "files": [
+ {
+ "path":,
+ "mediainfo":,
+ "file_meta":,
+ "transferinfo":
+ }
+ ],
+ "time": "2023-08-24 23:23:23.332"
+ }
+ }
+ """
+ # 发送消息汇总
+ media_list = self._medias.get(mediainfo.title_year + " " + file_meta.season) or {}
+ if media_list:
+ media_files = media_list.get("files") or []
+ if media_files:
+ file_exists = False
+ for file in media_files:
+ if str(file_path) == file.get("path"):
+ file_exists = True
+ break
+ if not file_exists:
+ media_files.append({
+ "path": str(file_path),
+ "mediainfo": mediainfo,
+ "file_meta": file_meta,
+ "transferinfo": transferinfo
+ })
+ else:
+ media_files = [
+ {
+ "path": str(file_path),
+ "mediainfo": mediainfo,
+ "file_meta": file_meta,
+ "transferinfo": transferinfo
+ }
+ ]
+ media_list = {
+ "files": media_files,
+ "time": datetime.datetime.now()
+ }
+ else:
+ media_list = {
+ "files": [
+ {
+ "path": str(file_path),
+ "mediainfo": mediainfo,
+ "file_meta": file_meta,
+ "transferinfo": transferinfo
+ }
+ ],
+ "time": datetime.datetime.now()
+ }
+ self._medias[mediainfo.title_year + " " + file_meta.season] = media_list
+
+ # 广播事件
+ self.eventmanager.send_event(EventType.TransferComplete, {
+ 'meta': file_meta,
+ 'mediainfo': mediainfo,
+ 'transferinfo': transferinfo
+ })
+
+ # 移动模式删除空目录
+ if transfer_type == "move":
+ for file_dir in file_path.parents:
+ if len(str(file_dir)) <= len(str(Path(mon_path))):
+ # 重要,删除到监控目录为止
+ break
+ files = SystemUtils.list_files(file_dir, settings.RMT_MEDIAEXT)
+ if not files:
+ logger.warn(f"移动模式,删除空目录:{file_dir}")
+ shutil.rmtree(file_dir, ignore_errors=True)
+
+ except Exception as e:
+ logger.error("目录监控发生错误:%s - %s" % (str(e), traceback.format_exc()))
+
+ def torrent_event(self, event, mon_path: str, text: str):
+ """
+ 处理种子文件
+ :param mon_path: 种子目录
+ """
+ evc_path = Path(event.src_path)
+ if not event.is_directory and (evc_path.suffix == ".torrent" or str(evc_path).split('.')[1] == "torrent"):
+ # 文件发生变化
+ logger.debug("文件%s:%s" % (text, mon_path))
+ self.__handle_torrent(torrent_path=self._torrents_path)
+ else:
+ logger.debug("不是种子文件:%s" % mon_path)
+
+ def __handle_torrent(self, torrent_path: str):
+ torrent_path = Path(torrent_path)
+ try:
+ if not torrent_path.exists():
+ return
+ # 只处理刚刚添加的种子也就是获取正在下载的种子
+ # 等待种子文件下载完成
+ time.sleep(5)
+ with lock:
+ torrents = self.qb.get_downloading_torrents()
+ for torrent in torrents:
+ if "VCB-Studio" in torrent.name:
+ logger.info(f"开始转移qb中正在下载的VCB资源,转移目录为:{self.new_save_path}")
+ # 原本存在的暂停的种子不处理
+ if torrent.state_enum == qbittorrentapi.TorrentState.PAUSED_DOWNLOAD:
+ continue
+ if torrent.save_path == self.new_save_path:
+ continue
+ torrent.pause()
+ torrent.set_save_path(save_path=self.new_save_path)
+ torrent.resume()
+ else:
+ continue
+ except qbittorrentapi.exceptions.APIError as e:
+ logger.error(f"VCB辅助整理模块转移qb文件移动失败:{e}")
+
+ def send_msg(self):
+ """
+ 定时检查是否有媒体处理完,发送统一消息
+ """
+ if not self._medias or not self._medias.keys():
+ return
+
+ # 遍历检查是否已刮削完,发送消息
+ for medis_title_year_season in list(self._medias.keys()):
+ media_list = self._medias.get(medis_title_year_season)
+ logger.info(f"开始处理媒体 {medis_title_year_season} 消息")
+
+ if not media_list:
+ continue
+
+ # 获取最后更新时间
+ last_update_time = media_list.get("time")
+ media_files = media_list.get("files")
+ if not last_update_time or not media_files:
+ continue
+
+ transferinfo = media_files[0].get("transferinfo")
+ file_meta = media_files[0].get("file_meta")
+ mediainfo = media_files[0].get("mediainfo")
+ # 判断剧集最后更新时间距现在是已超过10秒或者电影,发送消息
+ if (datetime.datetime.now() - last_update_time).total_seconds() > int(self._interval) \
+ or mediainfo.type == MediaType.MOVIE:
+ # 发送通知
+ if self._notify:
+
+ # 汇总处理文件总大小
+ total_size = 0
+ file_count = 0
+
+ # 剧集汇总
+ episodes = []
+ for file in media_files:
+ transferinfo = file.get("transferinfo")
+ total_size += transferinfo.total_size
+ file_count += 1
+
+ file_meta = file.get("file_meta")
+ if file_meta and file_meta.begin_episode:
+ episodes.append(file_meta.begin_episode)
+
+ transferinfo.total_size = total_size
+ # 汇总处理文件数量
+ transferinfo.file_count = file_count
+
+ # 剧集季集信息 S01 E01-E04 || S01 E01、E02、E04
+ season_episode = None
+ # 处理文件多,说明是剧集,显示季入库消息
+ if mediainfo.type == MediaType.TV:
+ # 季集文本
+ season_episode = f"{file_meta.season} {StringUtils.format_ep(episodes)}"
+ # 发送消息
+ self.transferchian.send_transfer_message(meta=file_meta,
+ mediainfo=mediainfo,
+ transferinfo=transferinfo,
+ season_episode=season_episode)
+ # 发送完消息,移出key
+ del self._medias[medis_title_year_season]
+ continue
+
+ def get_state(self) -> bool:
+ return self._enabled
+
+ 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": "vcbanimemonitor",
+ "name": "vcbanimemonitor",
+ "trigger": CronTrigger.from_crontab(self._cron),
+ "func": self.sync_all,
+ "kwargs": {}
+ }]
+ return []
+
+ def sync(self) -> schemas.Response:
+ """
+ API调用目录同步
+ """
+ self.sync_all()
+ return schemas.Response(success=True)
+
+ def get_api(self) -> List[Dict[str, Any]]:
+ pass
+
+ def get_command(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': 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': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'ova',
+ 'label': '开启识别OVA/OAD文件',
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSwitch',
+ 'props': {
+ 'model': 'scrape',
+ 'label': '刮削元数据',
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'model': 'mode',
+ 'label': '监控模式',
+ 'items': [
+ {'title': '兼容模式', 'value': 'compatibility'},
+ {'title': '性能模式', 'value': 'fast'}
+ ]
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VSelect',
+ 'props': {
+ 'model': 'transfer_type',
+ 'label': '转移方式',
+ 'items': [
+ {'title': '移动', 'value': 'move'},
+ {'title': '复制', 'value': 'copy'},
+ {'title': '硬链接', 'value': 'link'},
+ {'title': '软链接', 'value': 'softlink'},
+ {'title': 'Rclone复制', 'value': 'rclone_copy'},
+ {'title': 'Rclone移动', 'value': 'rclone_move'}
+ ]
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'interval',
+ 'label': '入库消息延迟',
+ 'placeholder': '10'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'cron',
+ 'label': '定时全量同步周期',
+ 'placeholder': '5位cron表达式,留空关闭'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'size',
+ 'label': '监控文件大小(GB)',
+ 'placeholder': '0'
+ }
+ }
+ ]
+ },
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ 'md': 4
+ },
+ 'content': [
+ {
+ 'component': 'VTextField',
+ 'props': {
+ 'model': 'torrents_path',
+ 'label': '监控种子目录',
+ 'placeholder': '填入路径代表启用'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'monitor_dirs',
+ 'label': '监控目录',
+ 'rows': 4,
+ 'placeholder': '每一行一个目录,支持以下几种配置方式,转移方式支持 move、copy、link、softlink、rclone_copy、rclone_move:\n'
+ '监控目录\n'
+ '监控目录#转移方式\n'
+ '监控目录:转移目的目录\n'
+ '监控目录:转移目的目录#转移方式'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VTextarea',
+ 'props': {
+ 'model': 'exclude_keywords',
+ 'label': '排除关键词',
+ 'rows': 2,
+ 'placeholder': '每一行一个关键词'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '核心用法与目录同步插件相同,不同点在于只识别处理VCB-Studio资源。'
+ '默认不处理SPs、CDs、SCans目录下的文件,OVA/OAD集数暂时根据入库顺序累加命名,'
+ '因此不保证与TMDB集数匹配。部分季度以罗马音音译为名的作品暂时无法识别出准确季度。'
+ '有想法,有问题欢迎点击插件作者主页提issue!'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ 'component': 'VRow',
+ 'content': [
+ {
+ 'component': 'VCol',
+ 'props': {
+ 'cols': 12,
+ },
+ 'content': [
+ {
+ 'component': 'VAlert',
+ 'props': {
+ 'type': 'info',
+ 'variant': 'tonal',
+ 'text': '最佳使用方式:监控目录单独设置一个作为保存VCB-Studio资源的目录,'
+ '填入监控种子目录,开启后会将正在QB(仅支持QB)下载器内正在下载的VCB-Studio资源转移到监控目录实现自动整理('
+ '仅支持第一个监控目录),'
+ '监控种子目录为空则不转移文件'
+ }
+ }
+ ]
+ }
+ ]
+ },
+ ]
+ },
+ ], {
+ "enabled": False,
+ "notify": False,
+ "onlyonce": False,
+ "mode": "fast",
+ "transfer_type": settings.TRANSFER_TYPE,
+ "monitor_dirs": "",
+ "exclude_keywords": "",
+ "interval": 10,
+ "cron": "",
+ "size": 0,
+ "ova": False,
+ "torrents_path": "",
+ }
+
+ def get_page(self) -> List[dict]:
+ pass
+
+ def stop_service(self):
+ """
+ 退出插件
+ """
+ if self._observer:
+ for observer in self._observer:
+ try:
+ observer.stop()
+ observer.join()
+ except Exception as e:
+ print(str(e))
+ self._observer = []
+ if self._scheduler:
+ self._scheduler.remove_all_jobs()
+ if self._scheduler.running:
+ self._event.set()
+ self._scheduler.shutdown()
+ self._event.clear()
+ self._scheduler = None
diff --git a/plugins.v2/vcbanimemonitor/remeta.py b/plugins.v2/vcbanimemonitor/remeta.py
new file mode 100644
index 0000000..ea261eb
--- /dev/null
+++ b/plugins.v2/vcbanimemonitor/remeta.py
@@ -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"))
diff --git a/plugins/removelink/__init__.py b/plugins/removelink/__init__.py
index df8bb29..f34419b 100644
--- a/plugins/removelink/__init__.py
+++ b/plugins/removelink/__init__.py
@@ -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(