init v2 plugins

This commit is contained in:
jxxghp
2024-09-26 17:22:39 +08:00
parent e63c4a7729
commit 32accf7b55
32 changed files with 24079 additions and 32 deletions

View File

@@ -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": "飞书机器人消息通知",

View File

@@ -0,0 +1,603 @@
import time
from collections import defaultdict
from datetime import datetime, timedelta
from pathlib import Path
import pytz
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from app.chain.transfer import TransferChain
from app.core.config import settings
from app.core.event import eventmanager
from app.db.downloadhistory_oper import DownloadHistoryOper
from app.db.transferhistory_oper import TransferHistoryOper
from app.plugins import _PluginBase
from typing import Any, List, Dict, Tuple, Optional
from app.log import logger
from app.schemas import NotificationType, DownloadHistory
from app.schemas.types import EventType
class AutoClean(_PluginBase):
# 插件名称
plugin_name = "定时清理媒体库"
# 插件描述
plugin_desc = "定时清理用户下载的种子、源文件、媒体库文件。"
# 插件图标
plugin_icon = "clean.png"
# 插件版本
plugin_version = "1.1"
# 插件作者
plugin_author = "thsrite"
# 作者主页
author_url = "https://github.com/thsrite"
# 插件配置项ID前缀
plugin_config_prefix = "autoclean_"
# 加载顺序
plugin_order = 23
# 可使用的用户级别
auth_level = 2
# 私有属性
_enabled = False
# 任务执行间隔
_cron = None
_type = None
_onlyonce = False
_notify = False
_cleantype = None
_cleandate = None
_cleanuser = None
_downloadhis = None
_transferhis = None
# 定时器
_scheduler: Optional[BackgroundScheduler] = None
def init_plugin(self, config: dict = None):
# 停止现有任务
self.stop_service()
if config:
self._enabled = config.get("enabled")
self._cron = config.get("cron")
self._onlyonce = config.get("onlyonce")
self._notify = config.get("notify")
self._cleantype = config.get("cleantype")
self._cleandate = config.get("cleandate")
self._cleanuser = config.get("cleanuser")
# 加载模块
if self._enabled:
self._downloadhis = DownloadHistoryOper()
self._transferhis = TransferHistoryOper()
if self._onlyonce:
# 定时服务
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
logger.info(f"定时清理媒体库服务启动,立即运行一次")
self._scheduler.add_job(func=self.__clean, trigger='date',
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
name="定时清理媒体库")
# 关闭一次性开关
self._onlyonce = False
self.update_config({
"onlyonce": False,
"cron": self._cron,
"cleantype": self._cleantype,
"cleandate": self._cleandate,
"enabled": self._enabled,
"cleanuser": self._cleanuser,
"notify": self._notify,
})
# 启动任务
if self._scheduler.get_jobs():
self._scheduler.print_jobs()
self._scheduler.start()
def __get_clean_date(self, deltatime: str = None):
# 清理日期
current_time = datetime.now()
if deltatime:
days_ago = current_time - timedelta(days=int(deltatime))
else:
days_ago = current_time - timedelta(days=int(self._cleandate))
return days_ago.strftime("%Y-%m-%d")
def __clean(self):
"""
定时清理媒体库
"""
if not self._cleandate:
logger.error("未配置媒体库全局清理时间,停止运行")
return
# 查询用户清理日期之前的下载历史,不填默认清理全部用户的下载
if not self._cleanuser:
clean_date = self.__get_clean_date()
downloadhis_list = self._downloadhis.list_by_user_date(date=clean_date)
logger.info(f'获取到日期 {clean_date} 之前的下载历史 {len(downloadhis_list)}')
self.__clean_history(date=clean_date, clean_type=self._cleantype, downloadhis_list=downloadhis_list)
# 根据填写的信息判断怎么清理
else:
# username:days#cleantype
clean_type = self._cleantype
clean_date = self._cleandate
# 1.3.7版本及之前处理多位用户
if str(self._cleanuser).count(','):
for username in str(self._cleanuser).split(","):
downloadhis_list = self._downloadhis.list_by_user_date(date=clean_date,
username=username)
logger.info(
f'获取到用户 {username} 日期 {clean_date} 之前的下载历史 {len(downloadhis_list)}')
self.__clean_history(date=clean_date, clean_type=self._cleantype, downloadhis_list=downloadhis_list)
return
for userinfo in str(self._cleanuser).split("\n"):
if userinfo.count('#'):
clean_type = userinfo.split('#')[1]
username_and_days = userinfo.split('#')[0]
else:
username_and_days = userinfo
if username_and_days.count(':'):
clean_date = username_and_days.split(':')[1]
username = username_and_days.split(':')[0]
else:
username = userinfo
# 转strftime
clean_date = self.__get_clean_date(clean_date)
logger.info(f'{username} 使用 {clean_type} 清理方式,清理 {clean_date} 之前的下载历史')
downloadhis_list = self._downloadhis.list_by_user_date(date=clean_date,
username=username)
logger.info(
f'获取到用户 {username} 日期 {clean_date} 之前的下载历史 {len(downloadhis_list)}')
self.__clean_history(date=clean_date, clean_type=clean_type,
downloadhis_list=downloadhis_list)
def __clean_history(self, date: str, clean_type: str, downloadhis_list: List[DownloadHistory]):
"""
清理下载历史、转移记录
"""
if not downloadhis_list:
logger.warn(f"未获取到日期 {date} 之前的下载记录,停止运行")
return
# 读取历史记录
pulgin_history = self.get_data('history') or []
# 创建一个字典来保存分组结果
downloadhis_grouped_dict: Dict[tuple, List[DownloadHistory]] = defaultdict(list)
# 遍历DownloadHistory对象列表
for downloadhis in downloadhis_list:
# 获取type和tmdbid的值
dtype = downloadhis.type
tmdbid = downloadhis.tmdbid
# 将DownloadHistory对象添加到对应分组的列表中
downloadhis_grouped_dict[(dtype, tmdbid)].append(downloadhis)
# 输出分组结果
for key, downloadhis_list in downloadhis_grouped_dict.items():
logger.info(f"开始清理 {key}")
del_transferhis_cnt = 0
del_media_name = downloadhis_list[0].title
del_media_user = downloadhis_list[0].username
del_media_type = downloadhis_list[0].type
del_media_year = downloadhis_list[0].year
del_media_season = downloadhis_list[0].seasons
del_media_episode = downloadhis_list[0].episodes
del_image = downloadhis_list[0].image
for downloadhis in downloadhis_list:
if not downloadhis.download_hash:
logger.debug(f'下载历史 {downloadhis.id} {downloadhis.title} 未获取到download_hash跳过处理')
continue
# 根据hash获取转移记录
transferhis_list = self._transferhis.list_by_hash(download_hash=downloadhis.download_hash)
if not transferhis_list:
logger.warn(f"下载历史 {downloadhis.download_hash} 未查询到转移记录,跳过处理")
continue
for history in transferhis_list:
# 册除媒体库文件
if clean_type in ["dest", "all"]:
TransferChain().delete_files(Path(history.dest))
# 删除记录
self._transferhis.delete(history.id)
# 删除源文件
if clean_type in ["src", "all"]:
TransferChain().delete_files(Path(history.src))
# 发送事件
eventmanager.send_event(
EventType.DownloadFileDeleted,
{
"src": history.src
}
)
# 累加删除数量
del_transferhis_cnt += len(transferhis_list)
if del_transferhis_cnt:
# 发送消息
if self._notify:
self.post_message(
mtype=NotificationType.MediaServer,
title="【定时清理媒体库任务完成】",
text=f"清理媒体名称 {del_media_name}\n"
f"下载媒体用户 {del_media_user}\n"
f"删除历史记录 {del_transferhis_cnt}")
pulgin_history.append({
"type": del_media_type,
"title": del_media_name,
"year": del_media_year,
"season": del_media_season,
"episode": del_media_episode,
"image": del_image,
"del_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time()))
})
# 保存历史
self.save_data("history", pulgin_history)
def get_state(self) -> bool:
return self._enabled
@staticmethod
def get_command() -> List[Dict[str, Any]]:
pass
def get_api(self) -> List[Dict[str, Any]]:
pass
def get_service(self) -> List[Dict[str, Any]]:
"""
注册插件公共服务
[{
"id": "服务ID",
"name": "服务名称",
"trigger": "触发器cron/interval/date/CronTrigger.from_crontab()",
"func": self.xxx,
"kwargs": {} # 定时器参数
}]
"""
if self._enabled and self._cron:
return [
{
"id": "AutoClean",
"name": "清理媒体库定时服务",
"trigger": CronTrigger.from_crontab(self._cron),
"func": self.__clean,
"kwargs": {}
}
]
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
"""
拼装插件配置页面需要返回两块数据1、页面配置2、数据结构
"""
return [
{
'component': 'VForm',
'content': [
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'enabled',
'label': '启用插件',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'onlyonce',
'label': '立即运行一次',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'notify',
'label': '开启通知',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'cron',
'label': '执行周期',
'placeholder': '0 0 ? ? ?'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSelect',
'props': {
'model': 'cleantype',
'label': '全局清理方式',
'items': [
{'title': '媒体库文件', 'value': 'dest'},
{'title': '源文件', 'value': 'src'},
{'title': '所有文件', 'value': 'all'},
]
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'cleandate',
'label': '全局清理日期',
'placeholder': '清理多少天之前的下载记录(天)'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VTextarea',
'props': {
'model': 'cleanuser',
'label': '清理配置',
'rows': 6,
'placeholder': '每一行一个配置,支持以下几种配置方式,清理方式支持 src、desc、all 分别对应源文件,媒体库文件,所有文件\n'
'用户名缺省默认清理所有用户(慎重留空),清理天数缺省默认使用全局清理天数,清理方式缺省默认使用全局清理方式\n'
'用户名/插件名豆瓣想看、豆瓣榜单、RSS订阅\n'
'用户名#清理方式\n'
'用户名:清理天数\n'
'用户名:清理天数#清理方式',
}
}
]
}
]
}
]
}
], {
"enabled": False,
"onlyonce": False,
"notify": False,
"cleantype": "dest",
"cron": "",
"cleanuser": "",
"cleandate": 30
}
def get_page(self) -> List[dict]:
"""
拼装插件详情页面,需要返回页面配置,同时附带数据
"""
# 查询同步详情
historys = self.get_data('history')
if not historys:
return [
{
'component': 'div',
'text': '暂无数据',
'props': {
'class': 'text-center',
}
}
]
# 数据按时间降序排序
historys = sorted(historys, key=lambda x: x.get('del_time'), reverse=True)
# 拼装页面
contents = []
for history in historys:
htype = history.get("type")
title = history.get("title")
year = history.get("year")
season = history.get("season")
episode = history.get("episode")
image = history.get("image")
del_time = history.get("del_time")
if season:
sub_contents = [
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'类型:{htype}'
},
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'标题:{title}'
},
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'年份:{year}'
},
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'季:{season}'
},
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'集:{episode}'
},
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'时间:{del_time}'
}
]
else:
sub_contents = [
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'类型:{htype}'
},
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'标题:{title}'
},
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'年份:{year}'
},
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'时间:{del_time}'
}
]
contents.append(
{
'component': 'VCard',
'content': [
{
'component': 'div',
'props': {
'class': 'd-flex justify-space-start flex-nowrap flex-row',
},
'content': [
{
'component': 'div',
'content': [
{
'component': 'VImg',
'props': {
'src': image,
'height': 120,
'width': 80,
'aspect-ratio': '2/3',
'class': 'object-cover shadow ring-gray-500',
'cover': True
}
}
]
},
{
'component': 'div',
'content': sub_contents
}
]
}
]
}
)
return [
{
'component': 'div',
'props': {
'class': 'grid gap-3 grid-info-card',
},
'content': contents
}
]
def stop_service(self):
"""
退出插件
"""
try:
if self._scheduler:
self._scheduler.remove_all_jobs()
if self._scheduler.running:
self._scheduler.shutdown()
self._scheduler = None
except Exception as e:
logger.error("退出插件失败:%s" % str(e))

View File

@@ -0,0 +1,708 @@
from datetime import datetime, timedelta
from functools import reduce
from pathlib import Path
from threading import RLock
from typing import Optional, Any, List, Dict, Tuple
from xml.dom.minidom import parseString
import pytz
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from requests import Response
from app.chain.subscribe import SubscribeChain
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.event import eventmanager
from app.log import logger
from app.modules.emby import Emby
from app.modules.jellyfin import Jellyfin
from app.modules.plex import Plex
from app.plugins import _PluginBase
from app.schemas import WebhookEventInfo
from app.schemas.types import MediaType, EventType
from app.utils.http import RequestUtils
lock = RLock()
class BestFilmVersion(_PluginBase):
# 插件名称
plugin_name = "收藏洗版"
# 插件描述
plugin_desc = "Jellyfin/Emby/Plex点击收藏电影后自动订阅洗版。"
# 插件图标
plugin_icon = "like.jpg"
# 插件版本
plugin_version = "2.3"
# 插件作者
plugin_author = "wlj"
# 作者主页
author_url = "https://github.com/developer-wlj"
# 插件配置项ID前缀
plugin_config_prefix = "bestfilmversion_"
# 加载顺序
plugin_order = 13
# 可使用的用户级别
auth_level = 2
# 私有变量
_scheduler: Optional[BackgroundScheduler] = None
_cache_path: Optional[Path] = None
subscribechain = None
# 配置属性
_enabled: bool = False
_cron: str = ""
_notify: bool = False
_webhook_enabled: bool = False
_only_once: bool = False
def init_plugin(self, config: dict = None):
self._cache_path = settings.TEMP_PATH / "__best_film_version_cache__"
self.subscribechain = SubscribeChain()
# 停止现有任务
self.stop_service()
# 配置
if config:
self._enabled = config.get("enabled")
self._cron = config.get("cron")
self._notify = config.get("notify")
self._webhook_enabled = config.get("webhook_enabled")
self._only_once = config.get("only_once")
if self._only_once:
self._only_once = False
self.update_config({
"enabled": self._enabled,
"cron": self._cron,
"notify": self._notify,
"webhook_enabled": self._webhook_enabled,
"only_once": self._only_once
})
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
self._scheduler.add_job(self.sync, 'date',
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
name="立即运行收藏洗版")
# 启动任务
if self._scheduler.get_jobs():
self._scheduler.print_jobs()
self._scheduler.start()
def get_state(self) -> bool:
return self._enabled
@staticmethod
def get_command() -> List[Dict[str, Any]]:
pass
def get_api(self) -> List[Dict[str, Any]]:
"""
获取插件API
[{
"path": "/xx",
"endpoint": self.xxx,
"methods": ["GET", "POST"],
"summary": "API说明"
}]
"""
pass
def get_service(self) -> List[Dict[str, Any]]:
"""
注册插件公共服务
[{
"id": "服务ID",
"name": "服务名称",
"trigger": "触发器cron/interval/date/CronTrigger.from_crontab()",
"func": self.xxx,
"kwargs": {} # 定时器参数
}]
"""
if self._enabled and not self._webhook_enabled:
if self._cron:
return [{
"id": "BestFilmVersion",
"name": "收藏洗版定时服务",
"trigger": CronTrigger.from_crontab(self._cron),
"func": self.sync,
"kwargs": {}
}]
return [
{
"id": "BestFilmVersion",
"name": "收藏洗版定时服务",
"trigger": "interval",
"func": self.sync,
"kwargs": {
"minutes": 30
}
}
]
return []
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
"""
拼装插件配置页面需要返回两块数据1、页面配置2、数据结构
"""
return [
{
'component': 'VForm',
'content': [
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 3
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'enabled',
'label': '启用插件',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 3
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'notify',
'label': '发送通知',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 3
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'only_once',
'label': '立即运行一次',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 3
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'webhook_enabled',
'label': 'Webhook',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'cron',
'label': '执行周期',
'placeholder': '5位cron表达式留空自动'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VAlert',
'props': {
'type': 'info',
'variant': 'tonal',
'text': '支持主动定时获取媒体库数据和Webhook实时触发两种方式两者只能选其一'
'Webhook需要在媒体服务器设置发送Webhook报文。'
'Plex使用主动获取时建议执行周期设置大于1小时'
'收藏Api调用Plex官网接口有频率限制。'
}
}
]
}
]
}
]
}
], {
"enabled": False,
"notify": False,
"cron": "*/30 * * * *",
"webhook_enabled": False,
"only_once": False
}
def get_page(self) -> List[dict]:
"""
拼装插件详情页面,需要返回页面配置,同时附带数据
"""
# 查询同步详情
historys = self.get_data('history')
if not historys:
return [
{
'component': 'div',
'text': '暂无数据',
'props': {
'class': 'text-center',
}
}
]
# 数据按时间降序排序
historys = sorted(historys, key=lambda x: x.get('time'), reverse=True)
# 拼装页面
contents = []
for history in historys:
title = history.get("title")
poster = history.get("poster")
mtype = history.get("type")
time_str = history.get("time")
tmdbid = history.get("tmdbid")
contents.append(
{
'component': 'VCard',
'content': [
{
'component': 'div',
'props': {
'class': 'd-flex justify-space-start flex-nowrap flex-row',
},
'content': [
{
'component': 'div',
'content': [
{
'component': 'VImg',
'props': {
'src': poster,
'height': 120,
'width': 80,
'aspect-ratio': '2/3',
'class': 'object-cover shadow ring-gray-500',
'cover': True
}
}
]
},
{
'component': 'div',
'content': [
{
'component': 'VCardTitle',
'props': {
'class': 'ps-1 pe-5 break-words whitespace-break-spaces'
},
'content': [
{
'component': 'a',
'props': {
'href': f"https://www.themoviedb.org/movie/{tmdbid}",
'target': '_blank'
},
'text': title
}
]
},
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'类型:{mtype}'
},
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'时间:{time_str}'
}
]
}
]
}
]
}
)
return [
{
'component': 'div',
'props': {
'class': 'grid gap-3 grid-info-card',
},
'content': contents
}
]
def stop_service(self):
"""
退出插件
"""
try:
if self._scheduler:
self._scheduler.remove_all_jobs()
if self._scheduler.running:
self._scheduler.shutdown()
self._scheduler = None
except Exception as e:
logger.error("退出插件失败:%s" % str(e))
def sync(self):
"""
通过流媒体管理工具收藏,自动洗版
"""
# 获取锁
_is_lock: bool = lock.acquire(timeout=60)
if not _is_lock:
return
try:
# 读取缓存
caches = self._cache_path.read_text().split("\n") if self._cache_path.exists() else []
# 读取历史记录
history = self.get_data('history') or []
# 媒体服务器类型,多个以,分隔
if not settings.MEDIASERVER:
return
media_servers = settings.MEDIASERVER.split(',')
# 读取收藏
all_items = {}
for media_server in media_servers:
if media_server == 'jellyfin':
all_items['jellyfin'] = self.jellyfin_get_items()
elif media_server == 'emby':
all_items['emby'] = self.emby_get_items()
else:
all_items['plex'] = self.plex_get_watchlist()
def function(y, x):
return y if (x['Name'] in [i['Name'] for i in y]) else (lambda z, u: (z.append(u), z))(y, x)[1]
# 处理所有结果
for server, all_item in all_items.items():
# all_item 根据电影名去重
result = reduce(function, all_item, [])
for data in result:
# 检查缓存
if data.get('Name') in caches:
continue
# 获取详情
if server == 'jellyfin':
item_info_resp = Jellyfin().get_iteminfo(itemid=data.get('Id'))
elif server == 'emby':
item_info_resp = Emby().get_iteminfo(itemid=data.get('Id'))
else:
item_info_resp = self.plex_get_iteminfo(itemid=data.get('Id'))
logger.debug(f'BestFilmVersion插件 item打印 {item_info_resp}')
if not item_info_resp:
continue
# 只接受Movie类型
if data.get('Type') != 'Movie':
continue
# 获取tmdb_id
tmdb_id = item_info_resp.get("tmdbid") if server == 'plex' else item_info_resp.tmdbid
if not tmdb_id:
continue
# 识别媒体信息
mediainfo: MediaInfo = self.chain.recognize_media(tmdbid=tmdb_id, mtype=MediaType.MOVIE)
if not mediainfo:
logger.warn(f'未识别到媒体信息,标题:{data.get("Name")}tmdbid{tmdb_id}')
continue
# 添加订阅
self.subscribechain.add(mtype=MediaType.MOVIE,
title=mediainfo.title,
year=mediainfo.year,
tmdbid=mediainfo.tmdb_id,
best_version=True,
username="收藏洗版",
exist_ok=True)
# 加入缓存
caches.append(data.get('Name'))
# 存储历史记录
if mediainfo.tmdb_id not in [h.get("tmdbid") for h in history]:
history.append({
"title": mediainfo.title,
"type": mediainfo.type.value,
"year": mediainfo.year,
"poster": mediainfo.get_poster_image(),
"overview": mediainfo.overview,
"tmdbid": mediainfo.tmdb_id,
"time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
})
# 保存历史记录
self.save_data('history', history)
# 保存缓存
self._cache_path.write_text("\n".join(caches))
finally:
lock.release()
def jellyfin_get_items(self) -> List[dict]:
# 获取所有user
users_url = "[HOST]Users?&apikey=[APIKEY]"
users = self.get_users(Jellyfin().get_data(users_url))
if not users:
logger.info(f"bestfilmversion/users_url: {users_url}")
return []
all_items = []
for user in users:
# 根据加入日期 降序排序
url = "[HOST]Users/" + user + "/Items?SortBy=DateCreated%2CSortName" \
"&SortOrder=Descending" \
"&Filters=IsFavorite" \
"&Recursive=true" \
"&Fields=PrimaryImageAspectRatio%2CBasicSyncInfo" \
"&CollapseBoxSetItems=false" \
"&ExcludeLocationTypes=Virtual" \
"&EnableTotalRecordCount=false" \
"&Limit=20" \
"&apikey=[APIKEY]"
resp = self.get_items(Jellyfin().get_data(url))
if not resp:
continue
all_items.extend(resp)
return all_items
def emby_get_items(self) -> List[dict]:
# 获取所有user
get_users_url = "[HOST]Users?&api_key=[APIKEY]"
users = self.get_users(Emby().get_data(get_users_url))
if not users:
return []
all_items = []
for user in users:
# 根据加入日期 降序排序
url = "[HOST]emby/Users/" + user + "/Items?SortBy=DateCreated%2CSortName" \
"&SortOrder=Descending" \
"&Filters=IsFavorite" \
"&Recursive=true" \
"&Fields=PrimaryImageAspectRatio%2CBasicSyncInfo" \
"&CollapseBoxSetItems=false" \
"&ExcludeLocationTypes=Virtual" \
"&EnableTotalRecordCount=false" \
"&Limit=20&api_key=[APIKEY]"
resp = self.get_items(Emby().get_data(url))
if not resp:
continue
all_items.extend(resp)
return all_items
@staticmethod
def get_items(resp: Response):
try:
if resp:
return resp.json().get("Items") or []
else:
return []
except Exception as e:
print(str(e))
return []
@staticmethod
def get_users(resp: Response):
try:
if resp:
return [data['Id'] for data in resp.json()]
else:
logger.error(f"BestFilmVersion/Users 未获取到返回数据")
return []
except Exception as e:
logger.error(f"连接BestFilmVersion/Users 出错:" + str(e))
return []
@staticmethod
def plex_get_watchlist() -> List[dict]:
# 根据加入日期 降序排序
url = f"https://metadata.provider.plex.tv/library/sections/watchlist/all?type=1&sort=addedAt%3Adesc" \
f"&X-Plex-Container-Start=0&X-Plex-Container-Size=50" \
f"&X-Plex-Token={settings.PLEX_TOKEN}"
res = []
try:
resp = RequestUtils().get_res(url=url)
if resp:
dom = parseString(resp.text)
# 获取文档元素对象
elem = dom.documentElement
# 获取 指定元素
eles = elem.getElementsByTagName('Video')
if not eles:
return []
for ele in eles:
data = {}
# 获取标签中内容
ele_id = ele.attributes['ratingKey'].nodeValue
ele_title = ele.attributes['title'].nodeValue
ele_type = ele.attributes['type'].nodeValue
_type = "Movie" if ele_type == "movie" else ""
data['Id'] = ele_id
data['Name'] = ele_title
data['Type'] = _type
res.append(data)
return res
else:
logger.error(f"Plex/Watchlist 未获取到返回数据")
return []
except Exception as e:
logger.error(f"连接Plex/Watchlist 出错:" + str(e))
return []
@staticmethod
def plex_get_iteminfo(itemid) -> dict:
url = f"https://metadata.provider.plex.tv/library/metadata/{itemid}" \
f"?X-Plex-Token={settings.PLEX_TOKEN}"
try:
resp = RequestUtils(accept_type="application/json, text/plain, */*").get_res(url=url)
if resp:
metadata = resp.json().get('MediaContainer').get('Metadata')
for item in metadata:
_guid = item.get('Guid')
if not _guid:
continue
id_list = [h.get('id') for h in _guid if h.get('id').__contains__("tmdb")]
if not id_list:
continue
return {'tmdbid': id_list[0].split("/")[-1]}
return {}
else:
logger.error(f"Plex/Items 未获取到返回数据")
return {}
except Exception as e:
logger.error(f"连接Plex/Items 出错:" + str(e))
return {}
@eventmanager.register(EventType.WebhookMessage)
def webhook_message_action(self, event):
if not self._enabled:
return
if not self._webhook_enabled:
return
data: WebhookEventInfo = event.event_data
# 排除不是收藏调用
if data.channel not in ['jellyfin', 'emby', 'plex']:
return
if data.channel in ['emby', 'plex'] and data.event != 'item.rate':
return
if data.channel == 'jellyfin' and data.save_reason != 'UpdateUserRating':
return
logger.info(f'BestFilmVersion/webhook_message_action WebhookEventInfo打印{data}')
# 获取锁
_is_lock: bool = lock.acquire(timeout=60)
if not _is_lock:
return
try:
if not data.tmdb_id:
info = None
if (data.channel == 'jellyfin'
and data.save_reason == 'UpdateUserRating'
and data.item_favorite):
info = Jellyfin().get_iteminfo(itemid=data.item_id)
elif data.channel == 'emby' and data.event == 'item.rate':
info = Emby().get_iteminfo(itemid=data.item_id)
elif data.channel == 'plex' and data.event == 'item.rate':
info = Plex().get_iteminfo(itemid=data.item_id)
logger.debug(f'BestFilmVersion/webhook_message_action item打印{info}')
if not info:
return
if info.item_type not in ['Movie', 'MOV', 'movie']:
return
# 获取tmdb_id
tmdb_id = info.tmdbid
else:
tmdb_id = data.tmdb_id
if (data.channel == 'jellyfin'
and (data.save_reason != 'UpdateUserRating' or not data.item_favorite)):
return
if data.item_type not in ['Movie', 'MOV', 'movie']:
return
# 识别媒体信息
mediainfo = self.chain.recognize_media(tmdbid=tmdb_id, mtype=MediaType.MOVIE)
if not mediainfo:
logger.warn(f'未识别到媒体信息,标题:{data.item_name}tmdbID{tmdb_id}')
return
# 读取缓存
caches = self._cache_path.read_text().split("\n") if self._cache_path.exists() else []
# 检查缓存
if data.item_name in caches:
return
# 读取历史记录
history = self.get_data('history') or []
# 添加订阅
self.subscribechain.add(mtype=MediaType.MOVIE,
title=mediainfo.title,
year=mediainfo.year,
tmdbid=mediainfo.tmdb_id,
best_version=True,
username="收藏洗版",
exist_ok=True)
# 加入缓存
caches.append(data.item_name)
# 存储历史记录
if mediainfo.tmdb_id not in [h.get("tmdbid") for h in history]:
history.append({
"title": mediainfo.title,
"type": mediainfo.type.value,
"year": mediainfo.year,
"poster": mediainfo.get_poster_image(),
"overview": mediainfo.overview,
"tmdbid": mediainfo.tmdb_id,
"time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
})
# 保存历史记录
self.save_data('history', history)
# 保存缓存
self._cache_path.write_text("\n".join(caches))
finally:
lock.release()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,918 @@
import glob
import os
import shutil
import time
from datetime import datetime, timedelta
from pathlib import Path
import pytz
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from app.modules.qbittorrent import Qbittorrent
from app.utils.string import StringUtils
from app.schemas.types import EventType
from app.core.event import eventmanager, Event
from app.core.config import settings
from app.plugins import _PluginBase
from typing import Any, List, Dict, Tuple, Optional
from app.log import logger
from app.schemas import NotificationType
class CleanInvalidSeed(_PluginBase):
# 插件名称
plugin_name = "清理QB无效做种"
# 插件描述
plugin_desc = "清理已经被站点删除的种子及源文件仅支持QB"
# 插件图标
plugin_icon = "clean_a.png"
# 插件版本
plugin_version = "2.2"
# 插件作者
plugin_author = "DzAvril"
# 作者主页
author_url = "https://github.com/DzAvril"
# 插件配置项ID前缀
plugin_config_prefix = "cleaninvalidseed"
# 加载顺序
plugin_order = 1
# 可使用的用户级别
auth_level = 1
# 私有属性
_enabled = False
_cron = None
_notify = False
_onlyonce = False
_qb = None
_detect_invalid_files = False
_delete_invalid_files = False
_delete_invalid_torrents = False
_notify_all = False
_label_only = False
_label = ""
_download_dirs = ""
_exclude_keywords = ""
_exclude_categories = ""
_exclude_labels = ""
_more_logs = False
# 定时器
_scheduler: Optional[BackgroundScheduler] = None
_error_msg = [
"torrent not registered with this tracker",
"Torrent not registered with this tracker",
"torrent banned",
"err torrent banned",
]
_custom_error_msg = ""
def init_plugin(self, config: dict = None):
# 停止现有任务
self.stop_service()
if config:
self._enabled = config.get("enabled")
self._cron = config.get("cron")
self._notify = config.get("notify")
self._onlyonce = config.get("onlyonce")
self._delete_invalid_torrents = config.get("delete_invalid_torrents")
self._delete_invalid_files = config.get("delete_invalid_files")
self._detect_invalid_files = config.get("detect_invalid_files")
self._notify_all = config.get("notify_all")
self._label_only = config.get("label_only")
self._label = config.get("label")
self._download_dirs = config.get("download_dirs")
self._exclude_keywords = config.get("exclude_keywords")
self._exclude_categories = config.get("exclude_categories")
self._exclude_labels = config.get("exclude_labels")
self._custom_error_msg = config.get("custom_error_msg")
self._more_logs = config.get("more_logs")
self._qb = Qbittorrent()
# 加载模块
if self._onlyonce:
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
logger.info(f"清理无效种子服务启动,立即运行一次")
self._scheduler.add_job(
func=self.clean_invalid_seed,
trigger="date",
run_date=datetime.now(tz=pytz.timezone(settings.TZ))
+ timedelta(seconds=3),
name="清理无效种子",
)
# 关闭一次性开关
self._onlyonce = False
self._update_config()
# 启动任务
if self._scheduler.get_jobs():
self._scheduler.print_jobs()
self._scheduler.start()
def get_state(self) -> bool:
return self._enabled
def _update_config(self):
self.update_config(
{
"onlyonce": False,
"cron": self._cron,
"enabled": self._enabled,
"notify": self._notify,
"delete_invalid_torrents": self._delete_invalid_torrents,
"delete_invalid_files": self._delete_invalid_files,
"detect_invalid_files": self._detect_invalid_files,
"notify_all": self._notify_all,
"label_only": self._label_only,
"label": self._label,
"download_dirs": self._download_dirs,
"exclude_keywords": self._exclude_keywords,
"exclude_categories": self._exclude_categories,
"exclude_labels": self._exclude_labels,
"custom_error_msg": self._custom_error_msg,
"more_logs": self._more_logs,
}
)
@staticmethod
def get_command() -> List[Dict[str, Any]]:
"""
定义远程控制命令
:return: 命令关键字、事件、描述、附带数据
"""
return [
{
"cmd": "/detect_invalid_torrents",
"event": EventType.PluginAction,
"desc": "检测无效做种",
"category": "QB",
"data": {"action": "detect_invalid_torrents"},
},
{
"cmd": "/delete_invalid_torrents",
"event": EventType.PluginAction,
"desc": "清理无效做种",
"category": "QB",
"data": {"action": "delete_invalid_torrents"},
},
{
"cmd": "/detect_invalid_files",
"event": EventType.PluginAction,
"desc": "检测无效源文件",
"category": "QB",
"data": {"action": "detect_invalid_files"},
},
{
"cmd": "/delete_invalid_files",
"event": EventType.PluginAction,
"desc": "清理无效源文件",
"category": "QB",
"data": {"action": "delete_invalid_files"},
},
{
"cmd": "/toggle_notify_all",
"event": EventType.PluginAction,
"desc": "QB清理插件切换全量通知",
"category": "QB",
"data": {"action": "toggle_notify_all"},
},
]
@eventmanager.register(EventType.PluginAction)
def handle_commands(self, event: Event):
if event:
event_data = event.event_data
if event_data:
if not (
event_data.get("action") == "detect_invalid_torrents"
or event_data.get("action") == "delete_invalid_torrents"
or event_data.get("action") == "detect_invalid_files"
or event_data.get("action") == "delete_invalid_files"
or event_data.get("action") == "toggle_notify_all"
):
return
self.post_message(
channel=event.event_data.get("channel"),
title="开始执行远程命令...",
userid=event.event_data.get("user"),
)
old_delete_invalid_torrents = self._delete_invalid_torrents
old_detect_invalid_files = self._detect_invalid_files
old_delete_invalid_files = self._delete_invalid_files
if event_data.get("action") == "detect_invalid_torrents":
logger.info("收到远程命令,开始检测无效做种")
self._delete_invalid_torrents = False
self._detect_invalid_files = False
self._delete_invalid_files = False
self.clean_invalid_seed()
elif event_data.get("action") == "delete_invalid_torrents":
logger.info("收到远程命令,开始清理无效做种")
self._delete_invalid_torrents = True
self._detect_invalid_files = False
self._delete_invalid_files = False
self.clean_invalid_seed()
elif event_data.get("action") == "detect_invalid_files":
logger.info("收到远程命令,开始检测无效源文件")
self._delete_invalid_files = False
self.detect_invalid_files()
elif event_data.get("action") == "delete_invalid_files":
logger.info("收到远程命令,开始清理无效源文件")
self._delete_invalid_files = True
self.detect_invalid_files()
elif event_data.get("action") == "toggle_notify_all":
self._notify_all = not self._notify_all
self._update_config()
if self._notify_all:
self.post_message(
channel=event.event_data.get("channel"),
title="已开启全量通知",
userid=event.event_data.get("user"),
)
else:
self.post_message(
channel=event.event_data.get("channel"),
title="已关闭全量通知",
userid=event.event_data.get("user"),
)
return
else:
logger.error("收到未知远程命令")
return
self._delete_invalid_torrents = old_delete_invalid_torrents
self._detect_invalid_files = old_detect_invalid_files
self._delete_invalid_files = old_delete_invalid_files
self.post_message(
channel=event.event_data.get("channel"),
title="远程命令执行完成!",
userid=event.event_data.get("user"),
)
def get_api(self) -> List[Dict[str, Any]]:
pass
def get_service(self) -> List[Dict[str, Any]]:
"""
注册插件公共服务
[{
"id": "服务ID",
"name": "服务名称",
"trigger": "触发器cron/interval/date/CronTrigger.from_crontab()",
"func": self.xxx,
"kwargs": {} # 定时器参数
}]
"""
if self._enabled and self._cron:
return [
{
"id": "CleanInvalidSeed",
"name": "清理QB无效做种",
"trigger": CronTrigger.from_crontab(self._cron),
"func": self.clean_invalid_seed,
"kwargs": {},
}
]
def get_all_torrents(self):
all_torrents, error = self._qb.get_torrents()
if error:
logger.error(f"获取QB种子失败: {error}")
if self._notify:
self.post_message(
mtype=NotificationType.SiteMessage,
title=f"【清理QB无效做种】",
text=f"获取QB种子失败请检查QB配置",
)
return []
if not all_torrents:
logger.warning("QB没有种子")
if self._notify:
self.post_message(
mtype=NotificationType.SiteMessage,
title=f"【清理QB无效做种】",
text=f"QB中没有种子",
)
return []
return all_torrents
def clean_invalid_seed(self):
logger.info("开始清理QB无效做种")
all_torrents = self.get_all_torrents()
temp_invalid_torrents = []
# tracker未工作但暂时不能判定为失效做种需人工判断
tracker_not_working_torrents = []
working_tracker_set = set()
exclude_categories = (
self._exclude_categories.split("\n") if self._exclude_categories else []
)
exclude_labels = (
self._exclude_labels.split("\n") if self._exclude_labels else []
)
custom_msgs = (
self._custom_error_msg.split("\n") if self._custom_error_msg else []
)
error_msgs = self._error_msg + custom_msgs
# 第一轮筛选出所有未工作的种子
for torrent in all_torrents:
trackers = torrent.trackers
is_invalid = True
is_tracker_working = False
for tracker in trackers:
if tracker.get("tier") == -1:
continue
tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1]
# 有一个tracker工作即为有效做种
if (tracker.get("status") == 2) or (tracker.get("status") == 3):
is_tracker_working = True
if not (
(tracker.get("status") == 4) and (tracker.get("msg") in error_msgs)
):
is_invalid = False
working_tracker_set.add(tracker_domian)
if self._more_logs:
logger.info(f"处理 [{torrent.name}] tracker [{tracker_domian}]: 分类: [{torrent.category}], 标签: [{torrent.tags}], 状态: [{tracker.get('status')}], msg: [{tracker.get('msg')}], is_invalid: [{is_invalid}], is_working: [{is_tracker_working}]")
if is_invalid:
temp_invalid_torrents.append(torrent)
elif not is_tracker_working:
# 排除已暂停的种子
if not torrent.state_enum.is_paused:
tracker_not_working_torrents.append(torrent)
logger.info(f"初筛共有{len(temp_invalid_torrents)}个无效做种")
# 第二轮筛选出tracker有正常工作种子而当前种子未工作的避免因临时关站或tracker失效导致误删的问题
# 失效做种但通过种子分类排除的种子
invalid_torrents_exclude_categories = []
# 失效做种但通过种子标签排除的种子
invalid_torrents_exclude_labels = []
# 将invalid_torrents基本信息保存起来在种子被删除后依然可以打印这些信息
invalid_torrent_tuple_list = []
deleted_torrent_tuple_list = []
for torrent in temp_invalid_torrents:
trackers = torrent.trackers
for tracker in trackers:
if tracker.get("tier") == -1:
continue
tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1]
if tracker_domian in working_tracker_set:
# tracker是正常的说明该种子是无效的
invalid_torrent_tuple_list.append(
(
torrent.name,
torrent.category,
torrent.tags,
torrent.size,
tracker_domian,
tracker.msg,
)
)
if self._delete_invalid_torrents or self._label_only:
# 检查种子分类和标签是否排除
is_excluded = False
if torrent.category in exclude_categories:
is_excluded = True
invalid_torrents_exclude_categories.append(torrent)
torrent_labels = [
tag.strip() for tag in torrent.tags.split(",")
]
for label in torrent_labels:
if label in exclude_labels:
is_excluded = True
invalid_torrents_exclude_labels.append(torrent)
if not is_excluded:
if self._label_only:
# 仅标记
self._qb.set_torrents_tag(ids=torrent.get("hash"), tags=[self._label if self._label != "" else "无效做种"])
else:
# 只删除种子不删除文件,以防其它站点辅种
self._qb.delete_torrents(False, torrent.get("hash"))
# 标记已处理种子信息
deleted_torrent_tuple_list.append(
(
torrent.name,
torrent.category,
torrent.tags,
torrent.size,
tracker_domian,
tracker.msg,
)
)
break
invalid_msg = f"检测到{len(invalid_torrent_tuple_list)}个失效做种\n"
tracker_not_working_msg = f"检测到{len(tracker_not_working_torrents)}个tracker未工作做种请检查种子状态\n"
if self._label_only or self._delete_invalid_torrents:
if self._label_only:
deleted_msg = f"标记了{len(deleted_torrent_tuple_list)}个失效种子\n"
else:
deleted_msg = f"删除了{len(deleted_torrent_tuple_list)}个失效种子\n"
if len(exclude_categories) != 0:
exclude_categories_msg = f"分类排除{len(invalid_torrents_exclude_categories)}个失效种子未删除,请手动处理\n"
if len(exclude_labels) != 0:
exclude_labels_msg = f"标签排除{len(invalid_torrents_exclude_labels)}个失效种子未删除,请手动处理\n"
for index in range(len(invalid_torrent_tuple_list)):
torrent = invalid_torrent_tuple_list[index]
invalid_msg += f"{index + 1}. {torrent[0]},分类:{torrent[1]},标签:{torrent[2]}, 大小:{StringUtils.str_filesize(torrent[3])}Trackers: {torrent[4]}{torrent[5]}\n"
for index in range(len(tracker_not_working_torrents)):
torrent = tracker_not_working_torrents[index]
trackers = torrent.trackers
tracker_msg = ""
for tracker in trackers:
if tracker.get("tier") == -1:
continue
tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1]
tracker_msg += f" {tracker_domian}{tracker.msg} "
tracker_not_working_msg += f"{index + 1}. {torrent.name},分类:{torrent.category},标签:{torrent.tags}, 大小:{StringUtils.str_filesize(torrent.size)}Trackers: {tracker_msg}\n"
for index in range(len(invalid_torrents_exclude_categories)):
torrent = invalid_torrents_exclude_categories[index]
trackers = torrent.trackers
tracker_msg = ""
for tracker in trackers:
if tracker.get("tier") == -1:
continue
tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1]
tracker_msg += f" {tracker_domian}{tracker.msg} "
exclude_categories_msg += f"{index + 1}. {torrent.name},分类:{torrent.category},标签:{torrent.tags}, 大小:{StringUtils.str_filesize(torrent.size)}Trackers: {tracker_msg}\n"
for index in range(len(invalid_torrents_exclude_labels)):
torrent = invalid_torrents_exclude_labels[index]
trackers = torrent.trackers
tracker_msg = ""
for tracker in trackers:
if tracker.get("tier") == -1:
continue
tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1]
tracker_msg += f" {tracker_domian}{tracker.msg} "
exclude_labels_msg += f"{index + 1}. {torrent.name},分类:{torrent.category},标签:{torrent.tags}, 大小:{StringUtils.str_filesize(torrent.size)}Trackers: {tracker_msg}\n"
for index in range(len(deleted_torrent_tuple_list)):
torrent = deleted_torrent_tuple_list[index]
deleted_msg += f"{index + 1}. {torrent[0]},分类:{torrent[1]},标签:{torrent[2]}, 大小:{StringUtils.str_filesize(torrent[3])}Trackers: {torrent[4]}{torrent[5]}\n"
# 日志
logger.info(invalid_msg)
logger.info(tracker_not_working_msg)
if self._delete_invalid_torrents:
logger.info(deleted_msg)
if len(exclude_categories) != 0:
logger.info(exclude_categories_msg)
if len(exclude_labels) != 0:
logger.info(exclude_labels_msg)
# 通知
if self._notify:
invalid_msg = invalid_msg.replace("_", "\_")
self.post_message(
mtype=NotificationType.SiteMessage,
title=f"【清理无效做种】",
text=invalid_msg,
)
if self._notify_all:
tracker_not_working_msg = tracker_not_working_msg.replace("_", "\_")
self.post_message(
mtype=NotificationType.SiteMessage,
title=f"【清理无效做种】",
text=tracker_not_working_msg,
)
if self._label_only or self._delete_invalid_torrents:
deleted_msg = deleted_msg.replace("_", "\_")
self.post_message(
mtype=NotificationType.SiteMessage,
title=f"【清理无效做种】",
text=deleted_msg,
)
if self._notify_all:
exclude_categories_msg = exclude_categories_msg.replace("_", "\_")
self.post_message(
mtype=NotificationType.SiteMessage,
title=f"【清理无效做种】",
text=exclude_categories_msg,
)
exclude_labels_msg = exclude_labels_msg.replace("_", "\_")
self.post_message(
mtype=NotificationType.SiteMessage,
title=f"【清理无效做种】",
text=exclude_labels_msg,
)
logger.info("检测无效做种任务结束")
if self._detect_invalid_files:
self.detect_invalid_files()
def detect_invalid_files(self):
logger.info("开始检测未做种的无效源文件")
all_torrents = self.get_all_torrents()
source_path_map = {}
source_paths = []
total_size = 0
deleted_file_cnt = 0
exclude_key_words = (
self._exclude_keywords.split("\n") if self._exclude_keywords else []
)
if not self._download_dirs:
logger.error("未配置下载目录,无法检测未做种无效源文件")
self.post_message(
mtype=NotificationType.SiteMessage,
title=f"【检测无效源文件】",
text="未配置下载目录,无法检测未做种无效源文件",
)
return
for path in self._download_dirs.split("\n"):
mp_path, qb_path = path.split(":")
source_path_map[mp_path] = qb_path
source_paths.append(mp_path)
# 所有做种源文件路径
content_path_set = set()
for torrent in all_torrents:
content_path_set.add(torrent.content_path)
message = "检测未做种无效源文件:\n"
for source_path_str in source_paths:
source_path = Path(source_path_str)
# 判断source_path是否存在
if not source_path.exists():
logger.error(f"{source_path} 不存在,无法检测未做种无效源文件")
self.post_message(
mtype=NotificationType.SiteMessage,
title=f"【检测无效源文件】",
text=f"{source_path} 不存在,无法检测未做种无效源文件",
)
continue
source_files = []
# 获取source_path下的所有文件包括文件夹
for file in source_path.iterdir():
source_files.append(file)
for source_file in source_files:
skip = False
for key_word in exclude_key_words:
if key_word in source_file.name:
logger.info(f"{str(source_file)}命中关键字{key_word},不做处理")
skip = True
break
if skip:
continue
# 将mp_path替换成 qb_path
qb_path = (str(source_file)).replace(
source_path_str, source_path_map[source_path_str]
)
# todo: 优化性能
is_exist = False
for content_path in content_path_set:
if qb_path in content_path:
is_exist = True
break
if not is_exist:
deleted_file_cnt += 1
message += f"{deleted_file_cnt}. {str(source_file)}\n"
total_size += self.get_size(source_file)
if self._delete_invalid_files:
if source_file.is_file():
source_file.unlink()
elif source_file.is_dir():
shutil.rmtree(source_file)
message += f"检测到{deleted_file_cnt}个未做种的无效源文件,共占用{StringUtils.str_filesize(total_size)}空间。\n"
if self._delete_invalid_files:
message += f"***已删除无效源文件,释放{StringUtils.str_filesize(total_size)}空间!***\n"
logger.info(message)
if self._notify:
message = message.replace("_", "\_")
self.post_message(
mtype=NotificationType.SiteMessage,
title=f"【清理无效做种】",
text=message,
)
logger.info("检测无效源文件任务结束")
def get_size(self, path: Path):
total_size = 0
if path.is_file():
return path.stat().st_size
# rglob 方法用于递归遍历所有文件和目录
for entry in path.rglob("*"):
if entry.is_file():
total_size += entry.stat().st_size
return total_size
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
return [
{
"component": "VForm",
"content": [
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {"cols": 12, "md": 4},
"content": [
{
"component": "VSwitch",
"props": {
"model": "enabled",
"label": "启用插件",
},
}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 4},
"content": [
{
"component": "VSwitch",
"props": {
"model": "notify",
"label": "开启通知",
},
}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 4},
"content": [
{
"component": "VSwitch",
"props": {
"model": "onlyonce",
"label": "立即运行一次",
},
}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 4},
"content": [
{
"component": "VSwitch",
"props": {
"model": "delete_invalid_torrents",
"label": "删除无效种子(确认无误后再开启)",
},
}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 4},
"content": [
{
"component": "VSwitch",
"props": {
"model": "detect_invalid_files",
"label": "检测无效源文件",
},
}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 4},
"content": [
{
"component": "VSwitch",
"props": {
"model": "delete_invalid_files",
"label": "删除无效源文件(确认无误后再开启)",
},
}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 4},
"content": [
{
"component": "VSwitch",
"props": {
"model": "notify_all",
"label": "全量通知",
},
}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 4},
"content": [
{
"component": "VSwitch",
"props": {
"model": "label_only",
"label": "仅标记模式(开启后不会删种)",
},
}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 4},
"content": [
{
"component": "VSwitch",
"props": {
"model": "more_logs",
"label": "打印更多日志",
},
}
],
},
],
},
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": { "cols": 12, "md": 6 },
"content": [
{
"component": "VTextField",
"props": {
"model": "cron",
"label": "执行周期",
},
}
],
},
{
"component": "VCol",
"props": { "cols": 12, "md": 6 },
"content": [
{
"component": "VTextField",
"props": {
"model": "label",
"label": "增加标签",
"placeholder": "仅标记模式下生效,给待处理的种子打标签",
},
}
],
},
],
},
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {"cols": 12},
"content": [
{
"component": "VTextarea",
"props": {
"model": "download_dirs",
"label": "下载目录映射",
"rows": 2,
"placeholder": "填写要监控的源文件目录并设置MP和QB的目录映射关系如/mp/download:/qb/download多个目录请换行",
},
}
],
}
],
},
{
"component": "VRow",
"props": {"style": {"margin-top": "0px"}},
"content": [
{
"component": "VCol",
"props": {"cols": 12, "md": 4},
"content": [
{
"component": "VTextarea",
"props": {
"model": "exclude_keywords",
"label": "过滤删源文件关键字",
"rows": 2,
"placeholder": "多个关键字请换行,仅针对删除源文件",
},
}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 4},
"content": [
{
"component": "VTextarea",
"props": {
"model": "exclude_categories",
"label": "过滤删种分类",
"rows": 2,
"placeholder": "多个分类请换行,仅针对删除种子",
},
}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 4},
"content": [
{
"component": "VTextarea",
"props": {
"model": "exclude_labels",
"label": "过滤删种标签",
"rows": 2,
"placeholder": "多个标签请换行,仅针对删除种子",
},
}
],
},
],
},
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {"cols": 12},
"content": [
{
"component": "VTextarea",
"props": {
"model": "custom_error_msg",
"label": "自定义无效做种tracker错误信息",
"rows": 5,
"placeholder": "填入想要清理的种子的tracker错误信息'skipping tracker announce (unreachable)',多个信息请换行",
},
}
],
}
],
},
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {
"cols": 12,
},
"content": [
{
"component": "VAlert",
"props": {
"type": "error",
"variant": "tonal",
"text": "谨慎起见删除种子/源文件功能做了开关,请确认无误后再开启删除功能",
},
}
],
},
{
"component": "VCol",
"props": {
"cols": 12,
},
"content": [
{
"component": "VAlert",
"props": {
"type": "info",
"variant": "tonal",
"text": "下载目录映射填入源文件根目录并设置MP和QB的目录映射关系。如某种子下载的源文件A存放路径为/qb/download/A则目录映射填入/mp/download:/qb/download多个目录请换行。注意映射目录不要有多余的'/'",
},
}
],
},
],
},
],
}
], {
"enabled": False,
"notify": False,
"download_dirs": "",
"delete_invalid_torrents": False,
"delete_invalid_files": False,
"detect_invalid_files": False,
"notify_all": False,
"onlyonce": False,
"cron": "0 0 * * *",
"label_only": False,
"label": "",
"more_logs": False,
}
def get_page(self) -> List[dict]:
pass
def stop_service(self):
"""
退出插件
"""
try:
if self._scheduler:
self._scheduler.remove_all_jobs()
if self._scheduler.running:
self._scheduler.shutdown()
self._scheduler = None
except Exception as e:
logger.error("退出插件失败:%s" % str(e))

View File

@@ -0,0 +1,540 @@
import json
import os
import shutil
import time
from pathlib import Path
from app.core.config import settings
from app.core.event import eventmanager, Event
from app.log import logger
from app.plugins import _PluginBase
from typing import Any, List, Dict, Tuple
from app.schemas.types import EventType, MediaImageType, NotificationType, MediaType
from app.utils.system import SystemUtils
class CloudDiskDel(_PluginBase):
# 插件名称
plugin_name = "云盘文件删除"
# 插件描述
plugin_desc = "媒体库删除strm文件后同步删除云盘资源。"
# 插件图标
plugin_icon = "clouddisk.png"
# 插件版本
plugin_version = "1.3"
# 插件作者
plugin_author = "thsrite"
# 作者主页
author_url = "https://github.com/thsrite"
# 插件配置项ID前缀
plugin_config_prefix = "clouddiskdel_"
# 加载顺序
plugin_order = 26
# 可使用的用户级别
auth_level = 1
# 私有属性
_enabled = False
# 任务执行间隔
_paths = {}
_notify = False
_del_history = False
_video_formats = ('.mp4', '.avi', '.rmvb', '.wmv', '.mov', '.mkv', '.flv', '.ts', '.webm', '.iso', '.mpg')
def init_plugin(self, config: dict = None):
if config:
self._enabled = config.get("enabled")
self._notify = config.get("notify")
self._del_history = config.get("del_history")
for path in str(config.get("path")).split("\n"):
paths = path.split(":")
self._paths[paths[0]] = paths[1]
# 清理插件历史
if self._del_history:
self.del_data(key="history")
self.update_config({
"enabled": self._enabled,
"notify": self._notify,
"path": config.get("path"),
"del_history": False
})
@eventmanager.register(EventType.PluginAction)
def clouddisk_del(self, event: Event):
if not self._enabled:
return
if not event:
return
event_data = event.event_data
if not event_data or event_data.get("action") != "networkdisk_del":
return
logger.info(f"获取到云盘删除请求 {event_data}")
media_path = event_data.get("media_path")
if not media_path:
logger.error("未获取到删除路径")
return
media_name = event_data.get("media_name")
tmdb_id = event_data.get("tmdb_id")
media_type = event_data.get("media_type")
season_num = event_data.get("season_num")
episode_num = event_data.get("episode_num")
# 不是网盘监控路径,直接排除
cloud_file_flag = False
# 判断删除媒体路径是否与配置的媒体库路径相符,相符则继续删除,不符则跳过
for library_path in list(self._paths.keys()):
if str(media_path).startswith(library_path):
cloud_file_flag = True
# 替换网盘路径
media_path = str(media_path).replace(library_path, self._paths.get(library_path))
logger.info(f"获取到moviepilot本地云盘挂载路径 {media_path}")
path = Path(media_path)
if path.is_file() or media_path.endswith(".strm"):
# 删除文件、nfo、jpg等同名文件
pattern = path.stem.replace('[', '?').replace(']', '?')
logger.info(f"开始筛选同名文件 {pattern}")
files = path.parent.glob(f"{pattern}.*")
remove_flag = False
for file in files:
Path(file).unlink()
logger.info(f"云盘文件 {file} 已删除")
self.__remove_json(file)
remove_flag = True
if not remove_flag:
for ext in self._video_formats:
file = path.stem + ext
if Path(file).exists():
Path(file).unlink()
logger.info(f"云盘文件 {file} 已删除")
self.__remove_json(file)
else:
# 非根目录,才删除目录
shutil.rmtree(path)
# 删除目录
logger.warn(f"云盘目录 {path} 已删除")
self.__remove_json(path)
# 判断当前媒体父路径下是否有媒体文件,如有则无需遍历父级
if not SystemUtils.exits_files(path.parent, settings.RMT_MEDIAEXT):
# 判断父目录是否为空, 为空则删除
for parent_path in path.parents:
if str(parent_path.parent) != str(path.root):
# 父目录非根目录,才删除父目录
if not SystemUtils.exits_files(parent_path, settings.RMT_MEDIAEXT):
# 当前路径下没有媒体文件则删除
shutil.rmtree(parent_path)
logger.warn(f"云盘目录 {parent_path} 已删除")
self.__remove_json(parent_path)
break
if cloud_file_flag:
# 发送消息
image = 'https://emby.media/notificationicon.png'
media_type = MediaType.MOVIE if media_type in ["Movie", "MOV"] else MediaType.TV
if self._notify:
backrop_image = self.chain.obtain_specific_image(
mediaid=tmdb_id,
mtype=media_type,
image_type=MediaImageType.Backdrop,
season=season_num,
episode=episode_num
) or image
# 类型
if media_type == MediaType.MOVIE:
msg = f'电影 {media_name} {tmdb_id}'
# 删除电视剧
elif media_type == MediaType.TV and not season_num and not episode_num:
msg = f'剧集 {media_name} {tmdb_id}'
# 删除季 S02
elif media_type == MediaType.TV and season_num and not episode_num:
msg = f'剧集 {media_name} S{season_num} {tmdb_id}'
# 删除剧集S02E02
elif media_type == MediaType.TV and season_num and episode_num:
msg = f'剧集 {media_name} S{season_num}E{episode_num} {tmdb_id}'
else:
msg = media_name
# 发送通知
self.post_message(
mtype=NotificationType.MediaServer,
title="云盘同步删除任务完成",
image=backrop_image,
text=f"{msg}\n"
f"时间 {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}"
)
# 读取历史记录
history = self.get_data('history') or []
# 获取poster
poster_image = self.chain.obtain_specific_image(
mediaid=tmdb_id,
mtype=media_type,
image_type=MediaImageType.Poster,
) or image
history.append({
"type": media_type.value,
"title": media_name,
"path": media_path,
"season": season_num if season_num and str(season_num).isdigit() else None,
"episode": episode_num if episode_num and str(episode_num).isdigit() else None,
"image": poster_image,
"del_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time()))
})
# 保存历史
self.save_data("history", history)
def __remove_json(self, path):
"""
删除json中的文件内容
"""
try:
# 删除本地缓存文件
cloud_files_json = os.path.join(settings.PLUGIN_DATA_PATH, "CloudStrm", "cloud_files.json")
if Path(cloud_files_json).exists():
# 删除json文件中已删除部分文件
# 尝试加载本地
with open(cloud_files_json, 'r') as file:
content = file.read()
if content:
__cloud_files = json.loads(content)
if __cloud_files:
if not isinstance(__cloud_files, list):
__cloud_files = [__cloud_files]
if str(path) in __cloud_files:
# 删除已删除文件
__cloud_files.remove(str(path))
# 重新写入本地
file = open(cloud_files_json, 'w')
file.write(json.dumps(__cloud_files))
file.close()
else:
remove_flag = False
# 删除目录下文件
for cloud_file in __cloud_files:
if str(cloud_file).startswith(str(path)):
__cloud_files.remove(cloud_file)
remove_flag = True
if remove_flag:
# 重新写入本地
file = open(cloud_files_json, 'w')
file.write(json.dumps(__cloud_files))
file.close()
except Exception as e:
print(str(e))
def get_state(self) -> bool:
return self._enabled
@staticmethod
def get_command() -> List[Dict[str, Any]]:
"""
定义远程控制命令
:return: 命令关键字、事件、描述、附带数据
"""
return [{
"cmd": "/networkdisk_del",
"event": EventType.PluginAction,
"desc": "云盘文件删除",
"category": "",
"data": {
"action": "networkdisk_del"
}
}]
def get_api(self) -> List[Dict[str, Any]]:
pass
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
"""
拼装插件配置页面需要返回两块数据1、页面配置2、数据结构
"""
return [
{
'component': 'VForm',
'content': [
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'enabled',
'label': '启用插件',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'notify',
'label': '开启通知',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'del_history',
'label': '删除历史',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VTextarea',
'props': {
'model': 'path',
'rows': '2',
'label': '媒体库路径映射',
'placeholder': '媒体服务器路径:moviepilot内云盘挂载路径一行一个'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VAlert',
'props': {
'type': 'info',
'variant': 'tonal',
'text': '需要开启媒体库删除插件且正确配置排除路径。'
'主要针对于strm文件删除后同步删除云盘资源。'
'如遇删除失败,请检查文件权限问题。'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VAlert',
'props': {
'type': 'info',
'variant': 'tonal',
'text': '关于路径映射:'
'emby:/data/series/A.mp4,'
'moviepilot内云盘挂载路径:/mnt/link/series/A.mp4。'
'路径映射填/data:/mnt/link'
}
}
]
}
]
},
]
}
], {
"enabled": False,
"path": "",
"notify": False,
"del_history": False
}
def get_page(self) -> List[dict]:
"""
拼装插件详情页面,需要返回页面配置,同时附带数据
"""
# 查询同步详情
historys = self.get_data('history')
if not historys:
return [
{
'component': 'div',
'text': '暂无数据',
'props': {
'class': 'text-center',
}
}
]
# 数据按时间降序排序
historys = sorted(historys, key=lambda x: x.get('del_time'), reverse=True)
# 拼装页面
contents = []
for history in historys:
htype = history.get("type")
title = history.get("title")
season = history.get("season")
episode = history.get("episode")
image = history.get("image")
del_time = history.get("del_time")
if season:
sub_contents = [
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'类型:{htype}'
},
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'标题:{title}'
},
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'季:{season}'
},
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'集:{episode}'
},
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'时间:{del_time}'
}
]
else:
sub_contents = [
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'类型:{htype}'
},
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'标题:{title}'
},
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'时间:{del_time}'
}
]
contents.append(
{
'component': 'VCard',
'content': [
{
'component': 'div',
'props': {
'class': 'd-flex justify-space-start flex-nowrap flex-row',
},
'content': [
{
'component': 'div',
'content': [
{
'component': 'VImg',
'props': {
'src': image,
'height': 120,
'width': 80,
'aspect-ratio': '2/3',
'class': 'object-cover shadow ring-gray-500',
'cover': True
}
}
]
},
{
'component': 'div',
'content': sub_contents
}
]
}
]
}
)
return [
{
'component': 'div',
'props': {
'class': 'grid gap-3 grid-info-card',
},
'content': contents
}
]
def stop_service(self):
"""
退出插件
"""
pass

View File

@@ -0,0 +1,597 @@
import copy
from typing import Any, List, Dict, Tuple
from dotenv import set_key
from app.core.config import settings
from app.core.module import ModuleManager
from app.log import logger
from app.plugins import _PluginBase
class ConfigCenter(_PluginBase):
# 插件名称
plugin_name = "配置中心"
# 插件描述
plugin_desc = "快速调整部分系统设定。"
# 插件图标
plugin_icon = "setting.png"
# 插件版本
plugin_version = "2.6"
# 插件作者
plugin_author = "jxxghp"
# 作者主页
author_url = "https://github.com/jxxghp"
# 插件配置项ID前缀
plugin_config_prefix = "configcenter_"
# 加载顺序
plugin_order = 0
# 可使用的用户级别
auth_level = 1
# 私有属性
_enabled = False
_params = ""
_writeenv = False
settings_attributes = [
"GITHUB_TOKEN", "API_TOKEN", "TMDB_API_DOMAIN", "TMDB_IMAGE_DOMAIN", "WALLPAPER",
"RECOGNIZE_SOURCE", "SCRAP_FOLLOW_TMDB", "AUTO_DOWNLOAD_USER",
"OCR_HOST", "DOWNLOAD_SUBTITLE", "PLUGIN_MARKET", "MOVIE_RENAME_FORMAT",
"TV_RENAME_FORMAT", "FANART_ENABLE", "DOH_ENABLE", "SEARCH_MULTIPLE_NAME", "META_CACHE_EXPIRE",
"GITHUB_PROXY", "DOH_DOMAINS", "DOH_RESOLVERS"
]
def init_plugin(self, config: dict = None):
if not config:
return
self._enabled = config.get("enabled")
self._writeenv = config.get("writeenv")
if not self._enabled:
return
logger.info(f"正在应用配置中心配置:{config}")
for attribute in self.settings_attributes:
setattr(settings, attribute, config.get(attribute) or getattr(settings, attribute))
# 自定义配置,以换行分隔
self._params = config.get("params") or ""
for key, value in self.__parse_params(self._params).items():
if hasattr(settings, key):
setattr(settings, key, str(value))
# 重新加载模块
ModuleManager().stop()
ModuleManager().load_modules()
# 如果写入app.env文件则关闭插件开关
if self._writeenv:
# 写入env文件
self.update_env(config)
# 自动关闭插件
self._enabled = False
logger.info("配置中心设置已写入app.env文件插件关闭...")
# 保存配置
config.update({"enabled": False})
self.update_config(config)
def update_env(self, config: dict):
"""
更新设置到app.env
"""
if not config:
return
# 避免修改原值
conf = copy.deepcopy(config)
# 自定义配置,以换行分隔
config_params = self.__parse_params(conf.get("params"))
conf.update(config_params)
# 读写app.env
env_path = settings.CONFIG_PATH / "app.env"
for key, value in conf.items():
if not key:
continue
# 如果参数不在支持列表中, 则跳过
if key not in self.settings_attributes and key not in config_params:
continue
if value is None or str(value) == "None":
value = ''
else:
value = str(value)
set_key(env_path, key, value)
logger.info("app.env文件写入完成")
self.systemmessage.put("配置中心设置已写入app.env文件插件关闭", title="配置中心")
@staticmethod
def __parse_params(param_str: str) -> dict:
"""
解析自定义配置
"""
if not param_str:
return {}
result = {}
params = param_str.split("\n")
for param in params:
if not param:
continue
if str(param).strip().startswith("#"):
continue
parts = param.split("=", 1)
if len(parts) != 2:
continue
key = parts[0].strip()
value = parts[1].strip()
if not key:
continue
if not value:
continue
result[key] = value
return result
def get_state(self) -> bool:
return self._enabled
@staticmethod
def get_command() -> List[Dict[str, Any]]:
pass
def get_api(self) -> List[Dict[str, Any]]:
pass
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
"""
拼装插件配置页面需要返回两块数据1、页面配置2、数据结构
"""
default_settings = {
"enabled": False,
"params": "",
}
for attribute in self.settings_attributes:
default_settings[attribute] = getattr(settings, attribute)
return [
{
"component": "VForm",
"content": [
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {
"cols": 12,
"md": 6
},
"content": [
{
"component": "VSwitch",
"props": {
"model": "enabled",
"label": "启用插件"
}
}
]
},
{
"component": "VCol",
"props": {
"cols": 12,
"md": 6
},
"content": [
{
"component": "VSwitch",
"props": {
"model": "writeenv",
"label": "写入app.env文件"
}
}
]
},
]
},
{
'component': 'VRow',
'content': [
{
"component": "VCol",
"props": {
"cols": 12,
"md": 6
},
"content": [
{
"component": "VTextField",
"props": {
"model": "GITHUB_TOKEN",
"label": "Github Token"
}
}
]
},
{
"component": "VCol",
"props": {
"cols": 12,
"md": 6
},
"content": [
{
"component": "VTextField",
"props": {
"model": "API_TOKEN",
"label": "API密钥"
}
}
]
},
{
"component": "VCol",
"props": {
"cols": 12,
"md": 6
},
"content": [
{
"component": "VTextField",
"props": {
"model": "TMDB_API_DOMAIN",
"label": "TMDB API地址"
}
}
]
},
{
"component": "VCol",
"props": {
"cols": 12,
"md": 6
},
"content": [
{
"component": "VTextField",
"props": {
"model": "TMDB_IMAGE_DOMAIN",
"label": "TheMovieDb图片服务器"
}
}
]
},
{
"component": "VCol",
"props": {
"cols": 12,
"md": 6
},
"content": [
{
"component": "VSelect",
"props": {
"model": "RECOGNIZE_SOURCE",
"label": "媒体信息识别来源",
"items": [
{"title": "TheMovieDb", "value": "themoviedb"},
{"title": "豆瓣", "value": "douban"}
]
}
}
]
},
{
"component": "VCol",
"props": {
"cols": 12,
"md": 6
},
"content": [
{
"component": "VSelect",
"props": {
"model": "SCRAP_SOURCE",
"label": "刮削元数据及图片使用的数据源",
"items": [
{"title": "TheMovieDb", "value": "themoviedb"},
{"title": "豆瓣", "value": "douban"},
]
}
}
]
},
{
"component": "VCol",
"props": {
"cols": 12,
"md": 6
},
"content": [
{
"component": "VSelect",
"props": {
"model": "WALLPAPER",
"label": "登录首页电影海报",
"items": [
{"title": "TheMovieDb电影海报", "value": "tmdb"},
{"title": "Bing每日壁纸", "value": "bing"}
]
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
"component": "VCol",
"props": {
"cols": 12,
"md": 6
},
"content": [
{
"component": "VTextField",
"props": {
"model": "OCR_HOST",
"label": "验证码识别服务器"
}
}
]
},
{
"component": "VCol",
"props": {
"cols": 12,
"md": 6
},
"content": [
{
"component": "VTextField",
"props": {
"model": "GITHUB_PROXY",
"label": "Github加速服务器",
"placeholder": "https://mirror.ghproxy.com/"
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
"component": "VCol",
"props": {
"cols": 12,
"md": 6
},
"content": [
{
"component": "VTextField",
"props": {
"model": "DOH_DOMAINS",
"label": "DOH解析的域名",
"placeholder": "多个域名使用,分隔"
}
}
]
},
{
"component": "VCol",
"props": {
"cols": 12,
"md": 6
},
"content": [
{
"component": "VTextField",
"props": {
"model": "DOH_RESOLVERS",
"label": "DOH解析服务器",
"placeholder": "多个地址使用,分隔"
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
"component": "VCol",
"props": {
"cols": 12,
},
"content": [
{
"component": "VTextarea",
"props": {
"model": "MOVIE_RENAME_FORMAT",
"label": "电影重命名格式"
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
"component": "VCol",
"props": {
"cols": 12,
},
"content": [
{
"component": "VTextarea",
"props": {
"model": "TV_RENAME_FORMAT",
"label": "电视剧重命名格式"
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
"component": "VCol",
"props": {
"cols": 12,
},
"content": [
{
"component": "VTextarea",
"props": {
"model": "PLUGIN_MARKET",
"label": "插件市场",
"placeholder": "多个地址使用,分隔"
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
"component": "VCol",
"props": {
"cols": 12,
},
"content": [
{
"component": "VTextarea",
"props": {
"model": "params",
"label": "自定义配置",
"placeholder": "每行一个配置项,格式:配置项=值"
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
"component": "VCol",
"props": {
"cols": 12,
"md": 4
},
"content": [
{
"component": "VSwitch",
"props": {
"model": "DOWNLOAD_SUBTITLE",
"label": "自动下载站点字幕"
}
}
]
},
{
"component": "VCol",
"props": {
"cols": 12,
"md": 4
},
"content": [
{
"component": "VSwitch",
"props": {
"model": "SCRAP_FOLLOW_TMDB",
"label": "新增入库跟随TMDB信息变化"
}
}
]
},
{
"component": "VCol",
"props": {
"cols": 12,
"md": 4
},
"content": [
{
"component": "VSwitch",
"props": {
"model": "FANART_ENABLE",
"label": "使用Fanart图片数据源"
}
}
]
},
{
"component": "VCol",
"props": {
"cols": 12,
"md": 4
},
"content": [
{
"component": "VSwitch",
"props": {
"model": "DOH_ENABLE",
"label": "启用DNS over HTTPS"
}
}
]
},
{
"component": "VCol",
"props": {
"cols": 12,
"md": 4
},
"content": [
{
"component": "VSwitch",
"props": {
"model": "SEARCH_MULTIPLE_NAME",
"label": "资源搜索整合多名称搜索结果"
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VAlert',
'props': {
'type': 'info',
'variant': 'tonal',
'text': '注意开启写入app.env后将直接修改配置文件否则只是运行时修改生效对应配置插件关闭且重启后配置失效有些自定义配置需要重启才能生效。'
}
}
]
}
]
}
]
}
], default_settings
def get_page(self) -> List[dict]:
pass
def stop_service(self):
"""
退出插件
"""
pass

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,456 @@
import json
import re
from datetime import datetime, timedelta
from app.modules.emby import Emby
from app.core.config import settings
from app.plugins import _PluginBase
from app.log import logger
from typing import List, Tuple, Dict, Any, Optional
import pytz
from app.schemas import WebhookEventInfo
from app.schemas.types import EventType
from app.core.event import eventmanager, Event
from apscheduler.triggers.cron import CronTrigger
from apscheduler.schedulers.background import BackgroundScheduler
class DiagParamAdjust(_PluginBase):
# 插件名称
plugin_name = "诊断参数调整"
# 插件描述
plugin_desc = "Emby专用插件|暂时性解决emby字幕偏移问题需要emby安装Diagnostics插件。"
# 插件图标
plugin_icon = "Gatus_A.png"
# 插件版本
plugin_version = "1.3"
# 插件作者
plugin_author = "jeblove"
# 作者主页
author_url = "https://github.com/jeblove"
# 插件配置项ID前缀
plugin_config_prefix = "dpa_"
# 加载顺序
plugin_order = 14
# 可使用的用户级别
auth_level = 1
# 私有属性
_enabled: bool = False
# 修正字幕偏移用途(播放时执行)
_offset_play = True
_onlyonce = False
_base_url = None
_endpoint = None
_api_key = None
_search_text = None
_replace_text = None
_cron = None
_cron_switch = False
# 请求接口
_url = "[HOST]emby/EncodingDiagnostics/DiagnosticOptions?api_key=[APIKEY]"
# 定时器
_scheduler: Optional[BackgroundScheduler] = None
# 目标消息
_webhook_actions = {
"playback.start": "开始播放",
}
# 分辨率标识
_resolution = None
# 分辨率改动
_last_resolution = None
# 目标参数
_target_search_text = None
_target_replace_text = None
def init_plugin(self, config: dict = None):
# 停止现有任务
self.stop_service()
if config:
self._enabled = config.get("enabled")
self._offset_play = config.get("offset_play")
self._onlyonce = config.get("onlyonce")
self._search_text = config.get("search")
self._replace_text = config.get("replace")
self._cron = config.get("cron")
self._cron_switch = config.get("cron_switch")
if self._onlyonce:
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
logger.info(f"诊断参数调整服务启动,立刻运行一次")
self._scheduler.add_job(func=self.run, trigger='date',
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
name="诊断参数调整")
# 关闭一次性开关
self._onlyonce = False
self.update_config({
"enabled": self._enabled,
"offset_play": self._offset_play,
"onlyonce": False,
"search": self._search_text,
"replace": self._replace_text,
"cron": self._cron,
"cron_switch": self._cron_switch,
})
# 启动任务
if self._scheduler.get_jobs():
self._scheduler.print_jobs()
self._scheduler.start()
def get_state(self) -> bool:
return self._enabled
@staticmethod
def get_command() -> List[Dict[str, Any]]:
pass
def get_api(self) -> List[Dict[str, Any]]:
pass
def get_service(self) -> List[Dict[str, Any]]:
"""
注册插件公共服务
[{
"id": "服务ID",
"name": "服务名称",
"trigger": "触发器cron/interval/date/CronTrigger.from_crontab()",
"func": self.xxx,
"kwargs": {} # 定时器参数
}]
"""
if self._enabled and self._cron and self._cron_switch:
return [{
"id": "DiagParamAdjust",
"name": "诊断参数调整定时服务",
"trigger": CronTrigger.from_crontab(self._cron),
"func": self.run,
"kwargs": {}
}]
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
"""
拼装插件配置页面需要返回两块数据1、页面配置2、数据结构
"""
return [
{
'component': 'VForm',
'content': [
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'enabled',
'label': '启用插件',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'offset_play',
'label': '修正字幕偏移(播放时执行)',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'onlyonce',
'label': '立即运行一次',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'search',
'label': '搜索文本'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'replace',
'label': '替换文本'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'cron',
'label': '检测执行周期',
'placeholder': '*/5 * * * *'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 3
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'cron_switch',
'label': '周期模式',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VAlert',
'props': {
'type': 'info',
'variant': 'tonal',
'text': '- 暂时性解决emby字幕偏移问题如默认参数不合适请在基础上修改【替换文本】x、y至适合(4K视频情况下),如[x=W/4:y=h/5]。\n - 【修正字幕偏移(播放时执行)】需要emby配置webhooks消息通知勾选[播放-开始](具体可参考【媒体库服务器通知】插件)',
'style': 'white-space: pre-line;'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VAlert',
'props': {
'type': 'info',
'variant': 'tonal',
'text': '- 播放视频分辨率与上次视频分辨率不一致时,在通知延迟和已加载旧位置字幕影响下,需要片刻后才会加载到新位置字幕,或关闭视频再次打开(建议)。\n - 此替换文本参数应用于emby-Diagnostics-Parameter Adjustment。\n - 默认参数用于修改ffmpeg中字幕覆盖在视频上的位置。\n - 方案来源于https://opve.cn/archives/983.html',
'style': 'white-space: pre-line;'
}
}
]
}
]
}
]
}
], {
"enabled": False,
"offset_play": True,
"onlyonce": False,
"search": "x=(W-w)/2:y=(H-h):repeatlast=0",
"replace": "x=W/4:y=h/4:repeatlast=0",
"cron": "*/5 * * * *",
"cron_switch": False,
}
def detect(self):
"""
检测是否存在目标参数(修正字幕偏移用途)
:return True: 存在; False: 不存在
"""
logger.info('字幕偏移修正,检测目标参数')
try:
res = Emby().get_data(self._url)
result = res.json()
data = result['Object']['CommandLineOptions']
searchText = data['SearchText']
replaceText = data['ReplaceText']
except json.JSONDecodeError:
logger.error('服务停止Emby请安装【Diagnostics】插件')
return None
except KeyError:
# 已装插件,未设置过该参数
# logger.info('目标参数为空')
return False
# 符合所有情况
if (('repeatlast' in replaceText
and 'x=(W-w)/2:y=(H-h):repeatlast=0' in searchText
and result['Object']['TranscodingOptions']['DisableHardwareSubtitleOverlay'] is True)
or (searchText == "" and replaceText == "")) \
and self._resolution == self._last_resolution:
# (A or B) and C
return True
return False
def set_options(self):
"""
向Emby发送请求设置参数
"""
# 根据分辨率情况而选择是否替换
if self._resolution == 0 and self._offset_play is True:
# 1080p不替换清空文本
self._target_search_text = ""
self._target_replace_text = ""
logger.info('清空替换参数')
else:
# >1080p or 非字幕偏移用途
self._target_search_text = self._search_text
self._target_replace_text = self._replace_text
logger.info("替换值为:{}".format(self._target_replace_text))
data = {
"CommandLineOptions": {
"SearchText": self._target_search_text,
"ReplaceText": self._target_replace_text
},
"TranscodingOptions": {
"DisableHardwareSubtitleOverlay": True
}
}
data = json.dumps(data)
headers = {
'Content-Type': 'application/octet-stream'
}
res = Emby().post_data(self._url, data, headers)
if res.status_code // 100 == 2:
logger.info('参数设置成功')
return True
else:
logger.error('参数设置失败 {}'.format(res.status_code))
return False
@eventmanager.register(EventType.WebhookMessage)
def get_msg(self, event: Event):
# 消息方式开关
if not self._enabled or not self._offset_play:
return
# 消息获取
event_info: WebhookEventInfo = event.event_data
if not event_info:
return
# 非目标消息
if not self._webhook_actions.get(event_info.event):
return
# 根据视频名获得分辨率信息
item_path = event_info.item_path
video_resolution = re.findall(r"\d{3,4}p", item_path)
video_width = int(video_resolution[0][:-1])
logger.info('视频分辨率:{}'.format(video_width))
self._last_resolution = self._resolution
# 分辨率变化情况
if video_width > 1080:
# 2160p/4k
self._resolution = 1
else:
self._resolution = 0
self.run()
def run(self):
# 字幕偏移修正,则带检测
if self._offset_play:
state = self.detect()
if state:
logger.info('参数正常,无需修正')
return True
elif state is None:
logger.info('插件退出')
return None
self.set_options()
def get_page(self) -> List[dict]:
pass
def stop_service(self):
"""
退出插件
"""
try:
if self._scheduler:
self._scheduler.remove_all_jobs()
if self._scheduler.running:
self._scheduler.shutdown()
self._scheduler = None
except Exception as e:
logger.error("退出插件失败:%s" % str(e))

View File

@@ -0,0 +1,812 @@
import datetime
import pytz
import threading
from typing import List, Tuple, Dict, Any, Optional
from app.core.context import Context
from app.core.event import eventmanager, Event
from app.schemas.types import EventType, MediaType
from app.core.config import settings
from app.log import logger
from app.plugins import _PluginBase
from app.modules.qbittorrent import Qbittorrent
from app.modules.transmission import Transmission
from app.db.downloadhistory_oper import DownloadHistoryOper
from app.db.models.downloadhistory import DownloadHistory
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from app.helper.sites import SitesHelper
from app.utils.string import StringUtils
class DownloadSiteTag(_PluginBase):
# 插件名称
plugin_name = "下载任务分类与标签"
# 插件描述
plugin_desc = "自动给下载任务分类与打站点标签、剧集名称标签"
# 插件图标
plugin_icon = "Youtube-dl_B.png"
# 插件版本
plugin_version = "2.1"
# 插件作者
plugin_author = "叮叮当"
# 作者主页
author_url = "https://github.com/cikezhu"
# 插件配置项ID前缀
plugin_config_prefix = "DownloadSiteTag_"
# 加载顺序
plugin_order = 2
# 可使用的用户级别
auth_level = 1
# 日志前缀
LOG_TAG = "[DownloadSiteTag] "
# 退出事件
_event = threading.Event()
# 私有属性
downloader_qb = None
downloader_tr = None
downloadhistory_oper = None
sites_helper = None
_scheduler = None
_enabled = False
_onlyonce = False
_interval = "计划任务"
_interval_cron = "5 4 * * *"
_interval_time = 6
_interval_unit = "小时"
_enabled_media_tag = False
_enabled_tag = True
_enabled_category = False
_category_movie = None
_category_tv = None
_category_anime = None
def init_plugin(self, config: dict = None):
self.downloader_qb = Qbittorrent()
self.downloader_tr = Transmission()
self.downloadhistory_oper = DownloadHistoryOper()
self.sites_helper = SitesHelper()
# 读取配置
if config:
self._enabled = config.get("enabled")
self._onlyonce = config.get("onlyonce")
self._interval = config.get("interval") or "计划任务"
self._interval_cron = config.get("interval_cron") or "5 4 * * *"
self._interval_time = self.str_to_number(config.get("interval_time"), 6)
self._interval_unit = config.get("interval_unit") or "小时"
self._enabled_media_tag = config.get("enabled_media_tag")
self._enabled_tag = config.get("enabled_tag")
self._enabled_category = config.get("enabled_category")
self._category_movie = config.get("category_movie") or "电影"
self._category_tv = config.get("category_tv") or "电视"
self._category_anime = config.get("category_anime") or "动漫"
if not ("interval_cron" in config):
# 新版本v1.6更新插件配置默认配置
config["interval"] = self._interval
config["interval_cron"] = self._interval_cron
config["interval_time"] = self._interval_time
config["interval_unit"] = self._interval_unit
self.update_config(config)
logger.warn(f"{self.LOG_TAG}新版本v{self.plugin_version} 配置修正 ...")
# 停止现有任务
self.stop_service()
if self._onlyonce:
# 创建定时任务控制器
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
# 执行一次, 关闭onlyonce
self._onlyonce = False
config.update({"onlyonce": self._onlyonce})
self.update_config(config)
# 添加 补全下载历史的标签与分类 任务
self._scheduler.add_job(func=self._complemented_history, trigger='date',
run_date=datetime.datetime.now(
tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3)
)
if self._scheduler and self._scheduler.get_jobs():
# 启动服务
self._scheduler.print_jobs()
self._scheduler.start()
def get_state(self) -> bool:
return self._enabled
@staticmethod
def get_command() -> List[Dict[str, Any]]:
pass
def get_api(self) -> List[Dict[str, Any]]:
pass
def get_service(self) -> List[Dict[str, Any]]:
"""
注册插件公共服务
[{
"id": "服务ID",
"name": "服务名称",
"trigger": "触发器cron/interval/date/CronTrigger.from_crontab()",
"func": self.xxx,
"kwargs": {} # 定时器参数
}]
"""
if self._enabled:
if self._interval == "计划任务" or self._interval == "固定间隔":
if self._interval == "固定间隔":
if self._interval_unit == "小时":
return [{
"id": "DownloadSiteTag",
"name": "补全下载历史的标签与分类",
"trigger": "interval",
"func": self._complemented_history,
"kwargs": {
"hours": self._interval_time
}
}]
else:
if self._interval_time < 5:
self._interval_time = 5
logger.info(f"{self.LOG_TAG}启动定时服务: 最小不少于5分钟, 防止执行间隔太短任务冲突")
return [{
"id": "DownloadSiteTag",
"name": "补全下载历史的标签与分类",
"trigger": "interval",
"func": self._complemented_history,
"kwargs": {
"minutes": self._interval_time
}
}]
else:
return [{
"id": "DownloadSiteTag",
"name": "补全下载历史的标签与分类",
"trigger": CronTrigger.from_crontab(self._interval_cron),
"func": self._complemented_history,
"kwargs": {}
}]
return []
@staticmethod
def str_to_number(s: str, i: int) -> int:
try:
return int(s)
except ValueError:
return i
def _complemented_history(self):
"""
补全下载历史的标签与分类
"""
logger.info(f"{self.LOG_TAG}开始执行 ...")
# 记录处理的种子, 供辅种(无下载历史)使用
dispose_history = {}
# 所有站点索引
indexers = [indexer.get("name") for indexer in self.sites_helper.get_indexers()]
# JackettIndexers索引器支持多个站点, 如果不存在历史记录, 则通过tracker会再次附加其他站点名称
indexers.append("JackettIndexers")
indexers = set(indexers)
tracker_mappings = {
"chdbits.xyz": "ptchdbits.co",
"agsvpt.trackers.work": "agsvpt.com",
"tracker.cinefiles.info": "audiences.me",
}
for DOWNLOADER in ["qbittorrent", "transmission"]:
logger.info(f"{self.LOG_TAG}开始扫描下载器 {DOWNLOADER} ...")
# 获取下载器中的种子
downloader_obj = self._get_downloader(DOWNLOADER)
if not downloader_obj:
logger.error(f"{self.LOG_TAG} 获取下载器失败 {DOWNLOADER}")
continue
torrents, error = downloader_obj.get_torrents()
# 如果下载器获取种子发生错误 或 没有种子 则跳过
if error or not torrents:
continue
logger.info(f"{self.LOG_TAG}按时间重新排序 {DOWNLOADER} 种子数:{len(torrents)}")
# 按添加时间进行排序, 时间靠前的按大小和名称加入处理历史, 判定为原始种子, 其他为辅种
torrents = self._torrents_sort(torrents=torrents, dl_type=DOWNLOADER)
logger.info(f"{self.LOG_TAG}下载器 {DOWNLOADER} 分析种子信息中 ...")
for torrent in torrents:
try:
if self._event.is_set():
logger.info(
f"{self.LOG_TAG}停止服务")
return
# 获取已处理种子的key (size, name)
_key = self._torrent_key(torrent=torrent, dl_type=DOWNLOADER)
# 获取种子hash
_hash = self._get_hash(torrent=torrent, dl_type=DOWNLOADER)
if not _hash:
continue
# 获取种子当前标签
torrent_tags = self._get_label(torrent=torrent, dl_type=DOWNLOADER)
torrent_cat = self._get_category(torrent=torrent, dl_type=DOWNLOADER)
# 提取种子hash对应的下载历史
history: DownloadHistory = self.downloadhistory_oper.get_by_hash(_hash)
if not history:
# 如果找到已处理种子的历史, 表明当前种子是辅种, 否则创建一个空DownloadHistory
if _key and _key in dispose_history:
history = dispose_history[_key]
# 因为辅种站点必定不同, 所以需要更新站点名字 history.torrent_site
history.torrent_site = None
else:
history = DownloadHistory()
else:
# 加入历史记录
if _key:
dispose_history[_key] = history
# 如果标签已经存在任意站点, 则不再添加站点标签
if indexers.intersection(set(torrent_tags)):
history.torrent_site = None
# 如果站点名称为空, 尝试通过trackers识别
elif not history.torrent_site:
trackers = self._get_trackers(torrent=torrent, dl_type=DOWNLOADER)
for tracker in trackers:
# 检查tracker是否包含特定的关键字并进行相应的映射
for key, mapped_domain in tracker_mappings.items():
if key in tracker:
domain = mapped_domain
break
else:
domain = StringUtils.get_url_domain(tracker)
site_info = self.sites_helper.get_indexer(domain)
if site_info:
history.torrent_site = site_info.get("name")
break
# 如果通过tracker还是无法获取站点名称, 且tmdbid, type, title都是空的, 那么跳过当前种子
if not history.torrent_site and not history.tmdbid and not history.type and not history.title:
continue
# 按设置生成需要写入的标签与分类
_tags = []
_cat = None
# 站点标签, 如果勾选开关的话 因允许torrent_site为空时运行到此, 因此需要判断torrent_site不为空
if self._enabled_tag and history.torrent_site:
_tags.append(history.torrent_site)
# 媒体标题标签, 如果勾选开关的话 因允许title为空时运行到此, 因此需要判断title不为空
if self._enabled_media_tag and history.title:
_tags.append(history.title)
# 分类, 如果勾选开关的话 <tr暂不支持> 因允许mtype为空时运行到此, 因此需要判断mtype不为空。为防止不必要的识别, 种子已经存在分类torrent_cat时 也不执行
if DOWNLOADER == "qbittorrent" and self._enabled_category and not torrent_cat and history.type:
# 如果是电视剧 需要区分是否动漫
genre_ids = None
# 因允许tmdbid为空时运行到此, 因此需要判断tmdbid不为空
history_type = MediaType(history.type) if history.type else None
if history.tmdbid and history_type == MediaType.TV:
# tmdb_id获取tmdb信息
tmdb_info = self.chain.tmdb_info(mtype=history_type, tmdbid=history.tmdbid)
if tmdb_info:
genre_ids = tmdb_info.get("genre_ids")
_cat = self._genre_ids_get_cat(history.type, genre_ids)
# 去除种子已经存在的标签
if _tags and torrent_tags:
_tags = list(set(_tags) - set(torrent_tags))
# 如果分类一样, 那么不需要修改
if _cat == torrent_cat:
_cat = None
# 判断当前种子是否不需要修改
if not _cat and not _tags:
continue
# 执行通用方法, 设置种子标签与分类
self._set_torrent_info(DOWNLOADER=DOWNLOADER, _hash=_hash, _torrent=torrent, _tags=_tags, _cat=_cat,
_original_tags=torrent_tags)
except Exception as e:
logger.error(
f"{self.LOG_TAG}分析种子信息时发生了错误: {str(e)}")
logger.info(f"{self.LOG_TAG}执行完成")
def _genre_ids_get_cat(self, mtype, genre_ids=None):
"""
根据genre_ids判断是否<动漫>分类
"""
_cat = None
if mtype == MediaType.MOVIE or mtype == MediaType.MOVIE.value:
# 电影
_cat = self._category_movie
elif mtype:
ANIME_GENREIDS = settings.ANIME_GENREIDS
if genre_ids \
and set(genre_ids).intersection(set(ANIME_GENREIDS)):
# 动漫
_cat = self._category_anime
else:
# 电视剧
_cat = self._category_tv
return _cat
def _get_downloader(self, dtype: str):
"""
根据类型返回下载器实例
"""
if dtype == "qbittorrent":
return self.downloader_qb
elif dtype == "transmission":
return self.downloader_tr
else:
return None
@staticmethod
def _torrent_key(torrent: Any, dl_type: str) -> Optional[Tuple[int, str]]:
"""
按种子大小和时间返回key
"""
if dl_type == "qbittorrent":
size = torrent.get('size')
name = torrent.get('name')
else:
size = torrent.total_size
name = torrent.name
if not size or not name:
return None
else:
return size, name
@staticmethod
def _torrents_sort(torrents: Any, dl_type: str):
"""
按种子添加时间排序
"""
if dl_type == "qbittorrent":
torrents = sorted(torrents, key=lambda x: x.get("added_on"), reverse=False)
else:
torrents = sorted(torrents, key=lambda x: x.added_date, reverse=False)
return torrents
@staticmethod
def _get_hash(torrent: Any, dl_type: str):
"""
获取种子hash
"""
try:
return torrent.get("hash") if dl_type == "qbittorrent" else torrent.hashString
except Exception as e:
print(str(e))
return ""
@staticmethod
def _get_trackers(torrent: Any, dl_type: str):
"""
获取种子trackers
"""
try:
if dl_type == "qbittorrent":
"""
url 字符串 跟踪器网址
status 整数 跟踪器状态。有关可能的值,请参阅下表
tier 整数 跟踪器优先级。较低级别的跟踪器在较高级别的跟踪器之前试用。当特殊条目(如 DHT不存在时层号用作占位符时层号有效。>= 0< 0tier
num_peers 整数 跟踪器报告的当前 torrent 的对等体数量
num_seeds 整数 当前种子的种子数,由跟踪器报告
num_leeches 整数 当前种子的水蛭数量,如跟踪器报告的那样
num_downloaded 整数 跟踪器报告的当前 torrent 的已完成下载次数
msg 字符串 跟踪器消息(无法知道此消息是什么 - 由跟踪器管理员决定)
"""
return [tracker.get("url") for tracker in (torrent.trackers or []) if
tracker.get("tier", -1) >= 0 and tracker.get("url")]
else:
"""
class Tracker(Container):
@property
def id(self) -> int:
return self.fields["id"]
@property
def announce(self) -> str:
return self.fields["announce"]
@property
def scrape(self) -> str:
return self.fields["scrape"]
@property
def tier(self) -> int:
return self.fields["tier"]
"""
return [tracker.announce for tracker in (torrent.trackers or []) if
tracker.tier >= 0 and tracker.announce]
except Exception as e:
print(str(e))
return []
@staticmethod
def _get_label(torrent: Any, dl_type: str):
"""
获取种子标签
"""
try:
return [str(tag).strip() for tag in torrent.get("tags", "").split(',')] \
if dl_type == "qbittorrent" else torrent.labels or []
except Exception as e:
print(str(e))
return []
@staticmethod
def _get_category(torrent: Any, dl_type: str):
"""
获取种子分类
"""
try:
return torrent.get("category") if dl_type == "qbittorrent" else None
except Exception as e:
print(str(e))
return None
def _set_torrent_info(self, DOWNLOADER: str, _hash: str, _torrent: Any = None, _tags=None, _cat: str = None,
_original_tags: list = None):
"""
设置种子标签与分类
"""
# 当前下载器
if _tags is None:
_tags = []
downloader_obj = self._get_downloader(DOWNLOADER)
if not _torrent:
_torrent, error = downloader_obj.get_torrents(ids=_hash)
if not _torrent or error:
logger.error(
f"{self.LOG_TAG}设置种子标签与分类时发生了错误: 通过 {_hash} 查询不到任何种子!")
return
logger.info(
f"{self.LOG_TAG}设置种子标签与分类: {_hash} 查询到 {len(_torrent)} 个种子")
_torrent = _torrent[0]
# 判断是否可执行
if DOWNLOADER and downloader_obj and _hash and _torrent:
# 下载器api不通用, 因此需分开处理
if DOWNLOADER == "qbittorrent":
# 设置标签
if _tags:
downloader_obj.set_torrents_tag(ids=_hash, tags=_tags)
# 设置分类 <tr暂不支持>
if _cat:
# 尝试设置种子分类, 如果失败, 则创建再设置一遍
try:
_torrent.setCategory(category=_cat)
except Exception as e:
logger.warn(f"下载器 {DOWNLOADER} 种子id: {_hash} 设置分类 {_cat} 失败:{str(e)}, "
f"尝试创建分类再设置 ...")
downloader_obj.qbc.torrents_createCategory(name=_cat)
_torrent.setCategory(category=_cat)
else:
# 设置标签
if _tags:
# _original_tags = None表示未指定, 因此需要获取原始标签
if _original_tags is None:
_original_tags = self._get_label(torrent=_torrent, dl_type=DOWNLOADER)
# 如果原始标签不是空的, 那么合并原始标签
if _original_tags:
_tags = list(set(_original_tags).union(set(_tags)))
downloader_obj.set_torrent_tag(ids=_hash, tags=_tags)
logger.warn(
f"{self.LOG_TAG}下载器: {DOWNLOADER} 种子id: {_hash} {(' 标签: ' + ','.join(_tags)) if _tags else ''} {(' 分类: ' + _cat) if _cat else ''}")
@eventmanager.register(EventType.DownloadAdded)
def DownloadAdded(self, event: Event):
"""
添加下载事件
"""
if not self.get_state():
return
if not event.event_data:
return
try:
context: Context = event.event_data.get("context")
_hash = event.event_data.get("hash")
_torrent = context.torrent_info
_media = context.media_info
_tags = []
_cat = None
# 站点标签, 如果勾选开关的话
if self._enabled_tag and _torrent.site_name:
_tags.append(_torrent.site_name)
# 媒体标题标签, 如果勾选开关的话
if self._enabled_media_tag and _media.title:
_tags.append(_media.title)
# 分类, 如果勾选开关的话 <tr暂不支持>
if self._enabled_category and _media.type:
_cat = self._genre_ids_get_cat(_media.type, _media.genre_ids)
if _hash and (_tags or _cat):
# 执行通用方法, 设置种子标签与分类
self._set_torrent_info(DOWNLOADER=settings.DEFAULT_DOWNLOADER, _hash=_hash, _tags=_tags, _cat=_cat)
except Exception as e:
logger.error(
f"{self.LOG_TAG}分析下载事件时发生了错误: {str(e)}")
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
"""
拼装插件配置页面需要返回两块数据1、页面配置2、数据结构
"""
return [
{
'component': 'VForm',
'content': [
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 3
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'enabled',
'label': '启用插件',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 3
},
'content': [
{
'component': 'VCheckboxBtn',
'props': {
'model': 'enabled_tag',
'label': '自动站点标签',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 3
},
'content': [
{
'component': 'VCheckboxBtn',
'props': {
'model': 'enabled_media_tag',
'label': '自动剧名标签',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 3
},
'content': [
{
'component': 'VCheckboxBtn',
'props': {
'model': 'enabled_category',
'label': '自动设置分类',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 12
},
'content': [
{
'component': 'VCheckboxBtn',
'props': {
'model': 'onlyonce',
'label': '补全下载历史的标签与分类(一次性任务)'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 3
},
'content': [
{
'component': 'VSelect',
'props': {
'model': 'interval',
'label': '定时任务',
'items': [
{'title': '禁用', 'value': '禁用'},
{'title': '计划任务', 'value': '计划任务'},
{'title': '固定间隔', 'value': '固定间隔'}
]
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 3,
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'interval_cron',
'label': '计划任务设置',
'placeholder': '5 4 * * *'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 6,
'md': 3,
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'interval_time',
'label': '固定间隔设置, 间隔每',
'placeholder': '6'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 6,
'md': 3,
},
'content': [
{
'component': 'VSelect',
'props': {
'model': 'interval_unit',
'label': '单位',
'items': [
{'title': '小时', 'value': '小时'},
{'title': '分钟', 'value': '分钟'}
]
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'category_movie',
'label': '电影分类名称(默认: 电影)',
'placeholder': '电影'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'category_tv',
'label': '电视分类名称(默认: 电视)',
'placeholder': '电视'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'category_anime',
'label': '动漫分类名称(默认: 动漫)',
'placeholder': '动漫'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VAlert',
'props': {
'type': 'info',
'variant': 'tonal',
'text': '定时任务:支持两种定时方式,主要针对辅种刷流等种子补全站点信息。如没有对应的需求建议切换为禁用。'
}
}
]
}
]
}
]
}
], {
"enabled": False,
"onlyonce": False,
"enabled_tag": True,
"enabled_media_tag": False,
"enabled_category": False,
"category_movie": "电影",
"category_tv": "电视",
"category_anime": "动漫",
"interval": "计划任务",
"interval_cron": "5 4 * * *",
"interval_time": "6",
"interval_unit": "小时"
}
def get_page(self) -> List[dict]:
pass
def stop_service(self):
"""
停止服务
"""
try:
if self._scheduler:
self._scheduler.remove_all_jobs()
if self._scheduler.running:
self._event.set()
self._scheduler.shutdown()
self._event.clear()
self._scheduler = None
except Exception as e:
print(str(e))

View File

@@ -0,0 +1,872 @@
import base64
import json
import threading
import time
from pathlib import Path
from typing import Any, List, Dict, Tuple, Optional, Union
from pydantic import BaseModel
from requests import RequestException
from app import schemas
from app.chain.mediaserver import MediaServerChain
from app.core.config import settings
from app.core.event import eventmanager, Event
from app.core.meta import MetaBase
from app.log import logger
from app.modules.emby import Emby
from app.modules.jellyfin import Jellyfin
from app.modules.plex import Plex
from app.modules.themoviedb.tmdbv3api import TV
from app.plugins import _PluginBase
from app.schemas.types import EventType
from app.utils.common import retry
from app.utils.http import RequestUtils
class ExistMediaInfo(BaseModel):
# 类型 电影、电视剧
type: Optional[schemas.MediaType]
# 季, 集
groupep: Optional[Dict[int, list]] = {}
# 集在媒体服务器的ID
groupid: Optional[Dict[int, List[list]]] = {}
# 媒体服务器
server: Optional[str] = None
# 媒体ID
itemid: Optional[Union[str, int]] = None
class EpisodeGroupMeta(_PluginBase):
# 插件名称
plugin_name = "TMDB剧集组刮削"
# 插件描述
plugin_desc = "从TMDB剧集组刮削季集的实际顺序。"
# 插件图标
plugin_icon = "Element_A.png"
# 主题色
plugin_color = "#098663"
# 插件版本
plugin_version = "1.1"
# 插件作者
plugin_author = "叮叮当"
# 作者主页
author_url = "https://github.com/cikezhu"
# 插件配置项ID前缀
plugin_config_prefix = "EpisodeGroupMeta_"
# 加载顺序
plugin_order = 29
# 可使用的用户级别
auth_level = 1
# 退出事件
_event = threading.Event()
# 私有属性
mschain = None
tv = None
emby = None
plex = None
jellyfin = None
_enabled = False
_ignorelock = False
_delay = 0
_allowlist = []
def init_plugin(self, config: dict = None):
self.mschain = MediaServerChain()
self.tv = TV()
self.emby = Emby()
self.plex = Plex()
self.jellyfin = Jellyfin()
if config:
self._enabled = config.get("enabled")
self._ignorelock = config.get("ignorelock")
self._delay = config.get("delay") or 120
self._allowlist = []
for s in str(config.get("allowlist", "")).split(","):
s = s.strip()
if s and s not in self._allowlist:
self._allowlist.append(s)
self.log_info(f"白名单数量: {len(self._allowlist)} > {self._allowlist}")
def get_state(self) -> bool:
return self._enabled
@staticmethod
def get_command() -> List[Dict[str, Any]]:
pass
def get_api(self) -> List[Dict[str, Any]]:
pass
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
"""
拼装插件配置页面需要返回两块数据1、页面配置2、数据结构
"""
return [
{
'component': 'VForm',
'content': [
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'enabled',
'label': '启用插件',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'ignorelock',
'label': '媒体信息锁定时也进行刮削',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'delay',
'label': '入库延迟时间(秒)',
'placeholder': '120'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12
},
'content': [
{
'component': 'VTextarea',
'props': {
'model': 'allowlist',
'label': '刮削白名单',
'rows': 6,
'placeholder': '使用,分隔电视剧名称'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VAlert',
'props': {
'type': 'info',
'variant': 'tonal',
'text': '注意:刮削白名单(留空), 则全部刮削. 否则仅刮削白名单.'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VAlert',
'props': {
'type': 'info',
'variant': 'tonal',
'text': '注意:如需刮削已经入库的项目, 可通过mp重新整理单集即可.'
}
}
]
}
]
}
]
}
], {
"enabled": False,
"ignorelock": False,
"allowlist": "",
"delay": 120
}
def get_page(self) -> List[dict]:
pass
@eventmanager.register(EventType.TransferComplete)
def scrap_rt(self, event: Event):
"""
根据事件实时刮削剧集组信息
"""
if not self.get_state():
return
# 事件数据
mediainfo: schemas.MediaInfo = event.event_data.get("mediainfo")
meta: MetaBase = event.event_data.get("meta")
# self.log_error(f"{event.event_data}")
if not mediainfo or not meta:
return
# 非TV类型不处理
if mediainfo.type != schemas.MediaType.TV:
self.log_warn(f"{mediainfo.title} 非TV类型, 无需处理")
return
# 没有tmdbID不处理
if not mediainfo.tmdb_id:
self.log_warn(f"{mediainfo.title} 没有tmdbID, 无需处理")
return
if len(self._allowlist) != 0 \
and mediainfo.title not in self._allowlist:
self.log_warn(f"{mediainfo.title} 不在白名单, 无需处理")
return
# 获取剧集组信息
try:
episode_groups = self.tv.episode_groups(mediainfo.tmdb_id)
if not episode_groups:
self.log_warn(f"{mediainfo.title} 没有剧集组, 无需处理")
return
self.log_info(f"{mediainfo.title_year} 剧集组数量: {len(episode_groups)} - {episode_groups}")
# episodegroup = self.tv.group_episodes(episode_groups[0].get('id'))
except Exception as e:
self.log_error(f"{mediainfo.title} {str(e)}")
return
# 延迟
if self._delay:
self.log_warn(f"{mediainfo.title} 将在 {self._delay} 秒后开始处理..")
time.sleep(int(self._delay))
# 获取可用的媒体服务器
_existsinfo = self.chain.media_exists(mediainfo=mediainfo)
existsinfo: ExistMediaInfo = self.__media_exists(server=_existsinfo.server, mediainfo=mediainfo,
existsinfo=_existsinfo)
if not existsinfo or not existsinfo.itemid:
self.log_warn(f"{mediainfo.title_year} 在媒体库中不存在")
return
# 新增需要的属性
existsinfo.server = _existsinfo.server
existsinfo.type = _existsinfo.type
self.log_info(f"{mediainfo.title_year} 存在于媒体服务器: {_existsinfo.server}")
# 获取全部剧集组信息
copy_keys = ['Id', 'Name', 'ChannelNumber', 'OriginalTitle', 'ForcedSortName', 'SortName', 'CommunityRating',
'CriticRating', 'IndexNumber', 'ParentIndexNumber', 'SortParentIndexNumber', 'SortIndexNumber',
'DisplayOrder', 'Album', 'AlbumArtists', 'ArtistItems', 'Overview', 'Status', 'Genres', 'Tags',
'TagItems', 'Studios', 'PremiereDate', 'DateCreated', 'ProductionYear', 'Video3DFormat',
'OfficialRating', 'CustomRating', 'People', 'LockData', 'LockedFields', 'ProviderIds',
'PreferredMetadataLanguage', 'PreferredMetadataCountryCode', 'Taglines']
for episode_group in episode_groups:
if not bool(existsinfo.groupep):
break
try:
id = episode_group.get('id')
name = episode_group.get('name')
if not id:
continue
# 处理
self.log_info(f"正在匹配剧集组: {id}")
groups_meta = self.tv.group_episodes(id)
if not groups_meta:
continue
for groups in groups_meta:
if not bool(existsinfo.groupep):
break
# 剧集组中的季
order = groups.get("order")
# 剧集组中的集列表
episodes = groups.get("episodes")
if not order or not episodes or len(episodes) == 0:
continue
# 进行集数匹配, 确定剧集组信息
ep = existsinfo.groupep.get(order)
if not ep or len(ep) != len(episodes):
continue
self.log_info(f"已匹配剧集组: {name}, {id}, 第 {order}")
# 遍历全部媒体项并更新
for _index, _ids in enumerate(existsinfo.groupid.get(order)):
# 提取出媒体库中集id对应的集数index
ep_num = ep[_index]
for _id in _ids:
# 获取媒体服务器媒体项
iteminfo = self.get_iteminfo(server=existsinfo.server, itemid=_id)
if not iteminfo:
self.log_info(f"未找到媒体项 - itemid: {_id}, 第 {order} 季, 第 {ep_num}")
continue
# 是否无视项目锁定
if not self._ignorelock:
if iteminfo.get("LockData") or (
"Name" in iteminfo.get("LockedFields", [])
and "Overview" in iteminfo.get("LockedFields", [])):
self.log_warn(f"已锁定媒体项 - itemid: {_id}, 第 {order} 季, 第 {ep_num}")
continue
# 替换项目数据
episode = episodes[ep_num - 1]
new_dict = {}
new_dict.update({k: v for k, v in iteminfo.items() if k in copy_keys})
new_dict["Name"] = episode["name"]
new_dict["Overview"] = episode["overview"]
new_dict["ParentIndexNumber"] = str(order)
new_dict["IndexNumber"] = str(ep_num)
new_dict["LockData"] = True
if episode.get("vote_average"):
new_dict["CommunityRating"] = episode.get("vote_average")
if not new_dict["LockedFields"]:
new_dict["LockedFields"] = []
self.__append_to_list(new_dict["LockedFields"], "Name")
self.__append_to_list(new_dict["LockedFields"], "Overview")
# 更新数据
self.set_iteminfo(server=existsinfo.server, itemid=_id, iteminfo=new_dict)
# still_path 图片
if episode.get("still_path"):
self.set_item_image(server=existsinfo.server, itemid=_id,
imageurl=f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{episode['still_path']}")
self.log_info(f"已修改剧集 - itemid: {_id}, 第 {order} 季, 第 {ep_num}")
# 移除已经处理成功的季
existsinfo.groupep.pop(order, 0)
existsinfo.groupid.pop(order, 0)
continue
except Exception as e:
self.log_warn(f"错误忽略: {str(e)}")
continue
self.log_info(f"{mediainfo.title_year} 已经运行完毕了..")
@staticmethod
def __append_to_list(list, item):
if item not in list:
list.append(item)
def __media_exists(self, server: str, mediainfo: schemas.MediaInfo,
existsinfo: schemas.ExistMediaInfo) -> ExistMediaInfo:
"""
根据媒体信息返回剧集列表与剧集ID列表
:param mediainfo: 媒体信息
:return: 剧集列表与剧集ID列表
"""
def __emby_media_exists():
# 获取系列id
item_id = None
try:
res = self.emby.get_data(("[HOST]emby/Items?"
"IncludeItemTypes=Series"
"&Fields=ProductionYear"
"&StartIndex=0"
"&Recursive=true"
"&SearchTerm=%s"
"&Limit=10"
"&IncludeSearchTypes=false"
"&api_key=[APIKEY]") % mediainfo.title)
res_items = res.json().get("Items")
if res_items:
for res_item in res_items:
if res_item.get('Name') == mediainfo.title and (
not mediainfo.year or str(res_item.get('ProductionYear')) == str(mediainfo.year)):
item_id = res_item.get('Id')
except Exception as e:
self.log_error(f"连接Items出错" + str(e))
if not item_id:
return None
# 验证tmdbid是否相同
item_info = self.emby.get_iteminfo(item_id)
if item_info:
if mediainfo.tmdb_id and item_info.tmdbid:
if str(mediainfo.tmdb_id) != str(item_info.tmdbid):
self.log_error(f"tmdbid不匹配或不存在")
return None
try:
res_json = self.emby.get_data(
"[HOST]emby/Shows/%s/Episodes?Season=&IsMissing=false&api_key=[APIKEY]" % item_id)
if res_json:
tv_item = res_json.json()
res_items = tv_item.get("Items")
group_ep = {}
group_id = {}
for res_item in res_items:
season_index = res_item.get("ParentIndexNumber")
if not season_index:
continue
episode_index = res_item.get("IndexNumber")
if not episode_index:
continue
if season_index not in group_ep:
group_ep[season_index] = []
group_id[season_index] = []
if episode_index not in group_ep[season_index]:
group_ep[season_index].append(episode_index)
group_id[season_index].append([])
# 找到准确的插入索引
_index = group_ep[season_index].index(episode_index)
if res_item.get("Id") not in group_id[season_index][_index]:
group_id[season_index][_index].append(res_item.get("Id"))
# 返回
return ExistMediaInfo(
itemid=item_id,
groupep=group_ep,
groupid=group_id
)
except Exception as e:
self.log_error(f"连接Shows/Id/Episodes出错{str(e)}")
return None
def __jellyfin_media_exists():
# 获取系列id
item_id = None
try:
res = self.jellyfin.get_data(url=f"[HOST]Users/[USER]/Items?api_key=[APIKEY]"
f"&searchTerm={mediainfo.title}"
f"&IncludeItemTypes=Series"
f"&Limit=10&Recursive=true")
res_items = res.json().get("Items")
if res_items:
for res_item in res_items:
if res_item.get('Name') == mediainfo.title and (
not mediainfo.year or str(res_item.get('ProductionYear')) == str(mediainfo.year)):
item_id = res_item.get('Id')
except Exception as e:
self.log_error(f"连接Items出错" + str(e))
if not item_id:
return None
# 验证tmdbid是否相同
item_info = self.jellyfin.get_iteminfo(item_id)
if item_info:
if mediainfo.tmdb_id and item_info.tmdbid:
if str(mediainfo.tmdb_id) != str(item_info.tmdbid):
self.log_error(f"tmdbid不匹配或不存在")
return None
try:
res_json = self.jellyfin.get_data(
"[HOST]emby/Shows/%s/Episodes?Season=&IsMissing=false&api_key=[APIKEY]" % item_id)
if res_json:
tv_item = res_json.json()
res_items = tv_item.get("Items")
group_ep = {}
group_id = {}
for res_item in res_items:
season_index = res_item.get("ParentIndexNumber")
if not season_index:
continue
episode_index = res_item.get("IndexNumber")
if not episode_index:
continue
if season_index not in group_ep:
group_ep[season_index] = []
group_id[season_index] = []
if episode_index not in group_ep[season_index]:
group_ep[season_index].append(episode_index)
group_id[season_index].append([])
# 找到准确的插入索引
_index = group_ep[season_index].index(episode_index)
if res_item.get("Id") not in group_id[season_index][_index]:
group_id[season_index][_index].append(res_item.get("Id"))
# 返回
return ExistMediaInfo(
itemid=item_id,
groupep=group_ep,
groupid=group_id
)
except Exception as e:
self.log_error(f"连接Shows/Id/Episodes出错{str(e)}")
return None
def __plex_media_exists():
try:
_plex = self.plex.get_plex()
if not _plex:
return None
if existsinfo.itemid:
videos = _plex.fetchItem(existsinfo.itemid)
else:
# 根据标题和年份模糊搜索,该结果不够准确
videos = _plex.library.search(title=mediainfo.title,
year=mediainfo.year,
libtype="show")
if (not videos
and mediainfo.original_title
and str(mediainfo.original_title) != str(mediainfo.title)):
videos = _plex.library.search(title=mediainfo.original_title,
year=mediainfo.year,
libtype="show")
if not videos:
return None
if isinstance(videos, list):
videos = videos[0]
video_tmdbid = __get_ids(videos.guids).get('tmdb_id')
if mediainfo.tmdb_id and video_tmdbid:
if str(video_tmdbid) != str(mediainfo.tmdb_id):
self.log_error(f"tmdbid不匹配或不存在")
return None
episodes = videos.episodes()
group_ep = {}
group_id = {}
for episode in episodes:
season_index = episode.seasonNumber
if not season_index:
continue
episode_index = episode.index
if not episode_index:
continue
episode_id = episode.key
if not episode_id:
continue
if season_index not in group_ep:
group_ep[season_index] = []
group_id[season_index] = []
if episode_index not in group_ep[season_index]:
group_ep[season_index].append(episode_index)
group_id[season_index].append([])
# 找到准确的插入索引
_index = group_ep[season_index].index(episode_index)
if episode_id not in group_id[season_index][_index]:
group_id[season_index][_index].append(episode_id)
# 返回
return ExistMediaInfo(
itemid=videos.key,
groupep=group_ep,
groupid=group_id
)
except Exception as e:
self.log_error(f"连接Shows/Id/Episodes出错{str(e)}")
return None
def __get_ids(guids: List[Any]) -> dict:
guid_mapping = {
"imdb://": "imdb_id",
"tmdb://": "tmdb_id",
"tvdb://": "tvdb_id"
}
ids = {}
for prefix, varname in guid_mapping.items():
ids[varname] = None
for guid in guids:
for prefix, varname in guid_mapping.items():
if isinstance(guid, dict):
if guid['id'].startswith(prefix):
# 找到匹配的ID
ids[varname] = guid['id'][len(prefix):]
break
else:
if guid.id.startswith(prefix):
# 找到匹配的ID
ids[varname] = guid.id[len(prefix):]
break
return ids
if server == "emby":
return __emby_media_exists()
elif server == "jellyfin":
return __jellyfin_media_exists()
else:
return __plex_media_exists()
def get_iteminfo(self, server: str, itemid: str) -> dict:
"""
获得媒体项详情
"""
def __get_emby_iteminfo() -> dict:
"""
获得Emby媒体项详情
"""
try:
url = f'[HOST]emby/Users/[USER]/Items/{itemid}?' \
f'Fields=ChannelMappingInfo&api_key=[APIKEY]'
res = self.emby.get_data(url=url)
if res:
return res.json()
except Exception as err:
self.log_error(f"获取Emby媒体项详情失败{str(err)}")
return {}
def __get_jellyfin_iteminfo() -> dict:
"""
获得Jellyfin媒体项详情
"""
try:
url = f'[HOST]Users/[USER]/Items/{itemid}?Fields=ChannelMappingInfo&api_key=[APIKEY]'
res = self.jellyfin.get_data(url=url)
if res:
result = res.json()
if result:
result['FileName'] = Path(result['Path']).name
return result
except Exception as err:
self.log_error(f"获取Jellyfin媒体项详情失败{str(err)}")
return {}
def __get_plex_iteminfo() -> dict:
"""
获得Plex媒体项详情
"""
iteminfo = {}
try:
plexitem = self.plex.get_plex().library.fetchItem(ekey=itemid)
if 'movie' in plexitem.METADATA_TYPE:
iteminfo['Type'] = 'Movie'
iteminfo['IsFolder'] = False
elif 'episode' in plexitem.METADATA_TYPE:
iteminfo['Type'] = 'Series'
iteminfo['IsFolder'] = False
if 'show' in plexitem.TYPE:
iteminfo['ChildCount'] = plexitem.childCount
iteminfo['Name'] = plexitem.title
iteminfo['Id'] = plexitem.key
iteminfo['ProductionYear'] = plexitem.year
iteminfo['ProviderIds'] = {}
for guid in plexitem.guids:
idlist = str(guid.id).split(sep='://')
if len(idlist) < 2:
continue
iteminfo['ProviderIds'][idlist[0]] = idlist[1]
for location in plexitem.locations:
iteminfo['Path'] = location
iteminfo['FileName'] = Path(location).name
iteminfo['Overview'] = plexitem.summary
iteminfo['CommunityRating'] = plexitem.audienceRating
# 增加锁定属性列表
iteminfo['LockedFields'] = []
try:
if plexitem.title.locked:
iteminfo['LockedFields'].append('Name')
except Exception as err:
logger.warn(f"获取Plex媒体项详情失败{str(err)}")
pass
try:
if plexitem.summary.locked:
iteminfo['LockedFields'].append('Overview')
except Exception as err:
logger.warn(f"获取Plex媒体项详情失败{str(err)}")
pass
return iteminfo
except Exception as err:
self.log_error(f"获取Plex媒体项详情失败{str(err)}")
return {}
if server == "emby":
return __get_emby_iteminfo()
elif server == "jellyfin":
return __get_jellyfin_iteminfo()
else:
return __get_plex_iteminfo()
def set_iteminfo(self, server: str, itemid: str, iteminfo: dict):
"""
更新媒体项详情
"""
def __set_emby_iteminfo():
"""
更新Emby媒体项详情
"""
try:
res = self.emby.post_data(
url=f'[HOST]emby/Items/{itemid}?api_key=[APIKEY]&reqformat=json',
data=json.dumps(iteminfo),
headers={
"Content-Type": "application/json"
}
)
if res and res.status_code in [200, 204]:
return True
else:
self.log_error(f"更新Emby媒体项详情失败错误码{res.status_code}")
return False
except Exception as err:
self.log_error(f"更新Emby媒体项详情失败{str(err)}")
return False
def __set_jellyfin_iteminfo():
"""
更新Jellyfin媒体项详情
"""
try:
res = self.jellyfin.post_data(
url=f'[HOST]Items/{itemid}?api_key=[APIKEY]',
data=json.dumps(iteminfo),
headers={
"Content-Type": "application/json"
}
)
if res and res.status_code in [200, 204]:
return True
else:
self.log_error(f"更新Jellyfin媒体项详情失败错误码{res.status_code}")
return False
except Exception as err:
self.log_error(f"更新Jellyfin媒体项详情失败{str(err)}")
return False
def __set_plex_iteminfo():
"""
更新Plex媒体项详情
"""
try:
plexitem = self.plex.get_plex().library.fetchItem(ekey=itemid)
if 'CommunityRating' in iteminfo and iteminfo['CommunityRating']:
edits = {
'audienceRating.value': iteminfo['CommunityRating'],
'audienceRating.locked': 1
}
plexitem.edit(**edits)
plexitem.editTitle(iteminfo['Name']).editSummary(iteminfo['Overview']).reload()
return True
except Exception as err:
self.log_error(f"更新Plex媒体项详情失败{str(err)}")
return False
if server == "emby":
return __set_emby_iteminfo()
elif server == "jellyfin":
return __set_jellyfin_iteminfo()
else:
return __set_plex_iteminfo()
@retry(RequestException, logger=logger)
def set_item_image(self, server: str, itemid: str, imageurl: str):
"""
更新媒体项图片
"""
def __download_image():
"""
下载图片
"""
try:
if "doubanio.com" in imageurl:
r = RequestUtils(headers={
'Referer': "https://movie.douban.com/"
}, ua=settings.USER_AGENT).get_res(url=imageurl, raise_exception=True)
else:
r = RequestUtils().get_res(url=imageurl, raise_exception=True)
if r:
return base64.b64encode(r.content).decode()
else:
self.log_error(f"{imageurl} 图片下载失败,请检查网络连通性")
except Exception as err:
self.log_error(f"下载图片失败:{str(err)}")
return None
def __set_emby_item_image(_base64: str):
"""
更新Emby媒体项图片
"""
try:
url = f'[HOST]emby/Items/{itemid}/Images/Primary?api_key=[APIKEY]'
res = self.emby.post_data(
url=url,
data=_base64,
headers={
"Content-Type": "image/png"
}
)
if res and res.status_code in [200, 204]:
return True
else:
self.log_error(f"更新Emby媒体项图片失败错误码{res.status_code}")
return False
except Exception as result:
self.log_error(f"更新Emby媒体项图片失败{result}")
return False
def __set_jellyfin_item_image():
"""
更新Jellyfin媒体项图片
# FIXME 改为预下载图片
"""
try:
url = f'[HOST]Items/{itemid}/RemoteImages/Download?' \
f'Type=Primary&ImageUrl={imageurl}&ProviderName=TheMovieDb&api_key=[APIKEY]'
res = self.jellyfin.post_data(url=url)
if res and res.status_code in [200, 204]:
return True
else:
self.log_error(f"更新Jellyfin媒体项图片失败错误码{res.status_code}")
return False
except Exception as err:
self.log_error(f"更新Jellyfin媒体项图片失败{err}")
return False
def __set_plex_item_image():
"""
更新Plex媒体项图片
# FIXME 改为预下载图片
"""
try:
plexitem = self.plex.get_plex().library.fetchItem(ekey=itemid)
plexitem.uploadPoster(url=imageurl)
return True
except Exception as err:
self.log_error(f"更新Plex媒体项图片失败{err}")
return False
if server == "emby":
# 下载图片获取base64
image_base64 = __download_image()
if image_base64:
return __set_emby_item_image(image_base64)
elif server == "jellyfin":
return __set_jellyfin_item_image()
else:
return __set_plex_item_image()
return None
def log_error(self, ss: str):
logger.error(f"<{self.plugin_name}> {str(ss)}")
def log_warn(self, ss: str):
logger.warn(f"<{self.plugin_name}> {str(ss)}")
def log_info(self, ss: str):
logger.info(f"<{self.plugin_name}> {str(ss)}")
def stop_service(self):
"""
停止服务
"""
pass

View File

@@ -0,0 +1,360 @@
import threading
from datetime import datetime, timedelta
from pathlib import Path
from threading import Event as ThreadEvent
from typing import List, Tuple, Dict, Any
import pytz
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from app.core.config import settings
from app.core.event import eventmanager, Event
from app.log import logger
from app.plugins import _PluginBase
from app.plugins.ffmpegthumb.ffmpeg_helper import FfmpegHelper
from app.schemas import TransferInfo
from app.schemas.types import EventType
from app.utils.system import SystemUtils
ffmpeg_lock = threading.Lock()
class FFmpegThumb(_PluginBase):
# 插件名称
plugin_name = "FFmpeg缩略图"
# 插件描述
plugin_desc = "TheMovieDb没有背景图片时使用FFmpeg截取视频文件缩略图。"
# 插件图标
plugin_icon = "ffmpeg.png"
# 插件版本
plugin_version = "1.2"
# 插件作者
plugin_author = "jxxghp"
# 作者主页
author_url = "https://github.com/jxxghp"
# 插件配置项ID前缀
plugin_config_prefix = "ffmpegthumb_"
# 加载顺序
plugin_order = 31
# 可使用的用户级别
user_level = 1
# 私有属性
_scheduler = None
_enabled = False
_onlyonce = False
_cron = None
_timeline = "00:03:01"
_scan_paths = ""
_exclude_paths = ""
# 退出事件
_event = ThreadEvent()
def init_plugin(self, config: dict = None):
# 读取配置
if config:
self._enabled = config.get("enabled")
self._onlyonce = config.get("onlyonce")
self._cron = config.get("cron")
self._timeline = config.get("timeline")
self._scan_paths = config.get("scan_paths") or ""
self._exclude_paths = config.get("exclude_paths") or ""
# 停止现有任务
self.stop_service()
# 启动定时任务 & 立即运行一次
if self._enabled or self._onlyonce:
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
if self._cron:
logger.info(f"FFmpeg缩略图服务启动周期{self._cron}")
try:
self._scheduler.add_job(func=self.__libraryscan,
trigger=CronTrigger.from_crontab(self._cron),
name="FFmpeg缩略图")
except Exception as e:
logger.error(f"FFmpeg缩略图服务启动失败原因{str(e)}")
self.systemmessage.put(f"FFmpeg缩略图服务启动失败原因{str(e)}", title="FFmpeg缩略图")
if self._onlyonce:
logger.info(f"FFmpeg缩略图服务立即运行一次")
self._scheduler.add_job(func=self.__libraryscan, trigger='date',
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
name="FFmpeg缩略图")
# 关闭一次性开关
self._onlyonce = False
self.update_config({
"onlyonce": False,
"enabled": self._enabled,
"cron": self._cron,
"timeline": self._timeline,
"scan_paths": self._scan_paths,
"exclude_paths": self._exclude_paths
})
if self._scheduler.get_jobs():
# 启动服务
self._scheduler.print_jobs()
self._scheduler.start()
def get_state(self) -> bool:
return self._enabled
@staticmethod
def get_command() -> List[Dict[str, Any]]:
pass
def get_api(self) -> List[Dict[str, Any]]:
pass
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
return [
{
'component': 'VForm',
'content': [
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'enabled',
'label': '启用插件',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'onlyonce',
'label': '立即运行一次',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'timeline',
'label': '截取时间',
'placeholder': '00:03:01'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'cron',
'label': '定时扫描周期',
'placeholder': '5位cron表达式留空关闭'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12
},
'content': [
{
'component': 'VTextarea',
'props': {
'model': 'scan_paths',
'label': '定时扫描路径',
'rows': 5,
'placeholder': '每一行一个目录'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12
},
'content': [
{
'component': 'VTextarea',
'props': {
'model': 'exclude_paths',
'label': '定时扫描排除路径',
'rows': 2,
'placeholder': '每一行一个目录'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VAlert',
'props': {
'type': 'info',
'variant': 'tonal',
'text': '开启插件后默认会实时处理增量整理的媒体文件需要处理存量媒体文件时才需开启定时需要提前安装FFmpeghttps://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))

View File

@@ -0,0 +1,82 @@
import json
import subprocess
from app.utils.system import SystemUtils
class FfmpegHelper:
@staticmethod
def get_thumb(video_path: str, image_path: str, frames: str = None):
"""
使用ffmpeg从视频文件中截取缩略图
"""
if not frames:
frames = "00:03:01"
if not video_path or not image_path:
return False
cmd = 'ffmpeg -i "{video_path}" -ss {frames} -vframes 1 -f image2 "{image_path}"'.format(video_path=video_path,
frames=frames,
image_path=image_path)
result = SystemUtils.execute(cmd)
if result:
return True
return False
@staticmethod
def extract_wav(video_path: str, audio_path: str, audio_index: str = None):
"""
使用ffmpeg从视频文件中提取16000hz, 16-bit的wav格式音频
"""
if not video_path or not audio_path:
return False
# 提取指定音频流
if audio_index:
command = ['ffmpeg', "-hide_banner", "-loglevel", "warning", '-y', '-i', video_path,
'-map', f'0:a:{audio_index}',
'-acodec', 'pcm_s16le', '-ac', '1', '-ar', '16000', audio_path]
else:
command = ['ffmpeg', "-hide_banner", "-loglevel", "warning", '-y', '-i', video_path,
'-acodec', 'pcm_s16le', '-ac', '1', '-ar', '16000', audio_path]
ret = subprocess.run(command).returncode
if ret == 0:
return True
return False
@staticmethod
def get_metadata(video_path: str):
"""
获取视频元数据
"""
if not video_path:
return False
try:
command = ['ffprobe', '-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', video_path]
result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if result.returncode == 0:
return json.loads(result.stdout.decode("utf-8"))
except Exception as e:
print(e)
return None
@staticmethod
def extract_subtitle(video_path: str, subtitle_path: str, subtitle_index: str = None):
"""
从视频中提取字幕
"""
if not video_path or not subtitle_path:
return False
if subtitle_index:
command = ['ffmpeg', "-hide_banner", "-loglevel", "warning", '-y', '-i', video_path,
'-map', f'0:s:{subtitle_index}',
subtitle_path]
else:
command = ['ffmpeg', "-hide_banner", "-loglevel", "warning", '-y', '-i', video_path, subtitle_path]
ret = subprocess.run(command).returncode
if ret == 0:
return True
return False

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,115 @@
import hashlib
import json
import time
from typing import Tuple, Optional
from app.utils.http import RequestUtils
class IyuuHelper(object):
"""
适配新版本IYUU开发版
"""
_version = "8.2.0"
_api_base = "https://dev.iyuu.cn"
_sites = {}
_token = None
_sid_sha1 = None
def __init__(self, token: str):
self._token = token
if self._token:
self.init_config()
def init_config(self):
pass
def __request_iyuu(self, url: str, method: str = "get", params: dict = None) -> Tuple[Optional[dict], str]:
"""
向IYUUApi发送请求
"""
if method == "post":
ret = RequestUtils(
accept_type="application/json",
headers={'token': self._token}
).post_res(f'{self._api_base + url}', json=params)
else:
ret = RequestUtils(
accept_type="application/json",
headers={'token': self._token}
).get_res(f'{self._api_base + url}', params=params)
if ret:
result = ret.json()
if result.get('code') == 0:
return result.get('data'), ""
else:
return None, f'请求IYUU失败状态码{result.get("code")},返回信息:{result.get("msg")}'
elif ret is not None:
return None, f"请求IYUU失败状态码{ret.status_code},错误原因:{ret.reason}"
else:
return None, f"请求IYUU失败未获取到返回信息"
def get_torrent_url(self, sid: str) -> Tuple[Optional[str], Optional[str]]:
if not sid:
return None, None
if not self._sites:
self._sites = self.__get_sites()
if not self._sites.get(sid):
return None, None
site = self._sites.get(sid)
return site.get('base_url'), site.get('download_page')
def __get_sites(self) -> dict:
"""
返回支持辅种的全部站点
:return: 站点列表、错误信息
"""
result, msg = self.__request_iyuu(url='/reseed/sites/index')
if result:
ret_sites = {}
sites = result.get('sites')
for site in sites:
ret_sites[site.get('id')] = site
return ret_sites
else:
print(msg)
return {}
def __report_existing(self) -> Optional[str]:
"""
汇报辅种的站点
:return:
"""
if not self._sites:
self._sites = self.__get_sites()
sid_list = list(self._sites.keys())
result, msg = self.__request_iyuu(url='/reseed/sites/reportExisting',
method='post',
params={'sid_list': sid_list})
if result:
return result.get('sid_sha1')
return None
def get_seed_info(self, info_hashs: list) -> Tuple[Optional[dict], str]:
"""
返回info_hash对应的站点id、种子id
:param info_hashs:
:return:
"""
if not self._sid_sha1:
self._sid_sha1 = self.__report_existing()
info_hashs.sort()
json_data = json.dumps(info_hashs, separators=(',', ':'), ensure_ascii=False)
sha1 = self.get_sha1(json_data)
result, msg = self.__request_iyuu(url='/reseed/index/index', method='post', params={
'hash': json_data,
'sha1': sha1,
'sid_sha1': self._sid_sha1,
'timestamp': int(time.time()),
'version': self._version
})
return result, msg
@staticmethod
def get_sha1(json_str: str) -> str:
return hashlib.sha1(json_str.encode('utf-8')).hexdigest()

View File

@@ -0,0 +1,437 @@
from datetime import datetime, timedelta
from pathlib import Path
from threading import Event
from typing import List, Tuple, Dict, Any
import pytz
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from app.core.config import settings
from app.core.metainfo import MetaInfoPath
from app.db.transferhistory_oper import TransferHistoryOper
from app.helper.nfo import NfoReader
from app.log import logger
from app.plugins import _PluginBase
from app.schemas import MediaType
from app.utils.system import SystemUtils
class LibraryScraper(_PluginBase):
# 插件名称
plugin_name = "媒体库刮削"
# 插件描述
plugin_desc = "定时对媒体库进行刮削,补齐缺失元数据和图片。"
# 插件图标
plugin_icon = "scraper.png"
# 插件版本
plugin_version = "1.5"
# 插件作者
plugin_author = "jxxghp"
# 作者主页
author_url = "https://github.com/jxxghp"
# 插件配置项ID前缀
plugin_config_prefix = "libraryscraper_"
# 加载顺序
plugin_order = 7
# 可使用的用户级别
user_level = 1
# 私有属性
transferhis = None
_scheduler = None
_scraper = None
# 限速开关
_enabled = False
_onlyonce = False
_cron = None
_mode = ""
_scraper_paths = ""
_exclude_paths = ""
# 退出事件
_event = Event()
def init_plugin(self, config: dict = None):
# 读取配置
if config:
self._enabled = config.get("enabled")
self._onlyonce = config.get("onlyonce")
self._cron = config.get("cron")
self._mode = config.get("mode") or ""
self._scraper_paths = config.get("scraper_paths") or ""
self._exclude_paths = config.get("exclude_paths") or ""
# 停止现有任务
self.stop_service()
# 启动定时任务 & 立即运行一次
if self._enabled or self._onlyonce:
self.transferhis = TransferHistoryOper()
if self._onlyonce:
logger.info(f"媒体库刮削服务,立即运行一次")
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
self._scheduler.add_job(func=self.__libraryscraper, trigger='date',
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
name="媒体库刮削")
# 关闭一次性开关
self._onlyonce = False
self.update_config({
"onlyonce": False,
"enabled": self._enabled,
"cron": self._cron,
"mode": self._mode,
"scraper_paths": self._scraper_paths,
"exclude_paths": self._exclude_paths
})
if self._scheduler.get_jobs():
# 启动服务
self._scheduler.print_jobs()
self._scheduler.start()
def get_state(self) -> bool:
return self._enabled
@staticmethod
def get_command() -> List[Dict[str, Any]]:
pass
def get_api(self) -> List[Dict[str, Any]]:
pass
def get_service(self) -> List[Dict[str, Any]]:
"""
注册插件公共服务
[{
"id": "服务ID",
"name": "服务名称",
"trigger": "触发器cron/interval/date/CronTrigger.from_crontab()",
"func": self.xxx,
"kwargs": {} # 定时器参数
}]
"""
if self._enabled and self._cron:
return [{
"id": "LibraryScraper",
"name": "媒体库刮削",
"trigger": CronTrigger.from_crontab(self._cron),
"func": self.__libraryscraper,
"kwargs": {}
}]
elif self._enabled:
return [{
"id": "LibraryScraper",
"name": "媒体库刮削",
"trigger": CronTrigger.from_crontab("0 0 */7 * *"),
"func": self.__libraryscraper,
"kwargs": {}
}]
return []
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
return [
{
'component': 'VForm',
'content': [
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'enabled',
'label': '启用插件',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'onlyonce',
'label': '立即运行一次',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VSelect',
'props': {
'model': 'mode',
'label': '刮削模式',
'items': [
{'title': '仅刮削缺失元数据和图片', 'value': ''},
{'title': '覆盖所有元数据和图片', 'value': 'force_all'},
{'title': '覆盖所有元数据', 'value': 'force_nfo'},
{'title': '覆盖所有图片', 'value': 'force_image'},
]
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'cron',
'label': '执行周期',
'placeholder': '5位cron表达式留空自动'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12
},
'content': [
{
'component': 'VTextarea',
'props': {
'model': 'scraper_paths',
'label': '削刮路径',
'rows': 5,
'placeholder': '每一行一个目录'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12
},
'content': [
{
'component': 'VTextarea',
'props': {
'model': 'exclude_paths',
'label': '排除路径',
'rows': 2,
'placeholder': '每一行一个目录'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VAlert',
'props': {
'type': 'info',
'variant': 'tonal',
'text': '刮削路径后拼接#电视剧/电影,强制指定该媒体路径媒体类型。'
'不加默认根据文件名自动识别媒体类型。'
}
}
]
}
]
}
]
}
], {
"enabled": False,
"cron": "0 0 */7 * *",
"mode": "",
"scraper_paths": "",
"err_hosts": ""
}
def get_page(self) -> List[dict]:
pass
def __libraryscraper(self):
"""
开始刮削媒体库
"""
if not self._scraper_paths:
return
# 排除目录
exclude_paths = self._exclude_paths.split("\n")
# 已选择的目录
paths = self._scraper_paths.split("\n")
for path in paths:
if not path:
continue
# 强制指定该路径媒体类型
mtype = None
if str(path).count("#") == 1:
mtype = next(
(mediaType for mediaType in MediaType.__members__.values() if
mediaType.value == str(str(path).split("#")[1])),
None)
path = str(path).split("#")[0]
scraper_path = Path(path)
if not scraper_path.exists():
logger.warning(f"媒体库刮削路径不存在:{path}")
continue
logger.info(f"开始刮削媒体库:{path} {mtype} ...")
# 遍历所有文件
files = SystemUtils.list_files(scraper_path, settings.RMT_MEDIAEXT)
for file_path in files:
if self._event.is_set():
logger.info(f"媒体库刮削服务停止")
return
# 排除目录
exclude_flag = False
for exclude_path in exclude_paths:
try:
if file_path.is_relative_to(Path(exclude_path)):
exclude_flag = True
break
except Exception as err:
print(str(err))
if exclude_flag:
logger.debug(f"{file_path} 在排除目录中,跳过 ...")
continue
# 开始刮削文件
self.__scrape_file(file=file_path, mtype=mtype)
logger.info(f"媒体库 {path} 刮削完成")
def __scrape_file(self, file: Path, mtype: MediaType = None):
"""
削刮一个目录,该目录必须是媒体文件目录
"""
# 识别元数据
meta_info = MetaInfoPath(file)
# 强制指定类型
if mtype:
meta_info.type = mtype
# 是否刮削
force_nfo = self._mode in ["force_all", "force_nfo"]
force_img = self._mode in ["force_all", "force_image"]
# 优先读取本地nfo文件
tmdbid = None
if meta_info.type == MediaType.MOVIE:
# 电影
movie_nfo = file.parent / "movie.nfo"
if movie_nfo.exists():
tmdbid = self.__get_tmdbid_from_nfo(movie_nfo)
file_nfo = file.with_suffix(".nfo")
if not tmdbid and file_nfo.exists():
tmdbid = self.__get_tmdbid_from_nfo(file_nfo)
else:
# 电视剧
tv_nfo = file.parent.parent / "tvshow.nfo"
if tv_nfo.exists():
tmdbid = self.__get_tmdbid_from_nfo(tv_nfo)
if tmdbid:
# 按TMDBID识别
logger.info(f"读取到本地nfo文件的tmdbid{tmdbid}")
mediainfo = self.chain.recognize_media(tmdbid=tmdbid, mtype=meta_info.type)
else:
# 按名称识别
mediainfo = self.chain.recognize_media(meta=meta_info)
if not mediainfo:
logger.warn(f"未识别到媒体信息:{file}")
return
# 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title
if not settings.SCRAP_FOLLOW_TMDB:
transfer_history = self.transferhis.get_by_type_tmdbid(tmdbid=mediainfo.tmdb_id,
mtype=mediainfo.type.value)
if transfer_history:
mediainfo.title = transfer_history.title
# 获取图片
self.chain.obtain_images(mediainfo)
# 刮削
self.chain.scrape_metadata(path=file,
mediainfo=mediainfo,
transfer_type=settings.TRANSFER_TYPE,
force_nfo=force_nfo,
force_img=force_img)
@staticmethod
def __get_tmdbid_from_nfo(file_path: Path):
"""
从nfo文件中获取信息
:param file_path:
:return: tmdbid
"""
if not file_path:
return None
xpaths = [
"uniqueid[@type='Tmdb']",
"uniqueid[@type='tmdb']",
"uniqueid[@type='TMDB']",
"tmdbid"
]
try:
reader = NfoReader(file_path)
for xpath in xpaths:
tmdbid = reader.get_element_value(xpath)
if tmdbid:
return tmdbid
except Exception as err:
logger.warn(f"从nfo文件中获取tmdbid失败{str(err)}")
return None
def stop_service(self):
"""
退出插件
"""
try:
if self._scheduler:
self._scheduler.remove_all_jobs()
if self._scheduler.running:
self._event.set()
self._scheduler.shutdown()
self._event.clear()
self._scheduler = None
except Exception as e:
print(str(e))

View File

@@ -0,0 +1,295 @@
import time
from typing import Any, List, Dict, Tuple
from app.core.event import eventmanager, Event
from app.log import logger
from app.modules.emby import Emby
from app.modules.jellyfin import Jellyfin
from app.modules.plex import Plex
from app.plugins import _PluginBase
from app.schemas import WebhookEventInfo
from app.schemas.types import EventType, MediaType, MediaImageType, NotificationType
from app.utils.web import WebUtils
class MediaServerMsg(_PluginBase):
# 插件名称
plugin_name = "媒体库服务器通知"
# 插件描述
plugin_desc = "发送Emby/Jellyfin/Plex服务器的播放、入库等通知消息。"
# 插件图标
plugin_icon = "mediaplay.png"
# 插件版本
plugin_version = "1.3"
# 插件作者
plugin_author = "jxxghp"
# 作者主页
author_url = "https://github.com/jxxghp"
# 插件配置项ID前缀
plugin_config_prefix = "mediaservermsg_"
# 加载顺序
plugin_order = 14
# 可使用的用户级别
auth_level = 1
# 对像
plex = None
emby = None
jellyfin = None
# 私有属性
_enabled = False
_types = []
_webhook_msg_keys = {}
# 拼装消息内容
_webhook_actions = {
"library.new": "新入库",
"system.webhooktest": "测试",
"playback.start": "开始播放",
"playback.stop": "停止播放",
"user.authenticated": "登录成功",
"user.authenticationfailed": "登录失败",
"media.play": "开始播放",
"media.stop": "停止播放",
"PlaybackStart": "开始播放",
"PlaybackStop": "停止播放",
"item.rate": "标记了"
}
_webhook_images = {
"emby": "https://emby.media/notificationicon.png",
"plex": "https://www.plex.tv/wp-content/uploads/2022/04/new-logo-process-lines-gray.png",
"jellyfin": "https://play-lh.googleusercontent.com/SCsUK3hCCRqkJbmLDctNYCfehLxsS4ggD1ZPHIFrrAN1Tn9yhjmGMPep2D9lMaaa9eQi"
}
def init_plugin(self, config: dict = None):
if config:
self._enabled = config.get("enabled")
self._types = config.get("types") or []
if self._enabled:
self.emby = Emby()
self.plex = Plex()
self.jellyfin = Jellyfin()
def get_state(self) -> bool:
return self._enabled
@staticmethod
def get_command() -> List[Dict[str, Any]]:
pass
def get_api(self) -> List[Dict[str, Any]]:
pass
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
"""
拼装插件配置页面需要返回两块数据1、页面配置2、数据结构
"""
types_options = [
{"title": "新入库", "value": "library.new"},
{"title": "开始播放", "value": "playback.start|media.play|PlaybackStart"},
{"title": "停止播放", "value": "playback.stop|media.stop|PlaybackStop"},
{"title": "用户标记", "value": "item.rate"},
{"title": "测试", "value": "system.webhooktest"},
{"title": "登录成功", "value": "user.authenticated"},
{"title": "登录失败", "value": "user.authenticationfailed"},
]
return [
{
'component': 'VForm',
'content': [
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'enabled',
'label': '启用插件',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VSelect',
'props': {
'chips': True,
'multiple': True,
'model': 'types',
'label': '消息类型',
'items': types_options
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VAlert',
'props': {
'type': 'info',
'variant': 'tonal',
'text': '需要设置媒体服务器Webhook回调相对路径为 /api/v1/webhook?token=moviepilot3001端口其中 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

View File

@@ -0,0 +1,170 @@
import time
from typing import Any, List, Dict, Tuple
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.event import eventmanager, Event
from app.modules.emby import Emby
from app.modules.jellyfin import Jellyfin
from app.modules.plex import Plex
from app.plugins import _PluginBase
from app.schemas import TransferInfo, RefreshMediaItem
from app.schemas.types import EventType
from app.log import logger
class MediaServerRefresh(_PluginBase):
# 插件名称
plugin_name = "媒体库服务器刷新"
# 插件描述
plugin_desc = "入库后自动刷新Emby/Jellyfin/Plex服务器海报墙。"
# 插件图标
plugin_icon = "refresh2.png"
# 插件版本
plugin_version = "1.2"
# 插件作者
plugin_author = "jxxghp"
# 作者主页
author_url = "https://github.com/jxxghp"
# 插件配置项ID前缀
plugin_config_prefix = "mediaserverrefresh_"
# 加载顺序
plugin_order = 14
# 可使用的用户级别
auth_level = 1
# 私有属性
_enabled = False
_delay = 0
_emby = None
_jellyfin = None
_plex = None
def init_plugin(self, config: dict = None):
self._emby = Emby()
self._jellyfin = Jellyfin()
self._plex = Plex()
if config:
self._enabled = config.get("enabled")
self._delay = config.get("delay") or 0
def get_state(self) -> bool:
return self._enabled
@staticmethod
def get_command() -> List[Dict[str, Any]]:
pass
def get_api(self) -> List[Dict[str, Any]]:
pass
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
"""
拼装插件配置页面需要返回两块数据1、页面配置2、数据结构
"""
return [
{
'component': 'VForm',
'content': [
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'enabled',
'label': '启用插件',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'delay',
'label': '延迟时间(秒)',
'placeholder': '0'
}
}
]
}
]
}
]
}
], {
"enabled": False,
"delay": 0
}
def get_page(self) -> List[dict]:
pass
@eventmanager.register(EventType.TransferComplete)
def refresh(self, event: Event):
"""
发送通知消息
"""
if not self._enabled:
return
event_info: dict = event.event_data
if not event_info:
return
# 刷新媒体库
if not settings.MEDIASERVER:
return
if self._delay:
logger.info(f"延迟 {self._delay} 秒后刷新媒体库... ")
time.sleep(float(self._delay))
# 入库数据
transferinfo: TransferInfo = event_info.get("transferinfo")
mediainfo: MediaInfo = event_info.get("mediainfo")
items = [
RefreshMediaItem(
title=mediainfo.title,
year=mediainfo.year,
type=mediainfo.type,
category=mediainfo.category,
target_path=transferinfo.target_path
)
]
# Emby
if "emby" in settings.MEDIASERVER:
self._emby.refresh_library_by_items(items)
# Jeyllyfin
if "jellyfin" in settings.MEDIASERVER:
# FIXME Jellyfin未找到刷新单个项目的API
self._jellyfin.refresh_root_library()
# Plex
if "plex" in settings.MEDIASERVER:
self._plex.refresh_library_by_items(items)
def stop_service(self):
"""
退出插件
"""
pass

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,430 @@
import json
import re
from datetime import datetime
from app.core.config import settings
from app.plugins import _PluginBase
from app.core.event import eventmanager
from app.schemas.types import EventType, MessageChannel
from app.utils.http import RequestUtils
from typing import Any, List, Dict, Tuple, Optional
from app.log import logger
class MessageForward(_PluginBase):
# 插件名称
plugin_name = "消息转发"
# 插件描述
plugin_desc = "根据正则转发通知到其他WeChat应用。"
# 插件图标
plugin_icon = "forward.png"
# 插件版本
plugin_version = "1.1"
# 插件作者
plugin_author = "thsrite"
# 作者主页
author_url = "https://github.com/thsrite"
# 插件配置项ID前缀
plugin_config_prefix = "messageforward_"
# 加载顺序
plugin_order = 16
# 可使用的用户级别
auth_level = 1
# 私有属性
_enabled = False
_wechat = None
_pattern = None
_pattern_token = {}
# 企业微信发送消息URL
_send_msg_url = f"{settings.WECHAT_PROXY}/cgi-bin/message/send?access_token=%s"
# 企业微信获取TokenURL
_token_url = f"{settings.WECHAT_PROXY}/cgi-bin/gettoken?corpid=%s&corpsecret=%s"
def init_plugin(self, config: dict = None):
if config:
self._enabled = config.get("enabled")
self._wechat = config.get("wechat")
self._pattern = config.get("pattern")
# 获取token存库
if self._enabled and self._wechat:
self.__save_wechat_token()
def get_state(self) -> bool:
return self._enabled
@staticmethod
def get_command() -> List[Dict[str, Any]]:
pass
def get_api(self) -> List[Dict[str, Any]]:
pass
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
"""
拼装插件配置页面需要返回两块数据1、页面配置2、数据结构
"""
return [
{
'component': 'VForm',
'content': [
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'enabled',
'label': '开启转发'
}
}
]
},
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VTextarea',
'props': {
'model': 'wechat',
'rows': '5',
'label': '应用配置',
'placeholder': 'appid:corpid:appsecret一行一个配置'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VTextarea',
'props': {
'model': 'pattern',
'rows': '6',
'label': '正则配置',
'placeholder': '对应上方应用配置,一行一个,一一对应'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VAlert',
'props': {
'type': 'info',
'variant': 'tonal',
'text': '根据正则表达式把MoviePilot的消息转发到多个微信应用。'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VAlert',
'props': {
'type': 'info',
'variant': 'tonal',
'text': '应用配置可加注释:'
'appid:corpid:appsecret#站点通知'
}
}
]
}
]
}
]
}
], {
"enabled": False,
"wechat": "",
"pattern": ""
}
def get_page(self) -> List[dict]:
pass
@eventmanager.register(EventType.NoticeMessage)
def send(self, event):
"""
消息转发
"""
if not self._enabled:
return
# 消息体
data = event.event_data
channel = data['channel']
if channel and channel != MessageChannel.Wechat:
return
title = data['title']
text = data['text']
image = data['image']
userid = data['userid']
# 正则匹配
patterns = self._pattern.split("\n")
for index, pattern in enumerate(patterns):
msg_match = re.search(pattern, title)
if msg_match:
access_token, appid = self.__flush_access_token(index)
if not access_token:
logger.error("未获取到有效token请检查配置")
continue
# 发送消息
if image:
self.__send_image_message(title, text, image, userid, access_token, appid, index)
else:
self.__send_message(title, text, userid, access_token, appid, index)
def __save_wechat_token(self):
"""
获取并存储wechat token
"""
# 解析配置
wechats = self._wechat.split("\n")
for index, wechat in enumerate(wechats):
# 排除注释
wechat = wechat.split("#")[0]
wechat_config = wechat.split(":")
if len(wechat_config) != 3:
logger.error(f"{wechat} 应用配置不正确")
continue
appid = wechat_config[0]
corpid = wechat_config[1]
appsecret = wechat_config[2]
# 已过期重新获取token
access_token, expires_in, access_token_time = self.__get_access_token(corpid=corpid,
appsecret=appsecret)
if not access_token:
# 没有token获取token
logger.error(f"wechat配置 appid = {appid} 获取token失败请检查配置")
continue
self._pattern_token[index] = {
"appid": appid,
"corpid": corpid,
"appsecret": appsecret,
"access_token": access_token,
"expires_in": expires_in,
"access_token_time": access_token_time,
}
def __flush_access_token(self, index: int, force: bool = False):
"""
获取第i个配置wechat token
"""
wechat_token = self._pattern_token[index]
if not wechat_token:
logger.error(f"未获取到第 {index} 条正则对应的wechat应用token请检查配置")
return None
access_token = wechat_token['access_token']
expires_in = wechat_token['expires_in']
access_token_time = wechat_token['access_token_time']
appid = wechat_token['appid']
corpid = wechat_token['corpid']
appsecret = wechat_token['appsecret']
# 判断token有效期
if force or (datetime.now() - access_token_time).seconds >= expires_in:
# 重新获取token
access_token, expires_in, access_token_time = self.__get_access_token(corpid=corpid,
appsecret=appsecret)
if not access_token:
logger.error(f"wechat配置 appid = {appid} 获取token失败请检查配置")
return None, None
self._pattern_token[index] = {
"appid": appid,
"corpid": corpid,
"appsecret": appsecret,
"access_token": access_token,
"expires_in": expires_in,
"access_token_time": access_token_time,
}
return access_token, appid
def __send_message(self, title: str, text: str = None, userid: str = None, access_token: str = None,
appid: str = None, index: int = None) -> Optional[bool]:
"""
发送文本消息
:param title: 消息标题
:param text: 消息内容
:param userid: 消息发送对象的ID为空则发给所有人
:return: 发送状态,错误信息
"""
if text:
conent = "%s\n%s" % (title, text.replace("\n\n", "\n"))
else:
conent = title
if not userid:
userid = "@all"
req_json = {
"touser": userid,
"msgtype": "text",
"agentid": appid,
"text": {
"content": conent
},
"safe": 0,
"enable_id_trans": 0,
"enable_duplicate_check": 0
}
return self.__post_request(access_token=access_token, req_json=req_json, index=index, title=title)
def __send_image_message(self, title: str, text: str, image_url: str, userid: str = None,
access_token: str = None, appid: str = None, index: int = None) -> Optional[bool]:
"""
发送图文消息
:param title: 消息标题
:param text: 消息内容
:param image_url: 图片地址
:param userid: 消息发送对象的ID为空则发给所有人
:return: 发送状态,错误信息
"""
if text:
text = text.replace("\n\n", "\n")
if not userid:
userid = "@all"
req_json = {
"touser": userid,
"msgtype": "news",
"agentid": appid,
"news": {
"articles": [
{
"title": title,
"description": text,
"picurl": image_url,
"url": ''
}
]
}
}
return self.__post_request(access_token=access_token, req_json=req_json, index=index, title=title)
def __post_request(self, access_token: str, req_json: dict, index: int, title: str, retry: int = 0) -> bool:
message_url = self._send_msg_url % access_token
"""
向微信发送请求
"""
try:
res = RequestUtils(content_type='application/json').post(
message_url,
data=json.dumps(req_json, ensure_ascii=False).encode('utf-8')
)
if res and res.status_code == 200:
ret_json = res.json()
if ret_json.get('errcode') == 0:
logger.info(f"转发消息 {title} 成功")
return True
else:
if ret_json.get('errcode') == 81013:
return False
logger.error(f"转发消息 {title} 失败,错误信息:{ret_json}")
if ret_json.get('errcode') == 42001 or ret_json.get('errcode') == 40014:
logger.info("token已过期正在重新刷新token重试")
# 重新获取token
access_token, appid = self.__flush_access_token(index=index,
force=True)
if access_token:
retry += 1
# 重发请求
if retry <= 3:
return self.__post_request(access_token=access_token,
req_json=req_json,
index=index,
title=title,
retry=retry)
return False
elif res is not None:
logger.error(f"转发消息 {title} 失败,错误码:{res.status_code},错误原因:{res.reason}")
return False
else:
logger.error(f"转发消息 {title} 失败,未获取到返回信息")
return False
except Exception as err:
logger.error(f"转发消息 {title} 异常,错误信息:{str(err)}")
return False
def __get_access_token(self, corpid: str, appsecret: str):
"""
获取微信Token
:return 微信Token
"""
try:
token_url = self._token_url % (corpid, appsecret)
res = RequestUtils().get_res(token_url)
if res:
ret_json = res.json()
if ret_json.get('errcode') == 0:
access_token = ret_json.get('access_token')
expires_in = ret_json.get('expires_in')
access_token_time = datetime.now()
return access_token, expires_in, access_token_time
else:
logger.error(f"{ret_json.get('errmsg')}")
return None, None, None
else:
logger.error(f"{corpid} {appsecret} 获取token失败")
return None, None, None
except Exception as e:
logger.error(f"获取微信access_token失败错误信息{str(e)}")
return None, None, None
def stop_service(self):
"""
退出插件
"""
pass

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,775 @@
import datetime
import re
import traceback
from pathlib import Path
from threading import Lock
from typing import Optional, Any, List, Dict, Tuple
import pytz
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from app import schemas
from app.chain.download import DownloadChain
from app.chain.search import SearchChain
from app.chain.subscribe import SubscribeChain
from app.core.config import settings
from app.core.context import MediaInfo, TorrentInfo, Context
from app.core.metainfo import MetaInfo
from app.helper.rss import RssHelper
from app.log import logger
from app.plugins import _PluginBase
from app.schemas import ExistMediaInfo
from app.schemas.types import SystemConfigKey, MediaType
lock = Lock()
class RssSubscribe(_PluginBase):
# 插件名称
plugin_name = "自定义订阅"
# 插件描述
plugin_desc = "定时刷新RSS报文识别内容后添加订阅或直接下载。"
# 插件图标
plugin_icon = "rss.png"
# 插件版本
plugin_version = "1.5"
# 插件作者
plugin_author = "jxxghp"
# 作者主页
author_url = "https://github.com/jxxghp"
# 插件配置项ID前缀
plugin_config_prefix = "rsssubscribe_"
# 加载顺序
plugin_order = 19
# 可使用的用户级别
auth_level = 2
# 私有变量
_scheduler: Optional[BackgroundScheduler] = None
_cache_path: Optional[Path] = None
rsshelper = None
downloadchain = None
searchchain = None
subscribechain = None
# 配置属性
_enabled: bool = False
_cron: str = ""
_notify: bool = False
_onlyonce: bool = False
_address: str = ""
_include: str = ""
_exclude: str = ""
_proxy: bool = False
_filter: bool = False
_clear: bool = False
_clearflag: bool = False
_action: str = "subscribe"
_save_path: str = ""
_size_range: str = ""
def init_plugin(self, config: dict = None):
self.rsshelper = RssHelper()
self.downloadchain = DownloadChain()
self.searchchain = SearchChain()
self.subscribechain = SubscribeChain()
# 停止现有任务
self.stop_service()
# 配置
if config:
self.__validate_and_fix_config(config=config)
self._enabled = config.get("enabled")
self._cron = config.get("cron")
self._notify = config.get("notify")
self._onlyonce = config.get("onlyonce")
self._address = config.get("address")
self._include = config.get("include")
self._exclude = config.get("exclude")
self._proxy = config.get("proxy")
self._filter = config.get("filter")
self._clear = config.get("clear")
self._action = config.get("action")
self._save_path = config.get("save_path")
self._size_range = config.get("size_range")
if self._onlyonce:
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
logger.info(f"自定义订阅服务启动,立即运行一次")
self._scheduler.add_job(func=self.check, trigger='date',
run_date=datetime.datetime.now(
tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3)
)
# 启动任务
if self._scheduler.get_jobs():
self._scheduler.print_jobs()
self._scheduler.start()
if self._onlyonce or self._clear:
# 关闭一次性开关
self._onlyonce = False
# 记录清理缓存设置
self._clearflag = self._clear
# 关闭清理缓存开关
self._clear = False
# 保存设置
self.__update_config()
def get_state(self) -> bool:
return self._enabled
@staticmethod
def get_command() -> List[Dict[str, Any]]:
"""
定义远程控制命令
:return: 命令关键字、事件、描述、附带数据
"""
pass
def get_api(self) -> List[Dict[str, Any]]:
"""
获取插件API
[{
"path": "/xx",
"endpoint": self.xxx,
"methods": ["GET", "POST"],
"summary": "API说明"
}]
"""
return [
{
"path": "/delete_history",
"endpoint": self.delete_history,
"methods": ["GET"],
"summary": "删除自定义订阅历史记录"
}
]
def get_service(self) -> List[Dict[str, Any]]:
"""
注册插件公共服务
[{
"id": "服务ID",
"name": "服务名称",
"trigger": "触发器cron/interval/date/CronTrigger.from_crontab()",
"func": self.xxx,
"kwargs": {} # 定时器参数
}]
"""
if self._enabled and self._cron:
return [{
"id": "RssSubscribe",
"name": "自定义订阅服务",
"trigger": CronTrigger.from_crontab(self._cron),
"func": self.check,
"kwargs": {}
}]
elif self._enabled:
return [{
"id": "RssSubscribe",
"name": "自定义订阅服务",
"trigger": "interval",
"func": self.check,
"kwargs": {"minutes": 30}
}]
return []
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
"""
拼装插件配置页面需要返回两块数据1、页面配置2、数据结构
"""
return [
{
'component': 'VForm',
'content': [
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'enabled',
'label': '启用插件',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'notify',
'label': '发送通知',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'onlyonce',
'label': '立即运行一次',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'cron',
'label': '执行周期',
'placeholder': '5位cron表达式留空自动'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VSelect',
'props': {
'model': 'action',
'label': '动作',
'items': [
{'title': '订阅', 'value': 'subscribe'},
{'title': '下载', 'value': 'download'}
]
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12
},
'content': [
{
'component': 'VTextarea',
'props': {
'model': 'address',
'label': 'RSS地址',
'rows': 3,
'placeholder': '每行一个RSS地址'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'include',
'label': '包含',
'placeholder': '支持正则表达式'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'exclude',
'label': '排除',
'placeholder': '支持正则表达式'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'size_range',
'label': '种子大小(GB)',
'placeholder': '3 或 3-5'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'save_path',
'label': '保存目录',
'placeholder': '下载时有效,留空自动'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'proxy',
'label': '使用代理服务器',
}
}
]
}, {
'component': 'VCol',
'props': {
'cols': 12,
'md': 4,
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'filter',
'label': '使用过滤规则',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'clear',
'label': '清理历史记录',
}
}
]
}
]
}
]
}
], {
"enabled": False,
"notify": True,
"onlyonce": False,
"cron": "*/30 * * * *",
"address": "",
"include": "",
"exclude": "",
"proxy": False,
"clear": False,
"filter": False,
"action": "subscribe",
"save_path": "",
"size_range": ""
}
def get_page(self) -> List[dict]:
"""
拼装插件详情页面,需要返回页面配置,同时附带数据
"""
# 查询同步详情
historys = self.get_data('history')
if not historys:
return [
{
'component': 'div',
'text': '暂无数据',
'props': {
'class': 'text-center',
}
}
]
# 数据按时间降序排序
historys = sorted(historys, key=lambda x: x.get('time'), reverse=True)
# 拼装页面
contents = []
for history in historys:
title = history.get("title")
poster = history.get("poster")
mtype = history.get("type")
time_str = history.get("time")
contents.append(
{
'component': 'VCard',
'content': [
{
"component": "VDialogCloseBtn",
"props": {
'innerClass': 'absolute top-0 right-0',
},
'events': {
'click': {
'api': 'plugin/RssSubscribe/delete_history',
'method': 'get',
'params': {
'key': title,
'apikey': settings.API_TOKEN
}
}
},
},
{
'component': 'div',
'props': {
'class': 'd-flex justify-space-start flex-nowrap flex-row',
},
'content': [
{
'component': 'div',
'content': [
{
'component': 'VImg',
'props': {
'src': poster,
'height': 120,
'width': 80,
'aspect-ratio': '2/3',
'class': 'object-cover shadow ring-gray-500',
'cover': True
}
}
]
},
{
'component': 'div',
'content': [
{
'component': 'VCardTitle',
'props': {
'class': 'pa-1 pe-5 break-words whitespace-break-spaces'
},
'text': title
},
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'类型:{mtype}'
},
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'时间:{time_str}'
}
]
}
]
}
]
}
)
return [
{
'component': 'div',
'props': {
'class': 'grid gap-3 grid-info-card',
},
'content': contents
}
]
def stop_service(self):
"""
退出插件
"""
try:
if self._scheduler:
self._scheduler.remove_all_jobs()
if self._scheduler.running:
self._scheduler.shutdown()
self._scheduler = None
except Exception as e:
logger.error("退出插件失败:%s" % str(e))
def delete_history(self, key: str, apikey: str):
"""
删除同步历史记录
"""
if apikey != settings.API_TOKEN:
return schemas.Response(success=False, message="API密钥错误")
# 历史记录
historys = self.get_data('history')
if not historys:
return schemas.Response(success=False, message="未找到历史记录")
# 删除指定记录
historys = [h for h in historys if h.get("title") != key]
self.save_data('history', historys)
return schemas.Response(success=True, message="删除成功")
def __update_config(self):
"""
更新设置
"""
self.update_config({
"enabled": self._enabled,
"notify": self._notify,
"onlyonce": self._onlyonce,
"cron": self._cron,
"address": self._address,
"include": self._include,
"exclude": self._exclude,
"proxy": self._proxy,
"clear": self._clear,
"filter": self._filter,
"action": self._action,
"save_path": self._save_path,
"size_range": self._size_range
})
def check(self):
"""
通过用户RSS同步豆瓣想看数据
"""
if not self._address:
return
# 读取历史记录
if self._clearflag:
history = []
else:
history: List[dict] = self.get_data('history') or []
for url in self._address.split("\n"):
# 处理每一个RSS链接
if not url:
continue
logger.info(f"开始刷新RSS{url} ...")
results = self.rsshelper.parse(url, proxy=self._proxy)
if not results:
logger.error(f"未获取到RSS数据{url}")
return
# 过滤规则
filter_rule = self.systemconfig.get(SystemConfigKey.SubscribeFilterRules)
# 解析数据
for result in results:
try:
title = result.get("title")
description = result.get("description")
enclosure = result.get("enclosure")
link = result.get("link")
size = result.get("size")
pubdate: datetime.datetime = result.get("pubdate")
# 检查是否处理过
if not title or title in [h.get("key") for h in history]:
continue
# 检查规则
if self._include and not re.search(r"%s" % self._include,
f"{title} {description}", re.IGNORECASE):
logger.info(f"{title} - {description} 不符合包含规则")
continue
if self._exclude and re.search(r"%s" % self._exclude,
f"{title} {description}", re.IGNORECASE):
logger.info(f"{title} - {description} 不符合排除规则")
continue
if self._size_range:
sizes = [float(_size) * 1024 ** 3 for _size in self._size_range.split("-")]
if len(sizes) == 1 and float(size) < sizes[0]:
logger.info(f"{title} - 种子大小不符合条件")
continue
elif len(sizes) > 1 and not sizes[0] <= float(size) <= sizes[1]:
logger.info(f"{title} - 种子大小不在指定范围")
continue
# 识别媒体信息
meta = MetaInfo(title=title, subtitle=description)
if not meta.name:
logger.warn(f"{title} 未识别到有效数据")
continue
mediainfo: MediaInfo = self.chain.recognize_media(meta=meta)
if not mediainfo:
logger.warn(f'未识别到媒体信息,标题:{title}')
continue
# 种子
torrentinfo = TorrentInfo(
title=title,
description=description,
enclosure=enclosure,
page_url=link,
size=size,
pubdate=pubdate.strftime("%Y-%m-%d %H:%M:%S") if pubdate else None,
site_proxy=self._proxy,
)
# 过滤种子
if self._filter:
result = self.chain.filter_torrents(
rule_string=filter_rule,
torrent_list=[torrentinfo],
mediainfo=mediainfo
)
if not result:
logger.info(f"{title} {description} 不匹配过滤规则")
continue
# 媒体库已存在的剧集
exist_info: Optional[ExistMediaInfo] = self.chain.media_exists(mediainfo=mediainfo)
if mediainfo.type == MediaType.TV:
if exist_info:
exist_season = exist_info.seasons
if exist_season:
exist_episodes = exist_season.get(meta.begin_season)
if exist_episodes and set(meta.episode_list).issubset(set(exist_episodes)):
logger.info(f'{mediainfo.title_year} {meta.season_episode} 己存在')
continue
elif exist_info:
# 电影已存在
logger.info(f'{mediainfo.title_year} 己存在')
continue
# 下载或订阅
if self._action == "download":
# 添加下载
result = self.downloadchain.download_single(
context=Context(
meta_info=meta,
media_info=mediainfo,
torrent_info=torrentinfo,
),
save_path=self._save_path,
username="RSS订阅"
)
if not result:
logger.error(f'{title} 下载失败')
continue
else:
# 检查是否在订阅中
subflag = self.subscribechain.exists(mediainfo=mediainfo, meta=meta)
if subflag:
logger.info(f'{mediainfo.title_year} {meta.season} 正在订阅中')
continue
# 添加订阅
self.subscribechain.add(title=mediainfo.title,
year=mediainfo.year,
mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id,
season=meta.begin_season,
exist_ok=True,
username="RSS订阅")
# 存储历史记录
history.append({
"title": f"{mediainfo.title} {meta.season}",
"key": f"{title}",
"type": mediainfo.type.value,
"year": mediainfo.year,
"poster": mediainfo.get_poster_image(),
"overview": mediainfo.overview,
"tmdbid": mediainfo.tmdb_id,
"time": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
})
except Exception as err:
logger.error(f'刷新RSS数据出错{str(err)} - {traceback.format_exc()}')
logger.info(f"RSS {url} 刷新完成")
# 保存历史记录
self.save_data('history', history)
# 缓存只清理一次
self._clearflag = False
def __log_and_notify_error(self, message):
"""
记录错误日志并发送系统通知
"""
logger.error(message)
self.systemmessage.put(message, title="自定义订阅")
def __validate_and_fix_config(self, config: dict = None) -> bool:
"""
检查并修正配置值
"""
size_range = config.get("size_range")
if size_range and not self.__is_number_or_range(str(size_range)):
self.__log_and_notify_error(f"自定义订阅出错,种子大小设置错误:{size_range}")
config["size_range"] = None
return False
return True
@staticmethod
def __is_number_or_range(value):
"""
检查字符串是否表示单个数字或数字范围(如'5', '5.5', '5-10''5.5-10.2'
"""
return bool(re.match(r"^\d+(\.\d+)?(-\d+(\.\d+)?)?$", value))

View File

@@ -0,0 +1,660 @@
import ipaddress
from typing import List, Tuple, Dict, Any
from app.core.config import settings
from app.core.event import eventmanager, Event
from app.log import logger
from app.modules.emby import Emby
from app.modules.jellyfin import Jellyfin
from app.modules.plex import Plex
from app.modules.qbittorrent import Qbittorrent
from app.modules.transmission import Transmission
from app.plugins import _PluginBase
from app.schemas import NotificationType, WebhookEventInfo
from app.schemas.types import EventType
from app.utils.ip import IpUtils
class SpeedLimiter(_PluginBase):
# 插件名称
plugin_name = "播放限速"
# 插件描述
plugin_desc = "外网播放媒体库视频时,自动对下载器进行限速。"
# 插件图标
plugin_icon = "Librespeed_A.png"
# 插件版本
plugin_version = "1.2"
# 插件作者
plugin_author = "Shurelol"
# 作者主页
author_url = "https://github.com/Shurelol"
# 插件配置项ID前缀
plugin_config_prefix = "speedlimit_"
# 加载顺序
plugin_order = 11
# 可使用的用户级别
auth_level = 1
# 私有属性
_scheduler = None
_qb = None
_tr = None
_enabled: bool = False
_notify: bool = False
_interval: int = 60
_downloader: list = []
_play_up_speed: float = 0
_play_down_speed: float = 0
_noplay_up_speed: float = 0
_noplay_down_speed: float = 0
_bandwidth: float = 0
_allocation_ratio: str = ""
_auto_limit: bool = False
_limit_enabled: bool = False
# 不限速地址
_unlimited_ips = {}
# 当前限速状态
_current_state = ""
_exclude_path = ""
def init_plugin(self, config: dict = None):
# 读取配置
if config:
self._enabled = config.get("enabled")
self._notify = config.get("notify")
self._play_up_speed = float(config.get("play_up_speed")) if config.get("play_up_speed") else 0
self._play_down_speed = float(config.get("play_down_speed")) if config.get("play_down_speed") else 0
self._noplay_up_speed = float(config.get("noplay_up_speed")) if config.get("noplay_up_speed") else 0
self._noplay_down_speed = float(config.get("noplay_down_speed")) if config.get("noplay_down_speed") else 0
self._current_state = f"U:{self._noplay_up_speed},D:{self._noplay_down_speed}"
self._exclude_path = config.get("exclude_path")
try:
# 总带宽
self._bandwidth = int(float(config.get("bandwidth") or 0)) * 1000000
# 自动限速开关
if self._bandwidth > 0:
self._auto_limit = True
else:
self._auto_limit = False
except Exception as e:
logger.error(f"智能限速上行带宽设置错误:{str(e)}")
self._bandwidth = 0
# 限速服务开关
self._limit_enabled = True if (self._play_up_speed
or self._play_down_speed
or self._auto_limit) else False
self._allocation_ratio = config.get("allocation_ratio") or ""
# 不限速地址
self._unlimited_ips["ipv4"] = config.get("ipv4") or ""
self._unlimited_ips["ipv6"] = config.get("ipv6") or ""
self._downloader = config.get("downloader") or []
if self._downloader:
if 'qbittorrent' in self._downloader:
self._qb = Qbittorrent()
if 'transmission' in self._downloader:
self._tr = Transmission()
def get_state(self) -> bool:
return self._enabled
@staticmethod
def get_command() -> List[Dict[str, Any]]:
pass
def get_api(self) -> List[Dict[str, Any]]:
pass
def get_service(self) -> List[Dict[str, Any]]:
"""
注册插件公共服务
[{
"id": "服务ID",
"name": "服务名称",
"trigger": "触发器cron/interval/date/CronTrigger.from_crontab()",
"func": self.xxx,
"kwargs": {} # 定时器参数
}]
"""
if self._enabled and self._limit_enabled and self._interval:
return [
{
"id": "SpeedLimiter",
"name": "播放限速检查服务",
"trigger": "interval",
"func": self.check_playing_sessions,
"kwargs": {"seconds": self._interval}
}
]
return []
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
return [
{
'component': 'VForm',
'content': [
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'enabled',
'label': '启用插件',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'notify',
'label': '发送通知',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'content': [
{
'component': 'VSelect',
'props': {
'chips': True,
'multiple': True,
'model': 'downloader',
'label': '下载器',
'items': [
{'title': 'Qbittorrent', 'value': 'qbittorrent'},
{'title': 'Transmission', 'value': 'transmission'},
]
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'play_up_speed',
'label': '播放限速(上传)',
'placeholder': 'KB/s'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'play_down_speed',
'label': '播放限速(下载)',
'placeholder': 'KB/s'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'noplay_up_speed',
'label': '未播放限速(上传)',
'placeholder': 'KB/s'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'noplay_down_speed',
'label': '未播放限速(下载)',
'placeholder': 'KB/s'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'bandwidth',
'label': '智能限速上行带宽',
'placeholder': 'Mbps'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VSelect',
'props': {
'model': 'allocation_ratio',
'label': '智能限速分配比例',
'items': [
{'title': '平均', 'value': ''},
{'title': '19', 'value': '1:9'},
{'title': '28', 'value': '2:8'},
{'title': '37', 'value': '3:7'},
{'title': '46', 'value': '4:6'},
{'title': '64', 'value': '6:4'},
{'title': '73', 'value': '7:3'},
{'title': '82', 'value': '8:2'},
{'title': '91', '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

View File

@@ -0,0 +1,579 @@
import time
from datetime import datetime
from pathlib import Path
from typing import Any, List, Dict, Tuple, Optional
from apscheduler.schedulers.background import BackgroundScheduler
from app.core.config import settings
from app.db.downloadhistory_oper import DownloadHistoryOper
from app.db.transferhistory_oper import TransferHistoryOper
from app.log import logger
from app.modules.qbittorrent import Qbittorrent
from app.modules.transmission import Transmission
from app.plugins import _PluginBase
class SyncDownloadFiles(_PluginBase):
# 插件名称
plugin_name = "下载器文件同步"
# 插件描述
plugin_desc = "同步下载器的文件信息到数据库,删除文件时联动删除下载任务。"
# 插件图标
plugin_icon = "Youtube-dl_A.png"
# 插件版本
plugin_version = "1.1.1"
# 插件作者
plugin_author = "thsrite"
# 作者主页
author_url = "https://github.com/thsrite"
# 插件配置项ID前缀
plugin_config_prefix = "syncdownloadfiles_"
# 加载顺序
plugin_order = 20
# 可使用的用户级别
auth_level = 1
# 私有属性
_enabled = False
# 任务执行间隔
_time = None
qb = None
tr = None
_onlyonce = False
_history = False
_clear = False
_downloaders = []
_dirs = None
downloadhis = None
transferhis = None
# 定时器
_scheduler: Optional[BackgroundScheduler] = None
def init_plugin(self, config: dict = None):
# 停止现有任务
self.stop_service()
self.qb = Qbittorrent()
self.tr = Transmission()
self.downloadhis = DownloadHistoryOper()
self.transferhis = TransferHistoryOper()
if config:
self._enabled = config.get('enabled')
self._time = config.get('time') or 6
self._history = config.get('history')
self._clear = config.get('clear')
self._onlyonce = config.get("onlyonce")
self._downloaders = config.get('downloaders') or []
self._dirs = config.get("dirs") or ""
if self._clear:
# 清理下载器文件记录
self.downloadhis.truncate_files()
# 清理下载器最后处理记录
for downloader in self._downloaders:
# 获取最后同步时间
self.del_data(f"last_sync_time_{downloader}")
# 关闭clear
self._clear = False
self.__update_config()
if self._onlyonce:
# 执行一次
# 关闭onlyonce
self._onlyonce = False
self.__update_config()
self.sync()
def sync(self):
"""
同步所选下载器种子记录
"""
start_time = datetime.now()
logger.info("开始同步下载器任务文件记录")
if not self._downloaders:
logger.error("未选择同步下载器,停止运行")
return
# 遍历下载器同步记录
for downloader in self._downloaders:
# 获取最后同步时间
last_sync_time = self.get_data(f"last_sync_time_{downloader}")
logger.info(f"开始扫描下载器 {downloader} ...")
downloader_obj = self.__get_downloader(downloader)
# 获取下载器中已完成的种子
torrents = downloader_obj.get_completed_torrents()
if torrents:
logger.info(f"下载器 {downloader} 已完成种子数:{len(torrents)}")
else:
logger.info(f"下载器 {downloader} 没有已完成种子")
continue
# 把种子按照名称和种子大小分组,获取添加时间最早的一个,认定为是源种子,其余为辅种
torrents = self.__get_origin_torrents(torrents, downloader)
logger.info(f"下载器 {downloader} 去除辅种,获取到源种子数:{len(torrents)}")
for torrent in torrents:
# 返回false标识后续种子已被同步
sync_flag = self.__compare_time(torrent, downloader, last_sync_time)
if not sync_flag:
logger.info(f"最后同步时间{last_sync_time}, 之前种子已被同步,结束当前下载器 {downloader} 任务")
break
# 获取种子hash
hash_str = self.__get_hash(torrent, downloader)
# 判断是否是mp下载判断download_hash是否在downloadhistory表中是则不处理
downloadhis = self.downloadhis.get_by_hash(hash_str)
if downloadhis:
downlod_files = self.downloadhis.get_files_by_hash(hash_str)
if downlod_files:
logger.info(f"种子 {hash_str} 通过MoviePilot下载跳过处理")
continue
# 获取种子download_dir
download_dir = self.__get_download_dir(torrent, downloader)
# 处理路径映射
if self._dirs:
paths = self._dirs.split("\n")
for path in paths:
sub_paths = path.split(":")
download_dir = download_dir.replace(sub_paths[0], sub_paths[1]).replace('\\', '/')
# 获取种子name
torrent_name = self.__get_torrent_name(torrent, downloader)
# 种子保存目录
save_path = Path(download_dir).joinpath(torrent_name)
# 获取种子文件
torrent_files = self.__get_torrent_files(torrent, downloader, downloader_obj)
logger.info(f"开始同步种子 {hash_str}, 文件数 {len(torrent_files)}")
download_files = []
for file in torrent_files:
# 过滤掉没下载的文件
if not self.__is_download(file, downloader):
continue
# 种子文件路径
file_path_str = self.__get_file_path(file, downloader)
file_path = Path(file_path_str)
# 只处理视频格式
if not file_path.suffix \
or file_path.suffix not in settings.RMT_MEDIAEXT:
continue
# 种子文件根路程
root_path = file_path.parts[0]
# 不含种子名称的种子文件相对路径
if root_path == torrent_name:
rel_path = str(file_path.relative_to(root_path))
else:
rel_path = str(file_path)
# 完整路径
full_path = save_path.joinpath(rel_path)
if self._history:
transferhis = self.transferhis.get_by_src(str(full_path))
if transferhis and not transferhis.download_hash:
logger.info(f"开始补充转移记录:{transferhis.id} download_hash {hash_str}")
self.transferhis.update_download_hash(historyid=transferhis.id,
download_hash=hash_str)
# 种子文件记录
download_files.append(
{
"download_hash": hash_str,
"downloader": downloader,
"fullpath": str(full_path),
"savepath": str(save_path),
"filepath": rel_path,
"torrentname": torrent_name,
}
)
if download_files:
# 登记下载文件
self.downloadhis.add_files(download_files)
logger.info(f"种子 {hash_str} 同步完成")
logger.info(f"下载器种子文件同步完成!")
self.save_data(f"last_sync_time_{downloader}",
time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time())))
# 计算耗时
end_time = datetime.now()
logger.info(f"下载器任务文件记录已同步完成。总耗时 {(end_time - start_time).seconds}")
def __update_config(self):
self.update_config({
"enabled": self._enabled,
"time": self._time,
"history": self._history,
"clear": self._clear,
"onlyonce": self._onlyonce,
"downloaders": self._downloaders,
"dirs": self._dirs
})
@staticmethod
def __get_origin_torrents(torrents: Any, dl_tpe: str):
# 把种子按照名称和种子大小分组,获取添加时间最早的一个,认定为是源种子,其余为辅种
grouped_data = {}
# 排序种子,根据种子添加时间倒序
if dl_tpe == "qbittorrent":
torrents = sorted(torrents, key=lambda x: x.get("added_on"), reverse=True)
# 遍历原始数组按照size和name进行分组
for torrent in torrents:
size = torrent.get('size')
name = torrent.get('name')
key = (size, name) # 使用元组作为字典的键
# 如果分组键不存在,则将当前元素作为最小元素添加到字典中
if key not in grouped_data:
grouped_data[key] = torrent
else:
# 如果分组键已存在则比较当前元素的time是否更小如果更小则更新字典中的元素
if torrent.get('added_on') < grouped_data[key].get('added_on'):
grouped_data[key] = torrent
else:
torrents = sorted(torrents, key=lambda x: x.added_date, reverse=True)
# 遍历原始数组按照size和name进行分组
for torrent in torrents:
size = torrent.total_size
name = torrent.name
key = (size, name) # 使用元组作为字典的键
# 如果分组键不存在,则将当前元素作为最小元素添加到字典中
if key not in grouped_data:
grouped_data[key] = torrent
else:
# 如果分组键已存在则比较当前元素的time是否更小如果更小则更新字典中的元素
if torrent.added_date < grouped_data[key].added_date:
grouped_data[key] = torrent
# 新的数组
return list(grouped_data.values())
@staticmethod
def __compare_time(torrent: Any, dl_tpe: str, last_sync_time: str = None):
if last_sync_time:
# 获取种子时间
if dl_tpe == "qbittorrent":
torrent_date = time.localtime(torrent.get("added_on")) # 将时间戳转换为时间元组
torrent_date = time.strftime("%Y-%m-%d %H:%M:%S", torrent_date) # 格式化时间
else:
torrent_date = torrent.added_date
# 之后的种子已经同步了
if last_sync_time > str(torrent_date):
return False
return True
@staticmethod
def __is_download(file: Any, dl_type: str):
"""
判断文件是否被下载
"""
try:
if dl_type == "qbittorrent":
return True
else:
return file.completed and file.completed > 0
except Exception as e:
print(str(e))
return True
@staticmethod
def __get_file_path(file: Any, dl_type: str):
"""
获取文件路径
"""
try:
return file.get("name") if dl_type == "qbittorrent" else file.name
except Exception as e:
print(str(e))
return ""
@staticmethod
def __get_torrent_files(torrent: Any, dl_type: str, downloader_obj):
"""
获取种子文件
"""
try:
return torrent.files if dl_type == "qbittorrent" else downloader_obj.get_files(tid=torrent.id)
except Exception as e:
print(str(e))
return ""
@staticmethod
def __get_torrent_name(torrent: Any, dl_type: str):
"""
获取种子name
"""
try:
return torrent.get("name") if dl_type == "qbittorrent" else torrent.name
except Exception as e:
print(str(e))
return ""
@staticmethod
def __get_download_dir(torrent: Any, dl_type: str):
"""
获取种子download_dir
"""
try:
return torrent.get("save_path") if dl_type == "qbittorrent" else torrent.download_dir
except Exception as e:
print(str(e))
return ""
@staticmethod
def __get_hash(torrent: Any, dl_type: str):
"""
获取种子hash
"""
try:
return torrent.get("hash") if dl_type == "qbittorrent" else torrent.hashString
except Exception as e:
print(str(e))
return ""
def __get_downloader(self, dtype: str):
"""
根据类型返回下载器实例
"""
if dtype == "qbittorrent":
return self.qb
elif dtype == "transmission":
return self.tr
else:
return None
def get_state(self) -> bool:
return True if self._enabled and self._time else False
@staticmethod
def get_command() -> List[Dict[str, Any]]:
pass
def get_api(self) -> List[Dict[str, Any]]:
pass
def get_service(self) -> List[Dict[str, Any]]:
"""
注册插件公共服务
[{
"id": "服务ID",
"name": "服务名称",
"trigger": "触发器cron/interval/date/CronTrigger.from_crontab()",
"func": self.xxx,
"kwargs": {} # 定时器参数
}]
"""
if self.get_state():
return [{
"id": "SyncDownloadFiles",
"name": "同步下载器文件记录服务",
"trigger": "interval",
"func": self.sync,
"kwargs": {"seconds": float(str(self._time).strip()) * 3600}
}]
return []
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
"""
拼装插件配置页面需要返回两块数据1、页面配置2、数据结构
"""
return [
{
'component': 'VForm',
'content': [
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'enabled',
'label': '开启插件',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'onlyonce',
'label': '立即运行一次',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'history',
'label': '补充整理历史记录',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'clear',
'label': '清理数据',
}
}
]
},
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'time',
'label': '同步时间间隔(小时)'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VSelect',
'props': {
'chips': True,
'multiple': True,
'model': 'downloaders',
'label': '同步下载器',
'items': [
{'title': 'Qbittorrent', 'value': 'qbittorrent'},
{'title': 'Transmission', 'value': 'transmission'}
]
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12
},
'content': [
{
'component': 'VTextarea',
'props': {
'model': 'dirs',
'label': '目录映射',
'rows': 5,
'placeholder': '每一行一个目录,下载器保存目录:MoviePilot映射目录'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VAlert',
'props': {
'type': 'info',
'variant': 'tonal',
'text': '适用于非MoviePilot下载的任务下载器种子数据较多时同步时间将会较长请耐心等候可查看实时日志了解同步进度时间间隔建议最少每6小时执行一次防止上次任务没处理完。'
}
}
]
}
]
}
]
}
], {
"enabled": False,
"onlyonce": False,
"history": False,
"clear": False,
"time": 6,
"dirs": "",
"downloaders": []
}
def get_page(self) -> List[dict]:
pass
def stop_service(self):
"""
退出插件
"""
try:
if self._scheduler:
self._scheduler.remove_all_jobs()
if self._scheduler.running:
self._scheduler.shutdown()
self._scheduler = None
except Exception as e:
logger.error("退出插件失败:%s" % str(e))

View File

@@ -0,0 +1,816 @@
import re
import threading
import time
from datetime import datetime, timedelta
from typing import List, Tuple, Dict, Any, Optional
import pytz
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from app.core.config import settings
from app.log import logger
from app.modules.qbittorrent import Qbittorrent
from app.modules.transmission import Transmission
from app.plugins import _PluginBase
from app.schemas import NotificationType
from app.utils.string import StringUtils
lock = threading.Lock()
class TorrentRemover(_PluginBase):
# 插件名称
plugin_name = "自动删种"
# 插件描述
plugin_desc = "自动删除下载器中的下载任务。"
# 插件图标
plugin_icon = "delete.jpg"
# 插件版本
plugin_version = "1.2.2"
# 插件作者
plugin_author = "jxxghp"
# 作者主页
author_url = "https://github.com/jxxghp"
# 插件配置项ID前缀
plugin_config_prefix = "torrentremover_"
# 加载顺序
plugin_order = 8
# 可使用的用户级别
auth_level = 2
# 私有属性
qb = None
tr = None
_event = threading.Event()
_scheduler = None
_enabled = False
_onlyonce = False
_notify = False
# pause/delete
_downloaders = []
_action = "pause"
_cron = None
_samedata = False
_mponly = False
_size = None
_ratio = None
_time = None
_upspeed = None
_labels = None
_pathkeywords = None
_trackerkeywords = None
_errorkeywords = None
_torrentstates = None
_torrentcategorys = None
def init_plugin(self, config: dict = None):
if config:
self._enabled = config.get("enabled")
self._onlyonce = config.get("onlyonce")
self._notify = config.get("notify")
self._downloaders = config.get("downloaders") or []
self._action = config.get("action")
self._cron = config.get("cron")
self._samedata = config.get("samedata")
self._mponly = config.get("mponly")
self._size = config.get("size") or ""
self._ratio = config.get("ratio")
self._time = config.get("time")
self._upspeed = config.get("upspeed")
self._labels = config.get("labels") or ""
self._pathkeywords = config.get("pathkeywords") or ""
self._trackerkeywords = config.get("trackerkeywords") or ""
self._errorkeywords = config.get("errorkeywords") or ""
self._torrentstates = config.get("torrentstates") or ""
self._torrentcategorys = config.get("torrentcategorys") or ""
self.stop_service()
if self.get_state() or self._onlyonce:
self.qb = Qbittorrent()
self.tr = Transmission()
if self._onlyonce:
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
logger.info(f"自动删种服务启动,立即运行一次")
self._scheduler.add_job(func=self.delete_torrents, trigger='date',
run_date=datetime.now(
tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3)
)
# 关闭一次性开关
self._onlyonce = False
# 保存设置
self.update_config({
"enabled": self._enabled,
"notify": self._notify,
"onlyonce": self._onlyonce,
"action": self._action,
"cron": self._cron,
"downloaders": self._downloaders,
"samedata": self._samedata,
"mponly": self._mponly,
"size": self._size,
"ratio": self._ratio,
"time": self._time,
"upspeed": self._upspeed,
"labels": self._labels,
"pathkeywords": self._pathkeywords,
"trackerkeywords": self._trackerkeywords,
"errorkeywords": self._errorkeywords,
"torrentstates": self._torrentstates,
"torrentcategorys": self._torrentcategorys
})
if self._scheduler.get_jobs():
# 启动服务
self._scheduler.print_jobs()
self._scheduler.start()
def get_state(self) -> bool:
return True if self._enabled and self._cron and self._downloaders else False
@staticmethod
def get_command() -> List[Dict[str, Any]]:
pass
def get_api(self) -> List[Dict[str, Any]]:
pass
def get_service(self) -> List[Dict[str, Any]]:
"""
注册插件公共服务
[{
"id": "服务ID",
"name": "服务名称",
"trigger": "触发器cron/interval/date/CronTrigger.from_crontab()",
"func": self.xxx,
"kwargs": {} # 定时器参数
}]
"""
if self.get_state():
return [{
"id": "TorrentRemover",
"name": "自动删种服务",
"trigger": CronTrigger.from_crontab(self._cron),
"func": self.delete_torrents,
"kwargs": {}
}]
return []
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
return [
{
'component': 'VForm',
'content': [
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'enabled',
'label': '启用插件',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'notify',
'label': '发送通知',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'cron',
'label': '执行周期',
'placeholder': '0 */12 * * *'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VSelect',
'props': {
'model': 'action',
'label': '动作',
'items': [
{'title': '暂停', 'value': 'pause'},
{'title': '删除种子', 'value': 'delete'},
{'title': '删除种子和文件', 'value': 'deletefile'}
]
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12
},
'content': [
{
'component': 'VSelect',
'props': {
'chips': True,
'multiple': True,
'model': 'downloaders',
'label': '下载器',
'items': [
{'title': 'Qbittorrent', 'value': 'qbittorrent'},
{'title': 'Transmission', 'value': 'transmission'}
]
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'size',
'label': '种子大小GB',
'placeholder': '例如1-10'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'ratio',
'label': '分享率',
'placeholder': ''
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'time',
'label': '做种时间(小时)',
'placeholder': ''
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'upspeed',
'label': '平均上传速度',
'placeholder': ''
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'labels',
'label': '标签',
'placeholder': '用,分隔多个标签'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'pathkeywords',
'label': '保存路径关键词',
'placeholder': '支持正式表达式'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'trackerkeywords',
'label': 'Tracker关键词',
'placeholder': '支持正式表达式'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'errorkeywords',
'label': '错误信息关键词TR',
'placeholder': '支持正式表达式仅适用于TR'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'torrentstates',
'label': '任务状态QB',
'placeholder': '用,分隔多个状态仅适用于QB'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'torrentcategorys',
'label': '任务分类',
'placeholder': '用,分隔多个分类'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'samedata',
'label': '处理辅种',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'mponly',
'label': '仅MoviePilot任务',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'onlyonce',
'label': '立即运行一次',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VAlert',
'props': {
'type': 'info',
'variant': 'tonal',
'text': '自动删种存在风险,如设置不当可能导致数据丢失!建议动作先选择暂停,确定条件正确后再改成删除。'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VAlert',
'props': {
'type': 'info',
'variant': 'tonal',
'text': '任务状态QB字典'
'downloading正在下载-传输数据,'
'stalledDL正在下载_未建立连接'
'uploading正在上传-传输数据,'
'stalledUP正在上传-未建立连接,'
'error暂停-发生错误,'
'pausedDL暂停-下载未完成,'
'pausedUP暂停-下载完成,'
'missingFiles暂停-文件丢失,'
'checkingDL检查中-下载未完成,'
'checkingUP检查中-下载完成,'
'checkingResumeData检查中-启动时恢复数据,'
'forcedDL强制下载-忽略队列,'
'queuedDL等待下载-排队,'
'forcedUP强制上传-忽略队列,'
'queuedUP等待上传-排队,'
'allocating分配磁盘空间'
'metaDL获取元数据'
'moving移动文件'
'unknown未知状态'
}
}
]
}
]
}
]
}
], {
"enabled": False,
"notify": False,
"onlyonce": False,
"action": 'pause',
'downloaders': [],
"cron": '0 */12 * * *',
"samedata": False,
"mponly": False,
"size": "",
"ratio": "",
"time": "",
"upspeed": "",
"labels": "",
"pathkeywords": "",
"trackerkeywords": "",
"errorkeywords": "",
"torrentstates": "",
"torrentcategorys": ""
}
def get_page(self) -> List[dict]:
pass
def stop_service(self):
"""
退出插件
"""
try:
if self._scheduler:
self._scheduler.remove_all_jobs()
if self._scheduler.running:
self._event.set()
self._scheduler.shutdown()
self._event.clear()
self._scheduler = None
except Exception as e:
print(str(e))
def __get_downloader(self, dtype: str):
"""
根据类型返回下载器实例
"""
if dtype == "qbittorrent":
return self.qb
elif dtype == "transmission":
return self.tr
else:
return None
def delete_torrents(self):
"""
定时删除下载器中的下载任务
"""
for downloader in self._downloaders:
try:
with lock:
# 获取需删除种子列表
torrents = self.get_remove_torrents(downloader)
logger.info(f"自动删种任务 获取符合处理条件种子数 {len(torrents)}")
# 下载器
downlader_obj = self.__get_downloader(downloader)
if self._action == "pause":
message_text = f"{downloader.title()} 共暂停{len(torrents)}个种子"
for torrent in torrents:
if self._event.is_set():
logger.info(f"自动删种服务停止")
return
text_item = f"{torrent.get('name')} " \
f"来自站点:{torrent.get('site')} " \
f"大小:{StringUtils.str_filesize(torrent.get('size'))}"
# 暂停种子
downlader_obj.stop_torrents(ids=[torrent.get("id")])
logger.info(f"自动删种任务 暂停种子:{text_item}")
message_text = f"{message_text}\n{text_item}"
elif self._action == "delete":
message_text = f"{downloader.title()} 共删除{len(torrents)}个种子"
for torrent in torrents:
if self._event.is_set():
logger.info(f"自动删种服务停止")
return
text_item = f"{torrent.get('name')} " \
f"来自站点:{torrent.get('site')} " \
f"大小:{StringUtils.str_filesize(torrent.get('size'))}"
# 删除种子
downlader_obj.delete_torrents(delete_file=False,
ids=[torrent.get("id")])
logger.info(f"自动删种任务 删除种子:{text_item}")
message_text = f"{message_text}\n{text_item}"
elif self._action == "deletefile":
message_text = f"{downloader.title()} 共删除{len(torrents)}个种子及文件"
for torrent in torrents:
if self._event.is_set():
logger.info(f"自动删种服务停止")
return
text_item = f"{torrent.get('name')} " \
f"来自站点:{torrent.get('site')} " \
f"大小:{StringUtils.str_filesize(torrent.get('size'))}"
# 删除种子
downlader_obj.delete_torrents(delete_file=True,
ids=[torrent.get("id")])
logger.info(f"自动删种任务 删除种子及文件:{text_item}")
message_text = f"{message_text}\n{text_item}"
else:
continue
if torrents and message_text and self._notify:
self.post_message(
mtype=NotificationType.SiteMessage,
title=f"【自动删种任务完成】",
text=message_text
)
except Exception as e:
logger.error(f"自动删种任务异常:{str(e)}")
def __get_qb_torrent(self, torrent: Any) -> Optional[dict]:
"""
检查QB下载任务是否符合条件
"""
# 完成时间
date_done = torrent.completion_on if torrent.completion_on > 0 else torrent.added_on
# 现在时间
date_now = int(time.mktime(datetime.now().timetuple()))
# 做种时间
torrent_seeding_time = date_now - date_done if date_done else 0
# 平均上传速度
torrent_upload_avs = torrent.uploaded / torrent_seeding_time if torrent_seeding_time else 0
# 大小 单位GB
sizes = self._size.split('-') if self._size else []
minsize = float(sizes[0]) * 1024 * 1024 * 1024 if sizes else 0
maxsize = float(sizes[-1]) * 1024 * 1024 * 1024 if sizes else 0
# 分享率
if self._ratio and torrent.ratio <= float(self._ratio):
return None
# 做种时间 单位:小时
if self._time and torrent_seeding_time <= float(self._time) * 3600:
return None
# 文件大小
if self._size and (torrent.size >= int(maxsize) or torrent.size <= int(minsize)):
return None
if self._upspeed and torrent_upload_avs >= float(self._upspeed) * 1024:
return None
if self._pathkeywords and not re.findall(self._pathkeywords, torrent.save_path, re.I):
return None
if self._trackerkeywords and not re.findall(self._trackerkeywords, torrent.tracker, re.I):
return None
if self._torrentstates and torrent.state not in self._torrentstates:
return None
if self._torrentcategorys and (not torrent.category or torrent.category not in self._torrentcategorys):
return None
return {
"id": torrent.hash,
"name": torrent.name,
"site": StringUtils.get_url_sld(torrent.tracker),
"size": torrent.size
}
def __get_tr_torrent(self, torrent: Any) -> Optional[dict]:
"""
检查TR下载任务是否符合条件
"""
# 完成时间
date_done = torrent.date_done or torrent.date_added
# 现在时间
date_now = int(time.mktime(datetime.now().timetuple()))
# 做种时间
torrent_seeding_time = date_now - int(time.mktime(date_done.timetuple())) if date_done else 0
# 上传量
torrent_uploaded = torrent.ratio * torrent.total_size
# 平均上传速茺
torrent_upload_avs = torrent_uploaded / torrent_seeding_time if torrent_seeding_time else 0
# 大小 单位GB
sizes = self._size.split('-') if self._size else []
minsize = float(sizes[0]) * 1024 * 1024 * 1024 if sizes else 0
maxsize = float(sizes[-1]) * 1024 * 1024 * 1024 if sizes else 0
# 分享率
if self._ratio and torrent.ratio <= float(self._ratio):
return None
if self._time and torrent_seeding_time <= float(self._time) * 3600:
return None
if self._size and (torrent.total_size >= int(maxsize) or torrent.total_size <= int(minsize)):
return None
if self._upspeed and torrent_upload_avs >= float(self._upspeed) * 1024:
return None
if self._pathkeywords and not re.findall(self._pathkeywords, torrent.download_dir, re.I):
return None
if self._trackerkeywords:
if not torrent.trackers:
return None
else:
tacker_key_flag = False
for tracker in torrent.trackers:
if re.findall(self._trackerkeywords, tracker.get("announce", ""), re.I):
tacker_key_flag = True
break
if not tacker_key_flag:
return None
if self._errorkeywords and not re.findall(self._errorkeywords, torrent.error_string, re.I):
return None
return {
"id": torrent.hashString,
"name": torrent.name,
"site": torrent.trackers[0].get("sitename") if torrent.trackers else "",
"size": torrent.total_size
}
def get_remove_torrents(self, downloader: str):
"""
获取自动删种任务种子
"""
remove_torrents = []
# 下载器对象
downloader_obj = self.__get_downloader(downloader)
# 标题
if self._labels:
tags = self._labels.split(',')
else:
tags = []
if self._mponly:
tags.append(settings.TORRENT_TAG)
# 查询种子
torrents, error_flag = downloader_obj.get_torrents(tags=tags or None)
if error_flag:
return []
# 处理种子
for torrent in torrents:
if downloader == "qbittorrent":
item = self.__get_qb_torrent(torrent)
else:
item = self.__get_tr_torrent(torrent)
if not item:
continue
remove_torrents.append(item)
# 处理辅种
if self._samedata and remove_torrents:
remove_ids = [t.get("id") for t in remove_torrents]
remove_torrents_plus = []
for remove_torrent in remove_torrents:
name = remove_torrent.get("name")
size = remove_torrent.get("size")
for torrent in torrents:
if downloader == "qbittorrent":
plus_id = torrent.hash
plus_name = torrent.name
plus_size = torrent.size
plus_site = StringUtils.get_url_sld(torrent.tracker)
else:
plus_id = torrent.hashString
plus_name = torrent.name
plus_size = torrent.total_size
plus_site = torrent.trackers[0].get("sitename") if torrent.trackers else ""
# 比对名称和大小
if plus_name == name \
and plus_size == size \
and plus_id not in remove_ids:
remove_torrents_plus.append(
{
"id": plus_id,
"name": plus_name,
"site": plus_site,
"size": plus_size
}
)
if remove_torrents_plus:
remove_torrents.extend(remove_torrents_plus)
return remove_torrents

View File

@@ -0,0 +1,932 @@
import os
from datetime import datetime, timedelta
from pathlib import Path
from threading import Event
from typing import Any, List, Dict, Tuple, Optional
import pytz
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from bencode import bdecode, bencode
from app.core.config import settings
from app.helper.torrent import TorrentHelper
from app.log import logger
from app.modules.qbittorrent import Qbittorrent
from app.modules.transmission import Transmission
from app.plugins import _PluginBase
from app.schemas import NotificationType
from app.utils.string import StringUtils
class TorrentTransfer(_PluginBase):
# 插件名称
plugin_name = "自动转移做种"
# 插件描述
plugin_desc = "定期转移下载器中的做种任务到另一个下载器。"
# 插件图标
plugin_icon = "seed.png"
# 插件版本
plugin_version = "1.5"
# 插件作者
plugin_author = "jxxghp"
# 作者主页
author_url = "https://github.com/jxxghp"
# 插件配置项ID前缀
plugin_config_prefix = "torrenttransfer_"
# 加载顺序
plugin_order = 18
# 可使用的用户级别
auth_level = 2
# 私有属性
_scheduler = None
qb = None
tr = None
torrent = None
# 开关
_enabled = False
_cron = None
_onlyonce = False
_fromdownloader = None
_todownloader = None
_frompath = None
_topath = None
_notify = False
_nolabels = None
_includelabels = None
_nopaths = None
_deletesource = False
_deleteduplicate = False
_fromtorrentpath = None
_autostart = False
_transferemptylabel = False
# 退出事件
_event = Event()
# 待检查种子清单
_recheck_torrents = {}
_is_recheck_running = False
# 任务标签
_torrent_tags = ["已整理", "转移做种"]
def init_plugin(self, config: dict = None):
self.torrent = TorrentHelper()
# 读取配置
if config:
self._enabled = config.get("enabled")
self._onlyonce = config.get("onlyonce")
self._cron = config.get("cron")
self._notify = config.get("notify")
self._nolabels = config.get("nolabels")
self._includelabels = config.get("includelabels")
self._frompath = config.get("frompath")
self._topath = config.get("topath")
self._fromdownloader = config.get("fromdownloader")
self._todownloader = config.get("todownloader")
self._deletesource = config.get("deletesource")
self._deleteduplicate = config.get("deleteduplicate")
self._fromtorrentpath = config.get("fromtorrentpath")
self._nopaths = config.get("nopaths")
self._autostart = config.get("autostart")
self._transferemptylabel = config.get("transferemptylabel")
# 停止现有任务
self.stop_service()
# 启动定时任务 & 立即运行一次
if self.get_state() or self._onlyonce:
self.qb = Qbittorrent()
self.tr = Transmission()
# 检查配置
if self._fromtorrentpath and not Path(self._fromtorrentpath).exists():
logger.error(f"源下载器种子文件保存路径不存在:{self._fromtorrentpath}")
self.systemmessage.put(f"源下载器种子文件保存路径不存在:{self._fromtorrentpath}", title="自动转移做种")
return
if self._fromdownloader == self._todownloader:
logger.error(f"源下载器和目的下载器不能相同")
self.systemmessage.put(f"源下载器和目的下载器不能相同", title="自动转移做种")
return
# 定时服务
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
if self._autostart:
# 追加种子校验服务
self._scheduler.add_job(self.check_recheck, 'interval', minutes=3)
if self._onlyonce:
logger.info(f"转移做种服务启动,立即运行一次")
self._scheduler.add_job(self.transfer, 'date',
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(
seconds=3))
# 关闭一次性开关
self._onlyonce = False
self.update_config({
"enabled": self._enabled,
"onlyonce": self._onlyonce,
"cron": self._cron,
"notify": self._notify,
"nolabels": self._nolabels,
"includelabels": self._includelabels,
"frompath": self._frompath,
"topath": self._topath,
"fromdownloader": self._fromdownloader,
"todownloader": self._todownloader,
"deletesource": self._deletesource,
"deleteduplicate": self._deleteduplicate,
"fromtorrentpath": self._fromtorrentpath,
"nopaths": self._nopaths,
"autostart": self._autostart,
"transferemptylabel": self._transferemptylabel
})
# 启动服务
if self._scheduler.get_jobs():
self._scheduler.print_jobs()
self._scheduler.start()
def get_state(self):
return True if self._enabled \
and self._cron \
and self._fromdownloader \
and self._todownloader \
and self._fromtorrentpath else False
@staticmethod
def get_command() -> List[Dict[str, Any]]:
pass
def get_api(self) -> List[Dict[str, Any]]:
pass
def get_service(self) -> List[Dict[str, Any]]:
"""
注册插件公共服务
[{
"id": "服务ID",
"name": "服务名称",
"trigger": "触发器cron/interval/date/CronTrigger.from_crontab()",
"func": self.xxx,
"kwargs": {} # 定时器参数
}]
"""
if self.get_state():
return [
{
"id": "TorrentTransfer",
"name": "转移做种服务",
"trigger": CronTrigger.from_crontab(self._cron),
"func": self.transfer,
"kwargs": {}
}
]
return []
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
"""
拼装插件配置页面需要返回两块数据1、页面配置2、数据结构
"""
return [
{
'component': 'VForm',
'content': [
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'enabled',
'label': '启用插件',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'notify',
'label': '发送通知',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'transferemptylabel',
'label': '转移无标签种子',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'cron',
'label': '执行周期',
'placeholder': '0 0 0 ? *'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'nolabels',
'label': '不转移种子标签',
}
}
]
}, {
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'includelabels',
'label': '转移种子标签',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12
},
'content': [
{
'component': 'VSelect',
'props': {
'model': 'fromdownloader',
'label': '源下载器',
'items': [
{'title': 'Qbittorrent', 'value': 'qbittorrent'},
{'title': 'Transmission', 'value': 'transmission'}
]
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'fromtorrentpath',
'label': '源下载器种子文件路径',
'placeholder': 'BT_backup、torrents'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'frompath',
'label': '源数据文件根路径',
'placeholder': '根路径,留空不进行路径转换'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12
},
'content': [
{
'component': 'VSelect',
'props': {
'model': 'todownloader',
'label': '目的下载器',
'items': [
{'title': 'Qbittorrent', 'value': 'qbittorrent'},
{'title': 'Transmission', 'value': 'transmission'}
]
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'topath',
'label': '目的数据文件根路径',
'placeholder': '根路径,留空不进行路径转换'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12
},
'content': [
{
'component': 'VTextarea',
'props': {
'model': 'nopaths',
'label': '不转移数据文件目录',
'rows': 3,
'placeholder': '每一行一个目录'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 3
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'autostart',
'label': '校验完成后自动开始',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 3
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'deletesource',
'label': '删除源种子',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 3
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'deleteduplicate',
'label': '删除重复种子',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 3
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'onlyonce',
'label': '立即运行一次',
}
}
]
}
]
}
]
}
], {
"enabled": False,
"notify": False,
"onlyonce": False,
"cron": "",
"nolabels": "",
"includelabels": "",
"frompath": "",
"topath": "",
"fromdownloader": "",
"todownloader": "",
"deletesource": False,
"deleteduplicate": False,
"fromtorrentpath": "",
"nopaths": "",
"autostart": True,
"transferemptylabel": False
}
def get_page(self) -> List[dict]:
pass
def __get_downloader(self, dtype: str):
"""
根据类型返回下载器实例
"""
if dtype == "qbittorrent":
return self.qb
elif dtype == "transmission":
return self.tr
else:
return None
def __download(self, downloader: str, content: bytes,
save_path: str) -> Optional[str]:
"""
添加下载任务
"""
if downloader == "qbittorrent":
# 生成随机Tag
tag = StringUtils.generate_random_str(10)
state = self.qb.add_torrent(content=content,
download_dir=save_path,
is_paused=True,
tag=["已整理", "转移做种", tag])
if not state:
return None
else:
# 获取种子Hash
torrent_hash = self.qb.get_torrent_id_by_tag(tags=tag)
if not torrent_hash:
logger.error(f"{downloader} 下载任务添加成功,但获取任务信息失败!")
return None
return torrent_hash
elif downloader == "transmission":
# 添加任务
torrent = self.tr.add_torrent(content=content,
download_dir=save_path,
is_paused=True,
labels=["已整理", "转移做种"])
if not torrent:
return None
else:
return torrent.hashString
logger.error(f"不支持的下载器:{downloader}")
return None
def transfer(self):
"""
开始转移做种
"""
logger.info("开始转移做种任务 ...")
# 源下载器
downloader = self._fromdownloader
# 目的下载器
todownloader = self._todownloader
# 获取下载器中已完成的种子
downloader_obj = self.__get_downloader(downloader)
torrents = downloader_obj.get_completed_torrents()
if torrents:
logger.info(f"下载器 {downloader} 已完成种子数:{len(torrents)}")
else:
logger.info(f"下载器 {downloader} 没有已完成种子")
return
# 过滤种子,记录保存目录
trans_torrents = []
for torrent in torrents:
if self._event.is_set():
logger.info(f"转移服务停止")
return
# 获取种子hash
hash_str = self.__get_hash(torrent, downloader)
# 获取保存路径
save_path = self.__get_save_path(torrent, downloader)
if self._nopaths and save_path:
# 过滤不需要转移的路径
nopath_skip = False
for nopath in self._nopaths.split('\n'):
if os.path.normpath(save_path).startswith(os.path.normpath(nopath)):
logger.info(f"种子 {hash_str} 保存路径 {save_path} 不需要转移,跳过 ...")
nopath_skip = True
break
if nopath_skip:
continue
# 获取种子标签
torrent_labels = self.__get_label(torrent, downloader)
# 种子为无标签,则进行规范化
is_torrent_labels_empty = torrent_labels == [''] or torrent_labels == [] or torrent_labels is None
if is_torrent_labels_empty:
torrent_labels = []
#根据设置决定是否转移无标签的种子
if is_torrent_labels_empty:
if not self._transferemptylabel:
continue
else:
# 排除含有不转移的标签
if self._nolabels:
is_skip = False
for label in self._nolabels.split(','):
if label in torrent_labels:
logger.info(f"种子 {hash_str} 含有不转移标签 {label},跳过 ...")
is_skip = True
break
if is_skip:
continue
# 排除不含有转移标签的种子
if self._includelabels:
is_skip = False
for label in self._includelabels.split(','):
if label not in torrent_labels:
logger.info(f"种子 {hash_str} 不含有转移标签 {label},跳过 ...")
is_skip = True
break
if is_skip:
continue
# 添加转移数据
trans_torrents.append({
"hash": hash_str,
"save_path": save_path,
"torrent": torrent
})
# 开始转移任务
if trans_torrents:
logger.info(f"需要转移的种子数:{len(trans_torrents)}")
# 记数
total = len(trans_torrents)
# 总成功数
success = 0
# 总失败数
fail = 0
# 跳过数
skip = 0
# 删除重复数
del_dup = 0
for torrent_item in trans_torrents:
# 检查种子文件是否存在
torrent_file = Path(self._fromtorrentpath) / f"{torrent_item.get('hash')}.torrent"
if not torrent_file.exists():
logger.error(f"种子文件不存在:{torrent_file}")
# 失败计数
fail += 1
continue
# 查询hash值是否已经在目的下载器中
todownloader_obj = self.__get_downloader(todownloader)
torrent_info, _ = todownloader_obj.get_torrents(ids=[torrent_item.get('hash')])
if torrent_info:
# 删除重复的源种子,不能删除文件!
if self._deleteduplicate:
logger.info(f"删除重复的源下载器任务(不含文件):{torrent_item.get('hash')} ...")
downloader_obj.delete_torrents(delete_file=False, ids=[torrent_item.get('hash')])
del_dup += 1
else:
logger.info(f"{torrent_item.get('hash')} 已在目的下载器中,跳过 ...")
# 跳过计数
skip += 1
continue
# 转换保存路径
download_dir = self.__convert_save_path(torrent_item.get('save_path'),
self._frompath,
self._topath)
if not download_dir:
logger.error(f"转换保存路径失败:{torrent_item.get('save_path')}")
# 失败计数
fail += 1
continue
# 如果源下载器是QB检查是否有Tracker没有的话额外获取
if downloader == "qbittorrent":
# 读取种子内容、解析种子文件
content = torrent_file.read_bytes()
if not content:
logger.warn(f"读取种子文件失败:{torrent_file}")
fail += 1
continue
# 读取trackers
try:
torrent_main = bdecode(content)
main_announce = torrent_main.get('announce')
except Exception as err:
logger.warn(f"解析种子文件 {torrent_file} 失败:{str(err)}")
fail += 1
continue
if not main_announce:
logger.info(f"{torrent_item.get('hash')} 未发现tracker信息尝试补充tracker信息...")
# 读取fastresume文件
fastresume_file = Path(self._fromtorrentpath) / f"{torrent_item.get('hash')}.fastresume"
if not fastresume_file.exists():
logger.warn(f"fastresume文件不存在{fastresume_file}")
fail += 1
continue
# 尝试补充trackers
try:
# 解析fastresume文件
fastresume = fastresume_file.read_bytes()
torrent_fastresume = bdecode(fastresume)
# 读取trackers
fastresume_trackers = torrent_fastresume.get('trackers')
if isinstance(fastresume_trackers, list) \
and len(fastresume_trackers) > 0 \
and fastresume_trackers[0]:
# 重新赋值
torrent_main['announce'] = fastresume_trackers[0][0]
# 保留其他tracker避免单一tracker无法连接
if len(fastresume_trackers) > 1 or len(fastresume_trackers[0]) > 1:
torrent_main['announce-list'] = fastresume_trackers
# 替换种子文件路径
torrent_file = settings.TEMP_PATH / f"{torrent_item.get('hash')}.torrent"
# 编码并保存到临时文件
torrent_file.write_bytes(bencode(torrent_main))
except Exception as err:
logger.error(f"解析fastresume文件 {fastresume_file} 出错:{str(err)}")
fail += 1
continue
# 发送到另一个下载器中下载:默认暂停、传输下载路径、关闭自动管理模式
logger.info(f"添加转移做种任务到下载器 {todownloader}{torrent_file}")
download_id = self.__download(downloader=todownloader,
content=torrent_file.read_bytes(),
save_path=download_dir)
if not download_id:
# 下载失败
fail += 1
logger.error(f"添加下载任务失败:{torrent_file}")
continue
else:
# 下载成功
logger.info(f"成功添加转移做种任务,种子文件:{torrent_file}")
# TR会自动校验QB需要手动校验
if todownloader == "qbittorrent":
logger.info(f"qbittorrent 开始校验 {download_id} ...")
todownloader_obj.recheck_torrents(ids=[download_id])
# 追加校验任务
logger.info(f"添加校验检查任务:{download_id} ...")
if not self._recheck_torrents.get(todownloader):
self._recheck_torrents[todownloader] = []
self._recheck_torrents[todownloader].append(download_id)
# 删除源种子,不能删除文件!
if self._deletesource:
logger.info(f"删除源下载器任务(不含文件):{torrent_item.get('hash')} ...")
downloader_obj.delete_torrents(delete_file=False, ids=[torrent_item.get('hash')])
# 成功计数
success += 1
# 插入转种记录
history_key = "%s-%s" % (self._fromdownloader, torrent_item.get('hash'))
self.save_data(key=history_key,
value={
"to_download": self._todownloader,
"to_download_id": download_id,
"delete_source": self._deletesource,
"delete_duplicate": self._deleteduplicate,
})
# 触发校验任务
if success > 0 and self._autostart:
self.check_recheck()
# 发送通知
if self._notify:
self.post_message(
mtype=NotificationType.SiteMessage,
title="【转移做种任务执行完成】",
text=f"总数:{total},成功:{success},失败:{fail},跳过:{skip},删除重复:{del_dup}"
)
else:
logger.info(f"没有需要转移的种子")
logger.info("转移做种任务执行完成")
def check_recheck(self):
"""
定时检查下载器中种子是否校验完成,校验完成且完整的自动开始辅种
"""
if not self._recheck_torrents:
return
if not self._todownloader:
return
if self._is_recheck_running:
return
# 校验下载器
downloader = self._todownloader
# 需要检查的种子
recheck_torrents = self._recheck_torrents.get(downloader, [])
if not recheck_torrents:
return
logger.info(f"开始检查下载器 {downloader} 的校验任务 ...")
# 运行状态
self._is_recheck_running = True
# 获取任务
downloader_obj = self.__get_downloader(downloader)
torrents, _ = downloader_obj.get_torrents(ids=recheck_torrents)
if torrents:
# 可做种的种子
can_seeding_torrents = []
for torrent in torrents:
# 获取种子hash
hash_str = self.__get_hash(torrent, downloader)
# 判断是否可做种
if self.__can_seeding(torrent, downloader):
can_seeding_torrents.append(hash_str)
if can_seeding_torrents:
logger.info(f"{len(can_seeding_torrents)} 个任务校验完成,开始做种")
# 开始做种
downloader_obj.start_torrents(ids=can_seeding_torrents)
# 去除已经处理过的种子
self._recheck_torrents[downloader] = list(
set(recheck_torrents).difference(set(can_seeding_torrents)))
else:
logger.info(f"没有新的任务校验完成,将在下次个周期继续检查 ...")
elif torrents is None:
logger.info(f"下载器 {downloader} 查询校验任务失败,将在下次继续查询 ...")
else:
logger.info(f"下载器 {downloader} 中没有需要检查的校验任务,清空待处理列表")
self._recheck_torrents[downloader] = []
self._is_recheck_running = False
@staticmethod
def __get_hash(torrent: Any, dl_type: str):
"""
获取种子hash
"""
try:
return torrent.get("hash") if dl_type == "qbittorrent" else torrent.hashString
except Exception as e:
print(str(e))
return ""
@staticmethod
def __get_label(torrent: Any, dl_type: str):
"""
获取种子标签
"""
try:
return [str(tag).strip() for tag in torrent.get("tags").split(',')] \
if dl_type == "qbittorrent" else torrent.labels or []
except Exception as e:
print(str(e))
return []
@staticmethod
def __get_save_path(torrent: Any, dl_type: str):
"""
获取种子保存路径
"""
try:
return torrent.get("save_path") if dl_type == "qbittorrent" else torrent.download_dir
except Exception as e:
print(str(e))
return ""
@staticmethod
def __can_seeding(torrent: Any, dl_type: str):
"""
判断种子是否可以做种并处于暂停状态
"""
try:
return (torrent.get("state") == "pausedUP") if dl_type == "qbittorrent" \
else (torrent.status.stopped and torrent.percent_done == 1)
except Exception as e:
print(str(e))
return False
@staticmethod
def __convert_save_path(save_path: str, from_root: str, to_root: str):
"""
转换保存路径
"""
try:
# 没有保存目录,以目的根目录为准
if not save_path:
return to_root
# 没有设置根目录时返回save_path
if not to_root or not from_root:
return save_path
# 统一目录格式
save_path = os.path.normpath(save_path).replace("\\", "/")
from_root = os.path.normpath(from_root).replace("\\", "/")
to_root = os.path.normpath(to_root).replace("\\", "/")
# 替换根目录
if save_path.startswith(from_root):
return save_path.replace(from_root, to_root, 1)
except Exception as e:
print(str(e))
return None
def stop_service(self):
"""
退出插件
"""
try:
if self._scheduler:
self._scheduler.remove_all_jobs()
if self._scheduler.running:
self._event.set()
self._scheduler.shutdown()
self._event.clear()
self._scheduler = None
except Exception as e:
print(str(e))

View File

@@ -0,0 +1,454 @@
from typing import List, Tuple, Dict, Any, Union, Optional
from apscheduler.triggers.cron import CronTrigger
from app.log import logger
from app.modules.qbittorrent import Qbittorrent
from qbittorrentapi.torrents import TorrentInfoList
from app.modules.transmission import Transmission
from transmission_rpc.torrent import Torrent
from app.plugins import _PluginBase
from app.schemas import NotificationType
class TrackerEditor(_PluginBase):
# 插件名称
plugin_name = "Tracker替换"
# 插件描述
plugin_desc = "批量替换种子tracker支持周期性巡检如为TR仅支持4.0以上版本)"
# 插件图标
plugin_icon = "trackereditor_A.png"
# 插件版本
plugin_version = "1.5"
# 插件作者
plugin_author = "honue"
# 作者主页
author_url = "https://github.com/honue"
# 插件配置项ID前缀
plugin_config_prefix = "trackereditor_"
# 加载顺序
plugin_order = 30
# 可使用的用户级别
auth_level = 1
_downloader_type: str = None
_username: str = None
_password: str = None
_host: str = None
_port: int = None
_target_domain: str = None
_replace_domain: str = None
_onlyonce: bool = False
_downloader: Union[Qbittorrent, Transmission] = None
_run_con_enable: bool = False
_run_con: Optional[str] = None
_notify: bool = False
def init_plugin(self, config: dict = None):
if config:
self._onlyonce = config.get("onlyonce")
self._downloader_type = config.get("downloader_type")
self._host = config.get("host")
self._port = config.get("port")
self._username = config.get("username")
self._password = config.get("password")
self._target_domain = config.get("target_domain")
self._replace_domain = config.get("replace_domain")
self._run_con_enable = config.get("run_con_enable")
self._run_con = config.get("run_con")
self._notify = config.get("notify")
if self._onlyonce:
# 执行替换
self.task()
self._onlyonce = False
# 更新onlyonce属性
self.__update_config()
def task(self):
logger.info(f"{'*' * 30}TrackerEditor: 开始执行Tracker替换{'*' * 30}")
torrent_total_cnt: int = 0
torrent_update_cnt: int = 0
if self._downloader_type == "qbittorrent":
self._downloader = Qbittorrent(self._host, self._port, self._username, self._password)
torrent_info_list: TorrentInfoList
torrent_info_list, error = self._downloader.get_torrents()
torrent_total_cnt = len(torrent_info_list)
if error:
return
for torrent in torrent_info_list:
for tracker in torrent.trackers:
if self._target_domain in tracker.url:
original_url = tracker.url
new_url = tracker.url.replace(self._target_domain, self._replace_domain)
logger.info(f"{original_url} 替换为\n {new_url}")
torrent.edit_tracker(orig_url=original_url, new_url=new_url)
torrent_update_cnt += 1
elif self._downloader_type == "transmission":
self._downloader = Transmission(self._host, self._port, self._username, self._password)
tr_version = self._downloader.get_session().get('version')
# "4.0.3 (6b0e49bbb2)" "3.00 (bb6b5a062e)"
torrent_list: List[Torrent]
torrent_list, error = self._downloader.get_torrents()
torrent_total_cnt = len(torrent_list)
if error:
return
for torrent in torrent_list:
new_tracker_list = []
for tracker in torrent.tracker_list:
if self._target_domain in tracker:
new_url = tracker.replace(self._target_domain, self._replace_domain)
new_tracker_list.append(new_url)
logger.info(f"{tracker} 替换为\n {new_url}")
torrent_update_cnt += 1
else:
new_tracker_list.append(tracker)
if int(tr_version[0]) >= 4:
# 版本大于等于4.x
__tracker_list = [new_tracker_list]
else:
__tracker_list = new_tracker_list
if torrent_update_cnt > 0:
update_result = self._downloader.update_tracker(hash_string=torrent.hashString, tracker_list=__tracker_list)
if not update_result:
logger.error(f"执行tracker修改出错中止本次执行")
torrent_update_cnt = 0
break
if torrent_update_cnt == 0:
logger.info(f"tracker修改条数为0")
logger.info(f"{'*' * 30}TrackerEditor: Tracker替换完成{'*' * 30}")
if (self._run_con_enable and self._notify) or (self._onlyonce and self._notify):
title = '【Tracker替换】'
msg = f'''扫描下载器{self._downloader_type}\n总的种子数: {torrent_total_cnt}\n已修改种子数: {torrent_update_cnt}'''
self.send_site_message(title, msg)
def __update_config(self):
self.update_config({
"onlyonce": self._onlyonce,
"downloader_type": self._downloader_type,
"username": self._username,
"password": self._password,
"host": self._host,
"port": self._port,
"target_domain": self._target_domain,
"replace_domain": self._replace_domain,
"run_cron_enable": self._run_con_enable,
"run_cron": self._run_con,
"notify": self._notify
})
@staticmethod
def get_command() -> List[Dict[str, Any]]:
pass
def get_api(self) -> List[Dict[str, Any]]:
pass
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
return [
{
'component': 'VForm',
'content': [
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'run_con_enable',
'label': '启用周期性巡检 (注: 请开启时务必填写cron表达式)',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'onlyonce',
'label': '立即运行一次',
}
}
]
}]
}, {
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'notify',
'label': '发送通知',
}
}
]
}]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'run_con',
'label': 'cron表达式',
'placeholder': '* * * * *'
}
}
]
}, {
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VSelect',
'props': {
'model': 'downloader_type',
'label': '下载器类型',
'items': [
{'title': 'Qbittorrent', 'value': 'qbittorrent'},
{'title': 'Transmission', 'value': 'transmission'}
]
}
}
]
}]
}, {
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'host',
'label': 'host主机ip',
'placeholder': '192.168.2.100'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'port',
'label': 'qb/tr端口',
'placeholder': '8989'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'username',
'label': '用户名',
'placeholder': 'username'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'password',
'label': '密码',
'placeholder': 'password'
}
}
]
}
]
}, {
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'target_domain',
'label': '待替换文本',
'placeholder': 'target.com'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'replace_domain',
'label': '替换的文本',
'placeholder': 'replace.net'
}
}
]
}
]
}, {
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VAlert',
'props': {
'type': 'info',
'variant': 'tonal',
'text': '对下载器中所有符合代替换文本的tacker进行字符串replace替换' + '\n' +
'现有tracker: https://baidu.com/announce.php?passkey=xxxx' + '\n' +
'待替换 baidu.com 或 https://baidu.com' + '\n' +
'用于替换的文本 qq.com 或 https://qq.com' + '\n' +
'结果为 https://qq.com/announce.php?passkey=xxxx',
'style': 'white-space: pre-line;'
}
},
{
'component': 'VAlert',
'props': {
'type': 'info',
'variant': 'tonal',
'text': '强烈建议自己先添加一个tracker测试替换是否符合预期程序是否正常运行',
'style': 'white-space: pre-line;'
}
},
{
'component': 'VAlert',
'props': {
'type': 'info',
'variant': 'tonal',
'text': '周期性巡检时指的是允许设置间隔一段进行巡检下载器中的种子Tracker' + '\n'
'当匹配到等待替换的tracker时进行替换其中cron表达式是5位例如:* * * * * 指的是每过一分钟轮训一次',
'style': 'white-space: pre-line;'
}
}
]
}
]
}
]
}
], {
"onlyonce": False,
"downloader_type": "qbittorrent",
"host": "192.168.2.100",
"port": 8989,
"username": "username",
"password": "password",
"target_domain": "",
"replace_domain": "",
"run_con_enable": False,
"run_con": "",
"notify": True
}
def get_page(self) -> List[dict]:
pass
def get_state(self) -> bool:
return True
def stop_service(self):
pass
def get_service(self) -> List[Dict[str, Any]]:
if self._run_con_enable and self._run_con:
logger.info(f"{'*' * 30}TrackerEditor: 注册公共调度服务{'*' * 30}")
return [
{
"id": "TrackerChangeRun",
"name": "启用周期性Tracker替换",
"trigger": CronTrigger.from_crontab(self._run_con),
"func": self.task,
"kwargs": {}
}]
return []
def send_site_message(self, title, message):
self.post_message(
mtype=NotificationType.SiteMessage,
title=title,
text=message
)

View File

@@ -0,0 +1,732 @@
from typing import List, Tuple, Dict, Any
from app.log import logger
from app.modules.transmission import Transmission
from app.plugins import _PluginBase
from app.schemas import NotificationType
from app.schemas.types import EventType
from apscheduler.triggers.cron import CronTrigger
from app.core.event import eventmanager, Event
import time
class TrCommand(_PluginBase):
# 插件名称
plugin_name = "TR远程操作"
# 插件描述
plugin_desc = "通过定时任务或交互命令远程操作TR暂停/开始/限速等。"
# 插件图标
plugin_icon = "Transmission_A.png"
# 插件版本
plugin_version = "1.1"
# 插件作者
plugin_author = "Hoey"
# 作者主页
author_url = "https://github.com/hoey94"
# 插件配置项ID前缀
plugin_config_prefix = "trcommand_"
# 加载顺序
plugin_order = 1
# 可使用的用户级别
auth_level = 1
# 私有属性
_tr = None
_enabled: bool = False
_notify: bool = False
_pause_cron = None
_resume_cron = None
_only_pause_once = False
_only_resume_once = False
_upload_limit = 0
_enable_upload_limit = False
_download_limit = 0
_enable_download_limit = False
def init_plugin(self, config: dict = None):
# 停止现有任务
self.stop_service()
# 读取配置
if config:
self._enabled = config.get("enabled")
self._notify = config.get("notify")
self._pause_cron = config.get("pause_cron")
self._resume_cron = config.get("resume_cron")
self._only_pause_once = config.get("onlypauseonce")
self._only_resume_once = config.get("onlyresumeonce")
self._download_limit = config.get("download_limit")
self._upload_limit = config.get("upload_limit")
self._enable_download_limit = config.get("enable_download_limit")
self._enable_upload_limit = config.get("enable_upload_limit")
self._tr = Transmission()
if self._only_pause_once or self._only_resume_once:
if self._only_pause_once and self._only_resume_once:
logger.warning("只能选择一个: 立即暂停或立即开始所有任务")
elif self._only_pause_once:
self.pause_torrent()
elif self._only_resume_once:
self.resume_torrent()
self._only_resume_once = False
self._only_pause_once = False
self.update_config(
{
"onlypauseonce": False,
"onlyresumeonce": False,
"enabled": self._enabled,
"notify": self._notify,
"pause_cron": self._pause_cron,
"resume_cron": self._resume_cron,
}
)
# 限速
self.set_limit(self._upload_limit, self._download_limit)
def get_state(self) -> bool:
return self._enabled
@staticmethod
def get_command() -> List[Dict[str, Any]]:
"""
定义远程控制命令
:return: 命令关键字、事件、描述、附带数据
"""
return [
{
"cmd": "/pause_torrents",
"event": EventType.PluginAction,
"desc": "暂停TR种子",
"category": "TR",
"data": {"action": "pause_torrents"},
},
{
"cmd": "/resume_torrents",
"event": EventType.PluginAction,
"desc": "开始TR种子",
"category": "TR",
"data": {"action": "resume_torrents"},
},
{
"cmd": "/toggle_upload_limit",
"event": EventType.PluginAction,
"desc": "TR切换上传限速状态",
"category": "TR",
"data": {"action": "toggle_upload_limit"},
},
{
"cmd": "/toggle_download_limit",
"event": EventType.PluginAction,
"desc": "TR切换下载限速状态",
"category": "TR",
"data": {"action": "toggle_download_limit"},
},
]
def get_api(self) -> List[Dict[str, Any]]:
pass
def get_service(self) -> List[Dict[str, Any]]:
"""
注册插件公共服务
[{
"id": "服务ID",
"name": "服务名称",
"trigger": "触发器cron/interval/date/CronTrigger.from_crontab()",
"func": self.xxx,
"kwargs": {} # 定时器参数
}]
"""
if self._enabled and self._pause_cron and self._resume_cron:
return [
{
"id": "TrPause",
"name": "暂停TR所有任务",
"trigger": CronTrigger.from_crontab(self._pause_cron),
"func": self.pause_torrent,
"kwargs": {},
},
{
"id": "TrResume",
"name": "开始TR所有任务",
"trigger": CronTrigger.from_crontab(self._resume_cron),
"func": self.resume_torrent,
"kwargs": {},
},
]
if self._enabled and self._pause_cron:
return [
{
"id": "TrPause",
"name": "暂停TR所有任务",
"trigger": CronTrigger.from_crontab(self._pause_cron),
"func": self.pause_torrent,
"kwargs": {},
}
]
if self._enabled and self._resume_cron:
return [
{
"id": "TrResume",
"name": "开始TR所有任务",
"trigger": CronTrigger.from_crontab(self._resume_cron),
"func": self.resume_torrent,
"kwargs": {},
}
]
return []
def get_all_torrents(self):
all_torrents, error = self._tr.get_torrents()
if error:
logger.error(f"获取TR种子失败: {error}")
if self._notify:
self.post_message(
mtype=NotificationType.SiteMessage,
title=f"【TR远程操作】",
text=f"获取TR种子失败请检查TR配置",
)
return []
if not all_torrents:
logger.warning("TR没有种子")
if self._notify:
self.post_message(
mtype=NotificationType.SiteMessage,
title=f"【TR远程操作】",
text=f"TR中没有种子",
)
return []
return all_torrents
@staticmethod
def get_torrents_status(torrents):
downloading_torrents = []
uploading_torrents = []
paused_torrents = []
checking_torrents = []
error_torrents = []
for torrent in torrents:
match torrent.status.lower():
case 'stopped':
paused_torrents.append(torrent.id)
case 'check_pending':
checking_torrents.append(torrent.id)
case 'checking':
checking_torrents.append(torrent.id)
case 'download_pending':
downloading_torrents.append(torrent.id)
case 'downloading':
downloading_torrents.append(torrent.id)
case 'seed_pending':
uploading_torrents.append(torrent.id)
case 'seeding':
uploading_torrents.append(torrent.id)
return (
downloading_torrents,
uploading_torrents,
paused_torrents,
checking_torrents,
error_torrents,
)
@eventmanager.register(EventType.PluginAction)
def handle_pause_torrent(self, event: Event):
if not self._enabled:
return
if event:
event_data = event.event_data
if not event_data or event_data.get("action") != "pause_torrents":
return
self.pause_torrent()
def pause_torrent(self):
if not self._enabled:
return
all_torrents = self.get_all_torrents()
hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = (
self.get_torrents_status(all_torrents)
)
to_be_paused = hash_downloading + hash_uploading + hash_checking
logger.info(
f"暂定任务启动 \n"
f"种子总数: {len(all_torrents)} \n"
f"做种数量: {len(hash_uploading)}\n"
f"下载数量: {len(hash_downloading)}\n"
f"检查数量: {len(hash_checking)}\n"
f"暂停数量: {len(hash_paused)}\n"
f"错误数量: {len(hash_error)}\n"
f"暂停操作中请稍等...\n",
)
if self._notify:
self.post_message(
mtype=NotificationType.SiteMessage,
title=f"【TR暂停任务启动】",
text=f"种子总数: {len(all_torrents)} \n"
f"做种数量: {len(hash_uploading)}\n"
f"下载数量: {len(hash_downloading)}\n"
f"检查数量: {len(hash_checking)}\n"
f"暂停数量: {len(hash_paused)}\n"
f"错误数量: {len(hash_error)}\n"
f"暂停操作中请稍等...\n",
)
if len(to_be_paused) > 0:
if self._tr.stop_torrents(ids=to_be_paused):
logger.info(f"暂停了{len(to_be_paused)}个种子")
else:
logger.error(f"暂停种子失败")
if self._notify:
self.post_message(
mtype=NotificationType.SiteMessage,
title=f"【TR远程操作】",
text=f"暂停种子失败",
)
# 每个种子等待1ms以让状态切换成功,至少等待1S
wait_time = 0.001 * len(to_be_paused) + 1
time.sleep(wait_time)
all_torrents = self.get_all_torrents()
hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = (
self.get_torrents_status(all_torrents)
)
logger.info(
f"暂定任务完成 \n"
f"种子总数: {len(all_torrents)} \n"
f"做种数量: {len(hash_uploading)}\n"
f"下载数量: {len(hash_downloading)}\n"
f"检查数量: {len(hash_checking)}\n"
f"暂停数量: {len(hash_paused)}\n"
f"错误数量: {len(hash_error)}\n"
)
if self._notify:
self.post_message(
mtype=NotificationType.SiteMessage,
title=f"【TR暂停任务完成】",
text=f"种子总数: {len(all_torrents)} \n"
f"做种数量: {len(hash_uploading)}\n"
f"下载数量: {len(hash_downloading)}\n"
f"检查数量: {len(hash_checking)}\n"
f"暂停数量: {len(hash_paused)}\n"
f"错误数量: {len(hash_error)}\n",
)
@eventmanager.register(EventType.PluginAction)
def handle_resume_torrent(self, event: Event):
if not self._enabled:
return
if event:
event_data = event.event_data
if not event_data or event_data.get("action") != "resume_torrents":
return
self.resume_torrent()
def resume_torrent(self):
if not self._enabled:
return
all_torrents = self.get_all_torrents()
hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = (
self.get_torrents_status(all_torrents)
)
logger.info(
f"TR开始任务启动 \n"
f"种子总数: {len(all_torrents)} \n"
f"做种数量: {len(hash_uploading)}\n"
f"下载数量: {len(hash_downloading)}\n"
f"检查数量: {len(hash_checking)}\n"
f"暂停数量: {len(hash_paused)}\n"
f"错误数量: {len(hash_error)}\n"
f"开始操作中请稍等...\n",
)
if self._notify:
self.post_message(
mtype=NotificationType.SiteMessage,
title=f"【TR开始任务启动】",
text=f"种子总数: {len(all_torrents)} \n"
f"做种数量: {len(hash_uploading)}\n"
f"下载数量: {len(hash_downloading)}\n"
f"检查数量: {len(hash_checking)}\n"
f"暂停数量: {len(hash_paused)}\n"
f"错误数量: {len(hash_error)}\n"
f"开始操作中请稍等...\n",
)
if not self._tr.start_torrents(ids=hash_paused):
logger.error(f"开始种子失败")
if self._notify:
self.post_message(
mtype=NotificationType.SiteMessage,
title=f"【TR远程操作】",
text=f"开始种子失败",
)
# 每个种子等待1ms以让状态切换成功,至少等待1S
wait_time = 0.001 * len(hash_paused) + 1
time.sleep(wait_time)
all_torrents = self.get_all_torrents()
hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = (
self.get_torrents_status(all_torrents)
)
logger.info(
f"开始任务完成 \n"
f"种子总数: {len(all_torrents)} \n"
f"做种数量: {len(hash_uploading)}\n"
f"下载数量: {len(hash_downloading)}\n"
f"检查数量: {len(hash_checking)}\n"
f"暂停数量: {len(hash_paused)}\n"
f"错误数量: {len(hash_error)}\n"
)
if self._notify:
self.post_message(
mtype=NotificationType.SiteMessage,
title=f"【TR开始任务完成】",
text=f"种子总数: {len(all_torrents)} \n"
f"做种数量: {len(hash_uploading)}\n"
f"下载数量: {len(hash_downloading)}\n"
f"检查数量: {len(hash_checking)}\n"
f"暂停数量: {len(hash_paused)}\n"
f"错误数量: {len(hash_error)}\n",
)
@eventmanager.register(EventType.PluginAction)
def handle_toggle_upload_limit(self, event: Event):
if not self._enabled:
return
if event:
event_data = event.event_data
if not event_data or event_data.get("action") != "toggle_upload_limit":
return
self.set_limit(self._upload_limit, self._download_limit)
@eventmanager.register(EventType.PluginAction)
def handle_toggle_download_limit(self, event: Event):
if not self._enabled:
return
if event:
event_data = event.event_data
if not event_data or event_data.get("action") != "toggle_download_limit":
return
self.set_limit(self._upload_limit, self._download_limit)
def set_both_limit(self, upload_limit, download_limit):
if not self._enable_upload_limit or not self._enable_upload_limit:
return True
if not upload_limit or not upload_limit.isdigit() or not download_limit or not download_limit.isdigit():
self.post_message(
mtype=NotificationType.SiteMessage,
title=f"【TR远程操作】",
text=f"设置TR限速失败,download_limit或upload_limit不是一个数值",
)
return False
return self._tr.set_speed_limit(
download_limit=int(download_limit), upload_limit=int(upload_limit)
)
def set_upload_limit(self, upload_limit):
if not self._enable_upload_limit:
return True
if not upload_limit or not upload_limit.isdigit():
self.post_message(
mtype=NotificationType.SiteMessage,
title=f"【TR远程操作】",
text=f"设置TR限速失败,upload_limit不是一个数值",
)
return False
download_limit_current_val, _ = self._tr.get_speed_limit()
return self._tr.set_speed_limit(
download_limit=int(download_limit_current_val), upload_limit=int(upload_limit)
)
def set_download_limit(self, download_limit):
if not self._enable_download_limit:
return True
if not download_limit or not download_limit.isdigit():
self.post_message(
mtype=NotificationType.SiteMessage,
title=f"【TR远程操作】",
text=f"设置TR限速失败,download_limit不是一个数值",
)
return False
_, upload_limit_current_val = self._tr.get_speed_limit()
return self._tr.set_speed_limit(
download_limit=int(download_limit), upload_limit=int(upload_limit_current_val)
)
def set_limit(self, upload_limit, download_limit):
# 限速,满足以下三种情况设置限速
# 1. 插件启用 && download_limit启用
# 2. 插件启用 && upload_limit启用
# 3. 插件启用 && download_limit启用 && upload_limit启用
flag = None
if self._enabled and self._enable_download_limit and self._enable_upload_limit:
flag = self.set_both_limit(upload_limit, download_limit)
elif flag is None and self._enabled and self._enable_download_limit:
flag = self.set_download_limit(download_limit)
elif flag is None and self._enabled and self._enable_upload_limit:
flag = self.set_upload_limit(upload_limit)
if flag:
logger.info(f"设置TR限速成功")
if self._notify:
if upload_limit == 0:
text = f"上传无限速"
else:
text = f"上传限速:{upload_limit} KB/s"
if download_limit == 0:
text += f"\n下载无限速"
else:
text += f"\n下载限速:{download_limit} KB/s"
self.post_message(
mtype=NotificationType.SiteMessage,
title=f"【TR远程操作】",
text=text,
)
elif not flag:
logger.error(f"TR设置限速失败")
if self._notify:
self.post_message(
mtype=NotificationType.SiteMessage,
title=f"【TR远程操作】",
text=f"设置TR限速失败",
)
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
return [
{
"component": "VForm",
"content": [
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {"cols": 12, "md": 6},
"content": [
{
"component": "VSwitch",
"props": {
"model": "enabled",
"label": "启用插件",
},
}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 6},
"content": [
{
"component": "VSwitch",
"props": {
"model": "notify",
"label": "发送通知",
},
}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 6},
"content": [
{
"component": "VSwitch",
"props": {
"model": "onlypauseonce",
"label": "立即暂停所有任务",
},
}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 6},
"content": [
{
"component": "VSwitch",
"props": {
"model": "onlyresumeonce",
"label": "立即开始所有任务",
},
}
],
},
],
},
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {"cols": 12, "md": 6},
"content": [
{
"component": "VTextField",
"props": {
"model": "pause_cron",
"label": "暂停周期",
},
}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 6},
"content": [
{
"component": "VTextField",
"props": {
"model": "resume_cron",
"label": "开始周期",
},
}
],
},
],
},
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {"cols": 12, "md": 6},
"content": [
{
"component": "VSwitch",
"props": {
"model": "enable_upload_limit",
"label": "上传限速",
},
}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 6},
"content": [
{
"component": "VSwitch",
"props": {
"model": "enable_download_limit",
"label": "下载限速",
},
}
],
},
],
},
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {"cols": 12, "md": 6},
"content": [
{
"component": "VTextField",
"props": {
"model": "upload_limit",
"label": "上传限速 KB/s",
"placeholder": "KB/s",
},
}
],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 6},
"content": [
{
"component": "VTextField",
"props": {
"model": "download_limit",
"label": "下载限速 KB/s",
"placeholder": "KB/s",
},
}
],
},
],
},
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {
"cols": 12,
},
"content": [
{
"component": "VAlert",
"props": {
"type": "info",
"variant": "tonal",
"text": "开始周期和暂停周期使用Cron表达式0 0 0 * *",
},
}
],
},
{
"component": "VCol",
"props": {
"cols": 12,
},
"content": [
{
"component": "VAlert",
"props": {
"type": "info",
"variant": "tonal",
"text": "交互命令有暂停TR种子、开始TR种子、TR切换上传限速状态、TR切换下载限速状态",
},
}
],
},
{
"component": "VCol",
"props": {
"cols": 12,
},
"content": [
{
"component": "VAlert",
"props": {
"type": "info",
"variant": "tonal",
"text": "PT精神重在分享请勿恶意限速因此导致账号被封禁作者概不负责",
},
}
],
}
],
},
],
}
], {
"enabled": False,
"notify": True,
"onlypauseonce": False,
"onlyresumeonce": False,
"upload_limit": 0,
"download_limit": 0,
"enable_upload_limit": False,
"enable_download_limit": False,
}
def get_page(self) -> List[dict]:
pass
def stop_service(self):
pass

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,284 @@
import concurrent
import re
from dataclasses import dataclass
from pathlib import Path
from typing import List
from app.chain.media import MediaChain
from app.chain.tmdb import TmdbChain
from app.core.metainfo import MetaInfoPath
from app.log import logger
from app.schemas import MediaType
season_patterns = [
{"pattern": re.compile(r"S(\d+)$", re.IGNORECASE), "group": 1},
{"pattern": re.compile(r"(\d+)$", re.IGNORECASE), "group": 1},
{"pattern": re.compile(r"(\d+)(st|nd|rd|th)?\s*season", re.IGNORECASE), "group": 1},
{"pattern": re.compile(r"(.*) ?\s*season (\d+)", re.IGNORECASE), "group": 2},
{"pattern": re.compile(r"\s(II|III|IV|V|VI|VII|VIII|IX|X)$", re.IGNORECASE), "group": "1"}
]
episode_patterns = [
{"pattern": re.compile(r"(\d+)\((\d+)\)", re.IGNORECASE), "group": 2},
{"pattern": re.compile(r"(\d+)", re.IGNORECASE), "group": 1},
{"pattern": re.compile(r'(\d+)v\d+', re.IGNORECASE), "group": 1},
]
ova_patterns = [
re.compile(r".*?(OVA|OAD).*?", re.IGNORECASE),
re.compile(r"\d+\.5"),
re.compile(r"00")
]
final_season_patterns = [
re.compile('final season', re.IGNORECASE),
re.compile('The Final', re.IGNORECASE),
re.compile(r'\sFinal')
]
movie_patterns = [
re.compile("Movie", re.IGNORECASE),
re.compile("the Movie", re.IGNORECASE),
]
@dataclass
class VCBMetaBase:
# 转化为小写后的原始文件名称 (不含后缀)
original_title: str = ""
# 解析后不包含季度和集数的标题
title: str = ""
# 类型:TV / Movie (默认TV)
type: str = "TV"
# 可能含有季度的标题,一级解析后的标题
season_title: str = ""
# 可能含有集数的字符串列表
ep_title: List[str] = None
# 识别出来的季度
season: int = None
# 识别出来的集数
ep: int = None
# 是否是OVA/OAD
is_ova: bool = False
# TMDB ID
tmdb_id: int = None
blocked_words = ["vcb-studio", "360p", "480p", "720p", "1080p", "2160p", "hdr", "x265", "x264", "aac", "flac"]
class ReMeta:
def __init__(self, ova_switch: bool = False, custom_season_patterns: list[dict] = None):
self.meta = None
# TODO:自定义季度匹配规则
self.custom_season_patterns = custom_season_patterns
self.season_patterns = season_patterns
self.ova_switch = ova_switch
self.vcb_meta = VCBMetaBase()
self.is_ova = False
def is_tv(self, title: str) -> bool:
"""
判断是否是TV
"""
if title.count("[") != 4 and title.count("]") != 4:
self.vcb_meta.type = "Movie"
self.vcb_meta.title = re.sub(r'\[.*?\]', '', title).strip()
return False
return True
def handel_file(self, file_path: Path):
file_name = file_path.stem.strip().lower()
self.vcb_meta.original_title = file_name
if not self.is_tv(file_name):
logger.warn(
"不符合VCB-Studio的剧集命名规范归类为电影,跳过剧集模块处理。注意:年份较为久远的作品可能在此会判断错误")
self.parse_movie()
else:
self.tv_mode()
self.is_ova = self.vcb_meta.is_ova
meta = MetaInfoPath(file_path)
meta.title = self.vcb_meta.title
meta.en_name = self.vcb_meta.title
if self.vcb_meta.type == "Movie":
meta.type = MediaType.MOVIE
else:
meta.type = MediaType.TV
if self.vcb_meta.ep is not None:
meta.begin_episode = self.vcb_meta.ep
if self.vcb_meta.season is not None:
meta.begin_season = self.vcb_meta.season
if self.vcb_meta.tmdb_id is not None:
meta.tmdbid = self.vcb_meta.tmdb_id
return meta
def split_season_ep(self):
# 把所有的[] 里面的内容获取出来,不需要[]本身
self.vcb_meta.ep_title = re.findall(r'\[(.*?)\]', self.vcb_meta.original_title)
# 去除所有[]后只剩下剧名
self.vcb_meta.season_title = re.sub(r"\[.*?\]", "", self.vcb_meta.original_title).strip()
if self.vcb_meta.ep_title:
self.culling_blocked_words()
logger.info(
f"分离出包含可能季度的内容部分:{self.vcb_meta.season_title} | 可能包含集数的内容部分: {self.vcb_meta.ep_title}")
self.vcb_meta.title = self.vcb_meta.season_title
if not self.vcb_meta.ep_title:
self.vcb_meta.title = self.vcb_meta.season_title
logger.warn("未识别出可能存在集数位置的信息,跳过剩余识别步骤!")
def tv_mode(self):
logger.info("开始分离季度和集数部分")
self.split_season_ep()
if not self.vcb_meta.ep_title:
return
self.parse_season()
self.parse_episode()
def parse_season(self):
"""
从标题中解析季度
"""
flag = False
for pattern in season_patterns:
match = pattern["pattern"].search(self.vcb_meta.season_title)
if match:
if isinstance(pattern["group"], int):
self.vcb_meta.season = int(match.group(pattern["group"]))
else:
self.vcb_meta.season = self.roman_to_int(match.group(pattern["group"]))
# 匹配成功后,标题中去除季度信息
self.vcb_meta.title = pattern["pattern"].sub("", self.vcb_meta.season_title).strip
logger.info(f"识别出季度为{self.vcb_meta.season}")
return
logger.info(f"正常匹配季度失败开始匹配ova/oad/最终季度")
if not flag:
# 匹配是否为最终季
for pattern in final_season_patterns:
if pattern.search(self.vcb_meta.season_title):
logger.info("命中到最终季匹配规则")
self.vcb_meta.title = pattern.sub("", self.vcb_meta.season_title).strip()
self.handle_final_season()
return
logger.info("未识别出最终季度开始匹配OVA/OAD")
# 匹配是否为OVA/OAD
if "ova" in self.vcb_meta.season_title or "oad" in self.vcb_meta.season_title:
logger.info("季度部分命中到OVA/OAD匹配规则")
if self.ova_switch:
logger.info("开启OVA/OAD处理逻辑")
self.vcb_meta.is_ova = True
for pattern in ova_patterns:
if pattern.search(self.vcb_meta.season_title):
self.vcb_meta.title = pattern.sub("", self.vcb_meta.season_title).strip()
self.vcb_meta.title = re.sub("ova|oad", "", self.vcb_meta.season_title).strip()
self.vcb_meta.season = 0
return
logger.warn("未识别出季度,默认处理逻辑返回第一季")
self.vcb_meta.title = self.vcb_meta.season_title
self.vcb_meta.season = 1
def parse_episode(self):
"""
从标题中解析集数
"""
# 从ep_title中剔除不相关的内容之后只剩下存在集数的字符串
ep = self.vcb_meta.ep_title[0]
for pattern in episode_patterns:
match = pattern["pattern"].search(ep)
if match:
self.vcb_meta.ep = int(match.group(pattern["group"]))
logger.info(f"识别出集数为{self.vcb_meta.ep}")
return
# 直接进入判断是否为OVA/OAD
for pattern in ova_patterns:
if pattern.search(ep):
self.vcb_meta.is_ova = True
# 直接获取数字
self.vcb_meta.ep = int(re.search(r"\d+", ep).group()) or 1
logger.info(f"OVA模式下识别出集数为{self.vcb_meta.ep}")
self.vcb_meta.season = 0
return
def culling_blocked_words(self):
"""
从ep_title中剔除不相关的内容
"""
blocked_set = set(blocked_words) # 将阻止词列表转换为集合
result = [ep for ep in self.vcb_meta.ep_title if not any(word in ep for word in blocked_set)]
self.vcb_meta.ep_title = result
def handle_final_season(self):
_, medias = MediaChain().search(title=self.vcb_meta.title)
if not medias:
logger.warning("匹配到最终季时无法找到对应的媒体信息季度返回默认值1")
self.vcb_meta.season = 1
return
filter_medias = [media for media in medias if media.type == MediaType.TV]
if not filter_medias:
logger.warning("匹配到最终季时无法找到对应的媒体信息季度返回默认值1")
self.vcb_meta.season = 1
return
medias = [media for media in filter_medias if media.popularity or media.vote_average]
if not medias:
logger.warning("匹配到最终季时无法找到对应的媒体信息季度返回默认值1")
self.vcb_meta.season = 1
return
# 获取欢迎度最高或者评分最高的媒体
medias_sorted = sorted(medias, key=lambda x: x.popularity or x.vote_average, reverse=True)[0]
self.vcb_meta.tmdb_id = medias_sorted.tmdb_id
if medias_sorted.tmdb_id:
seasons_info = TmdbChain().tmdb_seasons(tmdbid=medias_sorted.tmdb_id)
if seasons_info:
self.vcb_meta.season = len(seasons_info)
logger.info(f"获取到最终季度,季度为{self.vcb_meta.season}")
return
logger.warning("无法获取到最终季度信息季度返回默认值1")
self.vcb_meta.season = 1
def parse_movie(self):
logger.info("开始尝试剧场版模式解析")
for pattern in movie_patterns:
if pattern.search(self.vcb_meta.title):
logger.info("命中剧场版匹配规则,加上剧场版标识辅助识别")
self.vcb_meta.type = "Movie"
self.vcb_meta.title = pattern.sub("", self.vcb_meta.title).strip()
self.vcb_meta.title = self.vcb_meta.title
return
def find_ova_episode(self):
"""
搜索OVA的集数
TODO:模糊匹配OVA的集数
"""
pass
@staticmethod
def roman_to_int(s) -> int:
"""
:param s: 罗马数字字符串
罗马数字转整数
"""
roman_dict = {'I': 1, 'V': 5, 'X': 10, 'L': 50, 'C': 100, 'D': 500, 'M': 1000}
total = 0
prev_value = 0
for char in reversed(s): # 反向遍历罗马数字字符串
current_value = roman_dict[char]
if current_value >= prev_value:
total += current_value # 如果当前值大于等于前一个值,加上当前值
else:
total -= current_value # 如果当前值小于前一个值,减去当前值
prev_value = current_value
return total
# if __name__ == '__main__':
# ReMeta(
# ova_switch=True,
# ).handel_file(Path(
# r"[Airota&Nekomoe kissaten&VCB-Studio] Yuru Camp [Heya Camp EP00][Ma10p_1080p][x265_flac].mkv"))

View File

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