From 32accf7b55dab130da2d0235b555f12176f2e97b Mon Sep 17 00:00:00 2001 From: jxxghp Date: Thu, 26 Sep 2024 17:22:39 +0800 Subject: [PATCH] init v2 plugins --- package.json | 39 +- plugins.v2/autoclean/__init__.py | 603 +++ plugins.v2/bestfilmversion/__init__.py | 708 ++++ plugins.v2/brushflow/__init__.py | 4054 +++++++++++++++++++++ plugins.v2/cleaninvalidseed/__init__.py | 918 +++++ plugins.v2/clouddiskdel/__init__.py | 540 +++ plugins.v2/configcenter/__init__.py | 597 +++ plugins.v2/crossseed/__init__.py | 1232 +++++++ plugins.v2/diagparamadjust/__init__.py | 456 +++ plugins.v2/downloadsitetag/__init__.py | 812 +++++ plugins.v2/episodegroupmeta/__init__.py | 872 +++++ plugins.v2/ffmpegthumb/__init__.py | 360 ++ plugins.v2/ffmpegthumb/ffmpeg_helper.py | 82 + plugins.v2/iyuuautoseed/__init__.py | 1246 +++++++ plugins.v2/iyuuautoseed/iyuu_helper.py | 115 + plugins.v2/libraryscraper/__init__.py | 437 +++ plugins.v2/mediaservermsg/__init__.py | 295 ++ plugins.v2/mediaserverrefresh/__init__.py | 170 + plugins.v2/mediasyncdel/__init__.py | 1589 ++++++++ plugins.v2/messageforward/__init__.py | 430 +++ plugins.v2/personmeta/__init__.py | 1026 ++++++ plugins.v2/qbcommand/__init__.py | 1171 ++++++ plugins.v2/rsssubscribe/__init__.py | 775 ++++ plugins.v2/speedlimiter/__init__.py | 660 ++++ plugins.v2/syncdownloadfiles/__init__.py | 579 +++ plugins.v2/torrentremover/__init__.py | 816 +++++ plugins.v2/torrenttransfer/__init__.py | 932 +++++ plugins.v2/trackereditor/__init__.py | 454 +++ plugins.v2/trcommand/__init__.py | 732 ++++ plugins.v2/vcbanimemonitor/__init__.py | 1124 ++++++ plugins.v2/vcbanimemonitor/remeta.py | 284 ++ plugins/removelink/__init__.py | 3 +- 32 files changed, 24079 insertions(+), 32 deletions(-) create mode 100644 plugins.v2/autoclean/__init__.py create mode 100644 plugins.v2/bestfilmversion/__init__.py create mode 100644 plugins.v2/brushflow/__init__.py create mode 100644 plugins.v2/cleaninvalidseed/__init__.py create mode 100644 plugins.v2/clouddiskdel/__init__.py create mode 100644 plugins.v2/configcenter/__init__.py create mode 100644 plugins.v2/crossseed/__init__.py create mode 100644 plugins.v2/diagparamadjust/__init__.py create mode 100644 plugins.v2/downloadsitetag/__init__.py create mode 100644 plugins.v2/episodegroupmeta/__init__.py create mode 100644 plugins.v2/ffmpegthumb/__init__.py create mode 100644 plugins.v2/ffmpegthumb/ffmpeg_helper.py create mode 100644 plugins.v2/iyuuautoseed/__init__.py create mode 100644 plugins.v2/iyuuautoseed/iyuu_helper.py create mode 100644 plugins.v2/libraryscraper/__init__.py create mode 100644 plugins.v2/mediaservermsg/__init__.py create mode 100644 plugins.v2/mediaserverrefresh/__init__.py create mode 100644 plugins.v2/mediasyncdel/__init__.py create mode 100644 plugins.v2/messageforward/__init__.py create mode 100644 plugins.v2/personmeta/__init__.py create mode 100644 plugins.v2/qbcommand/__init__.py create mode 100644 plugins.v2/rsssubscribe/__init__.py create mode 100644 plugins.v2/speedlimiter/__init__.py create mode 100644 plugins.v2/syncdownloadfiles/__init__.py create mode 100644 plugins.v2/torrentremover/__init__.py create mode 100644 plugins.v2/torrenttransfer/__init__.py create mode 100644 plugins.v2/trackereditor/__init__.py create mode 100644 plugins.v2/trcommand/__init__.py create mode 100644 plugins.v2/vcbanimemonitor/__init__.py create mode 100644 plugins.v2/vcbanimemonitor/remeta.py 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(