Merge remote-tracking branch 'origin/main' into main

This commit is contained in:
thsrite
2024-04-22 09:57:04 +08:00
11 changed files with 939 additions and 59 deletions

View File

@@ -15,7 +15,7 @@ MoviePilot三方插件市场https://github.com/thsrite/MoviePilot-Plugins/
- [Strm文件模式转换 1.0](docs%2FStrmConvert.md)
- 清理订阅缓存 1.0
- 添加种子下载 1.0
- 删除站点种子 1.1
- 删除站点种子 1.2
- 插件更新管理 1.6
- 插件强制重装 1.4
- 群辉Webhook通知 1.1
@@ -25,10 +25,12 @@ MoviePilot三方插件市场https://github.com/thsrite/MoviePilot-Plugins/
- [Emby观影报告 1.5](docs%2FEmbyReporter.md)
- 演员订阅 1.5
- [短剧刮削 3.2](docs%2FShortPlayMonitor.md)
- 云盘实时链接 1.5
- 云盘实时监控 1.8
- 源文件恢复 1.2
- [微信消息转发 1.5](docs%2FWeChatForward.md)
- 订阅下载统计 1.5
- [自定义命令 1.5](docs%2FCustomCommand.md)
- docker自定义任务 1.2
- 插件彻底卸载 1.0
- 实时软连接 1.3

BIN
icons/softlink.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
icons/uninstall.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@@ -65,11 +65,12 @@
"RemoveTorrent": {
"name": "删除站点种子",
"description": "删除下载器中某站点种子。",
"version": "1.1",
"version": "1.2",
"icon": "delete.png",
"author": "thsrite",
"level": 1,
"history": {
"v1.2": "修复删除种子bug",
"v1.1": "可选择删除有无辅种",
"v1.0": "选择下载器,添加种子任务"
}
@@ -200,13 +201,16 @@
}
},
"CloudLinkMonitor": {
"name": "云盘实时链接",
"description": "监控云盘目录文件变化,自动转移链接(不刮削)。",
"version": "1.5",
"name": "云盘实时监控",
"description": "监控云盘目录文件变化,自动转移链接。",
"version": "1.9",
"icon": "Linkease_A.png",
"author": "thsrite",
"level": 1,
"history": {
"v1.8": "fix S00转移",
"v1.7": "fix 刮削",
"v1.6": "可配置是否刮削",
"v1.5": "fix 消息推送",
"v1.4": "fix 转移后路径",
"v1.3": "修复bug",
@@ -243,8 +247,8 @@
"v1.1": "自定义发送额外消息",
"v1.0": "根据正则转发通知到其他WeChat应用"
}
},
"SubscribeStatistic": {
},
"SubscribeStatistic": {
"name": "订阅下载统计",
"description": "统计指定时间内各站点订阅及下载情况。",
"version": "1.5",
@@ -259,8 +263,8 @@
"v1.1": "站点去重",
"v1.0": "统计指定时间内各站点订阅及下载情况"
}
},
"CustomCommand": {
},
"CustomCommand": {
"name": "自定义命令",
"description": "自定义执行周期执行命令并推送结果。",
"version": "1.5",
@@ -275,8 +279,8 @@
"v1.1": "打印命令日志",
"v1.0": "自定义执行周期执行命令并推送结果"
}
},
"DockerManager": {
},
"DockerManager": {
"name": "docker自定义任务",
"description": "管理宿主机docker自定义容器定时任务。",
"version": "1.2",
@@ -288,5 +292,27 @@
"v1.1": "修复多个任务立即运行一次",
"v1.0": "init"
}
}
},
"PluginUnInstall": {
"name": "插件彻底卸载",
"description": "删除数据库中已安装插件记录、清理插件文件。",
"version": "1.0",
"icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/uninstall.png",
"author": "thsrite",
"level": 1,
"history": {
"v1.0": "init"
}
},
"FileSoftLink": {
"name": "实时软连接",
"description": "监控目录文件变化,媒体文件软连接,其他文件可选复制。",
"version": "1.3",
"icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/softlink.png",
"author": "thsrite",
"level": 1,
"history": {
"v1.3": "异步启动"
}
}
}

View File

@@ -54,13 +54,13 @@ class FileMonitorHandler(FileSystemEventHandler):
class CloudLinkMonitor(_PluginBase):
# 插件名称
plugin_name = "云盘实时链接"
plugin_name = "云盘实时监控"
# 插件描述
plugin_desc = "监控云盘目录文件变化,自动转移链接(不刮削不生成目的二级目录)"
plugin_desc = "监控云盘目录文件变化,自动转移链接。"
# 插件图标
plugin_icon = "Linkease_A.png"
# 插件版本
plugin_version = "1.5"
plugin_version = "1.9"
# 插件作者
plugin_author = "thsrite"
# 作者主页
@@ -109,6 +109,7 @@ class CloudLinkMonitor(_PluginBase):
# 清空配置
self._dirconf = {}
self._transferconf = {}
self._scraperconf = {}
# 读取配置
if config:
@@ -141,6 +142,12 @@ class CloudLinkMonitor(_PluginBase):
if not mon_path:
continue
# 是否刮削
_scraper_type = False
if mon_path.count("$") == 1:
_scraper_type = bool(mon_path.split("$")[1])
mon_path = mon_path.split("$")[0]
# 自定义转移方式
_transfer_type = self._transfer_type
if mon_path.count("#") == 1:
@@ -166,6 +173,9 @@ class CloudLinkMonitor(_PluginBase):
else:
self._dirconf[mon_path] = None
# 是否刮削
self._scraperconf[mon_path] = _scraper_type
# 转移方式
self._transferconf[mon_path] = _transfer_type
@@ -209,8 +219,9 @@ class CloudLinkMonitor(_PluginBase):
# 运行一次定时服务
if self._onlyonce:
logger.info("目录监控服务启动,立即运行一次")
self._scheduler.add_job(func=self.sync_all, trigger='date',
logger.info("云盘实时监控服务启动,立即运行一次")
self._scheduler.add_job(name="云盘实时监控",
func=self.sync_all, trigger='date',
run_date=datetime.datetime.now(
tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3)
)
@@ -248,7 +259,7 @@ class CloudLinkMonitor(_PluginBase):
"""
if event:
event_data = event.event_data
if not event_data or event_data.get("action") != "directory_sync":
if not event_data or event_data.get("action") != "cloud_link_sync":
return
self.post_message(channel=event.event_data.get("channel"),
title="开始同步监控目录 ...",
@@ -331,18 +342,15 @@ class CloudLinkMonitor(_PluginBase):
return
# 判断是不是蓝光目录
bluray_flag = False
if re.search(r"BDMV[/\\]STREAM", event_path, re.IGNORECASE):
bluray_flag = True
# 截取BDMV前面的路径
blurray_dir = event_path[:event_path.find("BDMV")]
file_path = Path(blurray_dir)
logger.info(f"{event_path} 是蓝光目录,更正文件路径为:{str(file_path)}")
# 查询历史记录,已转移的不处理
if self.transferhis.get_by_src(str(file_path)):
logger.info(f"{file_path} 已整理过")
return
# 查询历史记录,已转移的不处理
if self.transferhis.get_by_src(str(file_path)):
logger.info(f"{file_path} 已整理过")
return
# 元数据
file_meta = MetaInfoPath(file_path)
@@ -359,6 +367,8 @@ class CloudLinkMonitor(_PluginBase):
target: Path = self._dirconf.get(mon_path)
# 查询转移方式
transfer_type = self._transferconf.get(mon_path)
# 是否刮削
scraper_type = self._scraperconf.get(mon_path)
# 识别媒体信息
mediainfo: MediaInfo = self.chain.recognize_media(meta=file_meta)
@@ -389,12 +399,13 @@ class CloudLinkMonitor(_PluginBase):
# 获取集数据
if mediainfo.type == MediaType.TV:
episodes_info = self.tmdbchain.tmdb_episodes(tmdbid=mediainfo.tmdb_id,
season=file_meta.begin_season or 1)
season=1 if file_meta.begin_season is None else file_meta.begin_season)
else:
episodes_info = None
# 拼装媒体库一、二级子目录
target = self.__get_dest_dir(mediainfo=mediainfo, target_dir=target)
# 转移
transferinfo: TransferInfo = self.filetransfer.transfer_media(in_path=file_path,
in_meta=file_meta,
@@ -435,6 +446,16 @@ class CloudLinkMonitor(_PluginBase):
transferinfo=transferinfo
)
# 刮削
if scraper_type:
# 更新媒体图片
self.chain.obtain_images(mediainfo=mediainfo)
# 刮削单个文件
if settings.SCRAP_METADATA:
self.chain.scrape_metadata(path=transferinfo.target_path,
mediainfo=mediainfo,
transfer_type=transfer_type)
"""
{
"title_year season": {
@@ -628,22 +649,22 @@ class CloudLinkMonitor(_PluginBase):
:return: 命令关键字、事件、描述、附带数据
"""
return [{
"cmd": "/directory_sync",
"cmd": "/cloud_link_sync",
"event": EventType.PluginAction,
"desc": "目录监控同步",
"category": "管理",
"desc": "云盘实时监控同步",
"category": "",
"data": {
"action": "directory_sync"
"action": "cloud_link_sync"
}
}]
def get_api(self) -> List[Dict[str, Any]]:
return [{
"path": "/directory_sync",
"path": "/cloud_link_sync",
"endpoint": self.sync,
"methods": ["GET"],
"summary": "目录监控同步",
"description": "目录监控同步",
"summary": "云盘实时监控同步",
"description": "云盘实时监控同步",
}]
def get_service(self) -> List[Dict[str, Any]]:
@@ -659,8 +680,8 @@ class CloudLinkMonitor(_PluginBase):
"""
if self._enabled and self._cron:
return [{
"id": "DirMonitor",
"name": "目录监控全量同步服务",
"id": "CloudLinkMonitor",
"name": "云盘实时监控全量同步服务",
"trigger": CronTrigger.from_crontab(self._cron),
"func": self.sync_all,
"kwargs": {}
@@ -771,7 +792,7 @@ class CloudLinkMonitor(_PluginBase):
{'title': '移动', 'value': 'move'},
{'title': '复制', 'value': 'copy'},
{'title': '硬链接', 'value': 'link'},
{'title': '软链接', 'value': 'softlink'},
{'title': '软链接', 'value': 'filesoftlink'},
{'title': 'Rclone复制', 'value': 'rclone_copy'},
{'title': 'Rclone移动', 'value': 'rclone_move'}
]
@@ -854,7 +875,9 @@ class CloudLinkMonitor(_PluginBase):
'rows': 5,
'placeholder': '每一行一个目录,支持以下几种配置方式,转移方式支持 move、copy、link、softlink、rclone_copy、rclone_move\n'
'监控目录:转移目的目录\n'
'监控目录:转移目的目录#转移方式'
'监控目录:转移目的目录$是否刮削True/False\n'
'监控目录:转移目的目录#转移方式\n'
'监控目录:转移目的目录#转移方式$是否刮削True/False\n'
}
}
]

View File

@@ -48,7 +48,6 @@ class CloudStrm(_PluginBase):
_rebuild = False
_https = False
_observer = []
_video_formats = ('.mp4', '.avi', '.rmvb', '.wmv', '.mov', '.mkv', '.flv', '.ts', '.webm', '.iso', '.mpg', '.m2ts')
__cloud_files_json = "cloud_files.json"
_dirconf = {}
@@ -231,7 +230,7 @@ class CloudStrm(_PluginBase):
continue
# 不复制非媒体文件时直接过滤掉非媒体文件
if not self._copy_files and not file.lower().endswith(self._video_formats):
if not self._copy_files and Path(file).suffix not in settings.RMT_MEDIAEXT:
continue
if source_file not in self.__cloud_files:
@@ -278,7 +277,7 @@ class CloudStrm(_PluginBase):
continue
# 不复制非媒体文件时直接过滤掉非媒体文件
if not self._copy_files and not file.lower().endswith(self._video_formats):
if not self._copy_files and Path(file).suffix not in settings.RMT_MEDIAEXT:
continue
logger.info(f"扫描到新文件 {source_file},正在开始处理")
@@ -341,7 +340,7 @@ class CloudStrm(_PluginBase):
os.makedirs(Path(dest_file).parent)
# 视频文件创建.strm文件
if dest_file.lower().endswith(self._video_formats):
if Path(dest_file).suffix in settings.RMT_MEDIAEXT:
# 创建.strm文件
self.__create_strm_file(scheme="https" if self._https else "http",
dest_file=dest_file,

View File

@@ -784,7 +784,7 @@ class DirMonitor(_PluginBase):
{'title': '移动', 'value': 'move'},
{'title': '复制', 'value': 'copy'},
{'title': '硬链接', 'value': 'link'},
{'title': '软链接', 'value': 'softlink'},
{'title': '软链接', 'value': 'filesoftlink'},
{'title': 'Rclone复制', 'value': 'rclone_copy'},
{'title': 'Rclone移动', 'value': 'rclone_move'}
]
@@ -847,7 +847,7 @@ class DirMonitor(_PluginBase):
'model': 'monitor_dirs',
'label': '监控目录',
'rows': 5,
'placeholder': '每一行一个目录,支持以下几种配置方式,转移方式支持 move、copy、link、softlink、rclone_copy、rclone_move\n'
'placeholder': '每一行一个目录,支持以下几种配置方式,转移方式支持 move、copy、link、filesoftlink、rclone_copy、rclone_move\n'
'监控目录\n'
'监控目录#转移方式\n'
'监控目录:转移目的目录\n'

View File

@@ -0,0 +1,614 @@
import datetime
import os
import re
import shutil
import threading
import traceback
from pathlib import Path
from typing import List, Tuple, Dict, Any, Optional
import pytz
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
from watchdog.observers.polling import PollingObserver
from app import schemas
from app.core.config import settings
from app.core.event import eventmanager, Event
from app.log import logger
from app.plugins import _PluginBase
from app.schemas.types import EventType, MediaType, SystemConfigKey
from app.utils.system import SystemUtils
lock = threading.Lock()
class FileMonitorHandler(FileSystemEventHandler):
"""
目录监控响应类
"""
def __init__(self, monpath: str, sync: Any, **kwargs):
super(FileMonitorHandler, self).__init__(**kwargs)
self._watch_path = monpath
self.sync = sync
def on_created(self, event):
self.sync.event_handler(event=event, text="创建",
mon_path=self._watch_path, event_path=event.src_path)
def on_moved(self, event):
self.sync.event_handler(event=event, text="移动",
mon_path=self._watch_path, event_path=event.dest_path)
class FileSoftLink(_PluginBase):
# 插件名称
plugin_name = "实时软连接"
# 插件描述
plugin_desc = "监控目录文件变化,媒体文件软连接,其他文件可选复制。"
# 插件图标
plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/softlink.png"
# 插件版本
plugin_version = "1.3"
# 插件作者
plugin_author = "thsrite"
# 作者主页
author_url = "https://github.com/thsrite"
# 插件配置项ID前缀
plugin_config_prefix = "filesoftlink_"
# 加载顺序
plugin_order = 10
# 可使用的用户级别
auth_level = 1
# 私有属性
_scheduler = None
_observer = []
_enabled = False
_onlyonce = False
_copy_files = False
_cron = None
_size = 0
# 模式 compatibility/fast
_mode = "compatibility"
_monitor_dirs = ""
_exclude_keywords = ""
# 存储源目录与目的目录关系
_dirconf: Dict[str, Optional[Path]] = {}
_medias = {}
# 退出事件
_event = threading.Event()
def init_plugin(self, config: dict = None):
# 清空配置
self._dirconf = {}
# 读取配置
if config:
self._enabled = config.get("enabled")
self._onlyonce = config.get("onlyonce")
self._copy_files = config.get("copy_files")
self._mode = config.get("mode")
self._monitor_dirs = config.get("monitor_dirs") or ""
self._exclude_keywords = config.get("exclude_keywords") or ""
self._cron = config.get("cron")
self._size = config.get("size") or 0
# 停止现有任务
self.stop_service()
if self._enabled or self._onlyonce:
# 定时服务管理器
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
# 读取目录配置
monitor_dirs = self._monitor_dirs.split("\n")
if not monitor_dirs:
return
for mon_path in monitor_dirs:
# 格式源目录:目的目录
if not mon_path:
continue
# 存储目的目录
if SystemUtils.is_windows():
if mon_path.count(":") > 1:
paths = [mon_path.split(":")[0] + ":" + mon_path.split(":")[1],
mon_path.split(":")[2] + ":" + mon_path.split(":")[3]]
else:
paths = [mon_path]
else:
paths = mon_path.split(":")
# 目的目录
target_path = None
if len(paths) > 1:
mon_path = paths[0]
target_path = Path(paths[1])
self._dirconf[mon_path] = target_path
else:
self._dirconf[mon_path] = None
# 启用目录监控
if self._enabled:
# 检查媒体库目录是不是下载目录的子目录
try:
if target_path and target_path.is_relative_to(Path(mon_path)):
logger.warn(f"{target_path} 是监控目录 {mon_path} 的子目录,无法监控")
self.systemmessage.put(f"{target_path} 是下载目录 {mon_path} 的子目录,无法监控")
continue
except Exception as e:
logger.debug(str(e))
pass
# 异步开启云盘监控
logger.info(f"异步开启实时硬链接 {mon_path} {self._mode}延迟5s启动")
self._scheduler.add_job(func=self.start_monitor, trigger='date',
run_date=datetime.datetime.now(
tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=5),
name=f"实时硬链接 {mon_path}",
kwargs={
"source_dir": mon_path
})
# 运行一次定时服务
if self._onlyonce:
logger.info("实时软连接服务启动,立即运行一次")
self._scheduler.add_job(name="实时软连接", func=self.sync_all, trigger='date',
run_date=datetime.datetime.now(
tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3)
)
# 关闭一次性开关
self._onlyonce = False
# 保存配置
self.__update_config()
# 启动定时服务
if self._scheduler.get_jobs():
self._scheduler.print_jobs()
self._scheduler.start()
def start_monitor(self, source_dir: str):
"""
异步开启实时软链接
"""
try:
if str(self._mode) == "compatibility":
# 兼容模式目录同步性能降低且NAS不能休眠但可以兼容挂载的远程共享目录如SMB
observer = PollingObserver(timeout=10)
else:
# 内部处理系统操作类型选择最优解
observer = Observer(timeout=10)
self._observer.append(observer)
observer.schedule(FileMonitorHandler(source_dir, self), path=source_dir, recursive=True)
observer.daemon = True
observer.start()
logger.info(f"{source_dir} 的实时软链接服务启动")
except Exception as e:
err_msg = str(e)
if "inotify" in err_msg and "reached" in err_msg:
logger.warn(
f"云盘监控服务启动出现异常:{err_msg}请在宿主机上不是docker容器内执行以下命令并重启"
+ """
echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf
echo fs.inotify.max_user_instances=524288 | sudo tee -a /etc/sysctl.conf
sudo sysctl -p
""")
else:
logger.error(f"{source_dir} 启动云盘监控失败:{err_msg}")
self.systemmessage.put(f"{source_dir} 启动云盘监控失败:{err_msg}")
def __update_config(self):
"""
更新配置
"""
self.update_config({
"enabled": self._enabled,
"onlyonce": self._onlyonce,
"copy_files": self._copy_files,
"mode": self._mode,
"monitor_dirs": self._monitor_dirs,
"exclude_keywords": self._exclude_keywords,
"cron": self._cron,
"size": self._size
})
@eventmanager.register(EventType.PluginAction)
def remote_sync(self, event: Event):
"""
远程全量同步
"""
if event:
event_data = event.event_data
if not event_data or event_data.get("action") != "softlink_sync":
return
self.post_message(channel=event.event_data.get("channel"),
title="开始同步监控目录 ...",
userid=event.event_data.get("user"))
self.sync_all()
if event:
self.post_message(channel=event.event_data.get("channel"),
title="监控目录同步完成!", userid=event.event_data.get("user"))
def sync_all(self):
"""
立即运行一次,全量同步目录中所有文件
"""
logger.info("开始全量同步监控目录 ...")
# 遍历所有监控目录
for mon_path in self._dirconf.keys():
# 遍历目录下所有文件
for file_path in SystemUtils.list_files(Path(mon_path), ['.*']):
self.__handle_file(event_path=str(file_path), mon_path=mon_path)
logger.info("全量同步监控目录完成!")
def event_handler(self, event, mon_path: str, text: str, event_path: str):
"""
处理文件变化
:param event: 事件
:param mon_path: 监控目录
:param text: 事件描述
:param event_path: 事件文件路径
"""
if not event.is_directory:
# 文件发生变化
logger.debug("文件%s%s" % (text, event_path))
self.__handle_file(event_path=event_path, mon_path=mon_path)
def __handle_file(self, event_path: str, mon_path: str):
"""
同步一个文件
:param event_path: 事件文件路径
:param mon_path: 监控目录
"""
file_path = Path(event_path)
try:
if not file_path.exists():
return
# 全程加锁
with lock:
# 回收站及隐藏的文件不处理
if event_path.find('/@Recycle/') != -1 \
or event_path.find('/#recycle/') != -1 \
or event_path.find('/.') != -1 \
or event_path.find('/@eaDir') != -1:
logger.debug(f"{event_path} 是回收站或隐藏的文件")
return
# 命中过滤关键字不处理
if self._exclude_keywords:
for keyword in self._exclude_keywords.split("\n"):
if keyword and re.findall(keyword, event_path):
logger.info(f"{event_path} 命中过滤关键字 {keyword},不处理")
return
# 整理屏蔽词不处理
transfer_exclude_words = self.systemconfig.get(SystemConfigKey.TransferExcludeWords)
if transfer_exclude_words:
for keyword in transfer_exclude_words:
if not keyword:
continue
if keyword and re.search(r"%s" % keyword, event_path, re.IGNORECASE):
logger.info(f"{event_path} 命中整理屏蔽词 {keyword},不处理")
return
# 判断是不是蓝光目录
if re.search(r"BDMV[/\\]STREAM", event_path, re.IGNORECASE):
# 截取BDMV前面的路径
blurray_dir = event_path[:event_path.find("BDMV")]
file_path = Path(blurray_dir)
logger.info(f"{event_path} 是蓝光目录,更正文件路径为:{str(file_path)}")
# 判断文件大小
if self._size and float(self._size) > 0 and file_path.stat().st_size < float(self._size) * 1024 ** 3:
logger.info(f"{file_path} 文件大小小于监控文件大小,不处理")
return
# 查询转移目的目录
target: Path = self._dirconf.get(mon_path)
target_file = str(file_path).replace(str(mon_path), str(target))
# 如果是文件夹
if Path(target_file).is_dir():
if not Path(target_file).exists():
logger.info(f"创建目标文件夹 {target_file}")
os.makedirs(target_file)
return
else:
# 文件
if Path(target_file).exists():
logger.info(f"目标文件 {target_file} 已存在")
return
if not Path(target_file).parent.exists():
logger.info(f"创建目标文件夹 {Path(target_file).parent}")
os.makedirs(Path(target_file).parent)
# 媒体文件软连接
if Path(target_file).suffix in settings.RMT_MEDIAEXT:
retcode, retmsg = SystemUtils.softlink(file_path, Path(target_file))
logger.info(f"创建媒体文件软连接 {str(file_path)}{target_file} {retcode} {retmsg}")
else:
if self._copy_files:
# 其他nfo、jpg等复制文件
shutil.copy2(str(file_path), target_file)
logger.info(f"复制其他文件 {str(file_path)}{target_file}")
except Exception as e:
logger.error("软连接发生错误:%s - %s" % (str(e), traceback.format_exc()))
def get_state(self) -> bool:
return self._enabled
@staticmethod
def get_command() -> List[Dict[str, Any]]:
"""
定义远程控制命令
:return: 命令关键字、事件、描述、附带数据
"""
return [{
"cmd": "/softlink_sync",
"event": EventType.PluginAction,
"desc": "文件软连接同步",
"category": "",
"data": {
"action": "softlink_sync"
}
}]
def get_api(self) -> List[Dict[str, Any]]:
return [{
"path": "/softlink_sync",
"endpoint": self.sync,
"methods": ["GET"],
"summary": "实时软连接同步",
"description": "实时软连接同步",
}]
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": "FileSoftLink",
"name": "实时软连接全量同步服务",
"trigger": CronTrigger.from_crontab(self._cron),
"func": self.sync_all,
"kwargs": {}
}]
return []
def sync(self) -> schemas.Response:
"""
API调用目录同步
"""
self.sync_all()
return schemas.Response(success=True)
def get_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': 'onlyonce',
'label': '立即运行一次',
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'copy_files',
'label': '复制非媒体文件',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VSelect',
'props': {
'model': 'mode',
'label': '监控模式',
'items': [
{'title': '兼容模式', 'value': 'compatibility'},
{'title': '性能模式', 'value': 'fast'}
]
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'cron',
'label': '定时全量同步周期',
'placeholder': '5位cron表达式留空关闭'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'size',
'label': '监控文件大小GB',
'placeholder': '0'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12
},
'content': [
{
'component': 'VTextarea',
'props': {
'model': 'monitor_dirs',
'label': '监控目录',
'rows': 5,
'placeholder': '监控目录:转移目的目录'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VTextarea',
'props': {
'model': 'exclude_keywords',
'label': '排除关键词',
'rows': 2,
'placeholder': '每一行一个关键词'
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VAlert',
'props': {
'type': 'info',
'variant': 'tonal',
'text': '监控文件大小单位GB0为不开启低于监控文件大小的文件不会被监控转移。'
}
}
]
}
]
}
]
}
], {
"enabled": False,
"onlyonce": False,
"copy_files": True,
"mode": "compatibility",
"monitor_dirs": "",
"exclude_keywords": "",
"cron": "",
"size": 0
}
def get_page(self) -> List[dict]:
pass
def stop_service(self):
"""
退出插件
"""
if self._observer:
for observer in self._observer:
try:
observer.stop()
observer.join()
except Exception as e:
print(str(e))
self._observer = []
if self._scheduler:
self._scheduler.remove_all_jobs()
if self._scheduler.running:
self._event.set()
self._scheduler.shutdown()
self._event.clear()
self._scheduler = None

View File

@@ -0,0 +1,184 @@
import shutil
from pathlib import Path
from app.core.config import settings
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.plugin import PluginHelper
from app.plugins import _PluginBase
from typing import Any, List, Dict, Tuple
from app.log import logger
from app.schemas.types import SystemConfigKey
from app.utils.string import StringUtils
class PluginUnInstall(_PluginBase):
# 插件名称
plugin_name = "插件彻底卸载"
# 插件描述
plugin_desc = "删除数据库中已安装插件记录、清理插件文件。"
# 插件图标
plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/uninstall.png"
# 插件版本
plugin_version = "1.0"
# 插件作者
plugin_author = "thsrite"
# 作者主页
author_url = "https://github.com/thsrite"
# 插件配置项ID前缀
plugin_config_prefix = "pluginuninstall_"
# 加载顺序
plugin_order = 98
# 可使用的用户级别
auth_level = 1
# 私有属性
_plugin_ids = []
def init_plugin(self, config: dict = None):
if config:
self._plugin_ids = config.get("plugin_ids") or []
if not self._plugin_ids:
return
# 已安装插件
install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
new_install_plugins = []
for install_plugin in install_plugins:
if install_plugin in self._plugin_ids:
# 删除插件文件
plugin_dir = Path(settings.ROOT_PATH) / "app" / "plugins" / install_plugin.lower()
if plugin_dir.exists():
shutil.rmtree(plugin_dir, ignore_errors=True)
logger.info(f"插件 {install_plugin} 已卸载")
else:
new_install_plugins.append(install_plugin)
# 保存已安装插件
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, new_install_plugins)
self.update_config({
"plugin_ids": ""
})
def get_state(self) -> bool:
return False
@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、数据结构
"""
# 已安装插件
local_plugins = self.get_local_plugins()
# 编历 local_plugins生成插件类型选项
pluginOptions = []
for plugin_id in list(local_plugins.keys()):
local_plugin = local_plugins.get(plugin_id)
pluginOptions.append({
"title": f"{local_plugin.get('plugin_name')} v{local_plugin.get('plugin_version')}",
"value": local_plugin.get("id")
})
return [
{
'component': 'VForm',
'content': [
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VSelect',
'props': {
'multiple': True,
'chips': True,
'model': 'plugin_ids',
'label': '卸载插件',
'items': pluginOptions
}
}
]
},
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VAlert',
'props': {
'type': 'info',
'variant': 'tonal',
'text': '删除数据库中已安装插件记录、清理插件文件。'
}
}
]
}
]
},
]
}
], {
"plugin_ids": []
}
@staticmethod
def get_local_plugins():
"""
获取本地插件
"""
# 已安装插件
install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
local_plugins = {}
# 线上插件列表
markets = settings.PLUGIN_MARKET.split(",")
for market in markets:
online_plugins = PluginHelper().get_plugins(market) or {}
for pid, plugin in online_plugins.items():
if pid in install_plugins:
local_plugin = local_plugins.get(pid)
if local_plugin:
if StringUtils.compare_version(local_plugin.get("plugin_version"), plugin.get("version")) < 0:
local_plugins[pid] = {
"id": pid,
"plugin_name": plugin.get("name"),
"repo_url": market,
"plugin_version": plugin.get("version")
}
else:
local_plugins[pid] = {
"id": pid,
"plugin_name": plugin.get("name"),
"repo_url": market,
"plugin_version": plugin.get("version")
}
return local_plugins
def get_page(self) -> List[dict]:
pass
def stop_service(self):
"""
退出插件
"""
pass

View File

@@ -13,7 +13,7 @@ class RemoveTorrent(_PluginBase):
# 插件图标
plugin_icon = "delete.png"
# 插件版本
plugin_version = "1.1"
plugin_version = "1.2"
# 插件作者
plugin_author = "thsrite"
# 作者主页
@@ -27,10 +27,13 @@ class RemoveTorrent(_PluginBase):
# 私有属性
_downloader = None
_onlyonce = None
_delete_type = False
_delete_torrent = False
_delete_file = False
_trackers = None
qb = None
tr = None
def init_plugin(self, config: dict = None):
self.qb = Qbittorrent()
@@ -38,23 +41,26 @@ class RemoveTorrent(_PluginBase):
if config:
self._downloader = config.get("downloader")
self._onlyonce = config.get("onlyonce")
self._delete_type = config.get("delete_type")
self._delete_torrent = config.get("delete_torrent")
self._delete_file = config.get("delete_file")
self._trackers = config.get("trackers")
if self._trackers:
for tracker in str(self._trackers).split("\n"):
logger.info(f"开始处理站点tracker {tracker}")
self.__check_feed(tracker)
if self._trackers and self._onlyonce:
self.update_config({
"downloader": self._downloader,
"delete_type": self._delete_type,
"delete_torrent": self._delete_torrent,
"delete_file": self._delete_file,
"trackers": self._trackers,
"onlyonce": False
})
self.update_config({
"downloader": self._downloader,
"delete_type": self._delete_type,
"delete_torrent": self._delete_torrent,
"delete_file": self._delete_file,
"trackers": ""
})
for tracker in str(self._trackers).split("\n"):
logger.info(f"下载器 {self._downloader} 开始处理站点tracker {tracker}")
self.__check_feed(tracker)
logger.info(f"下载器 {self._downloader} 处理站点tracker {tracker} 完成")
def __check_feed(self, tracker: str):
"""
@@ -66,6 +72,7 @@ class RemoveTorrent(_PluginBase):
if not torrents:
logger.info(f"下载器 {self._downloader} 未获取到已完成种子")
return
logger.info(f"下载器 {self._downloader} 获取到已完成种子 {len(torrents)}")
all_torrents = []
tracker_torrents = []
@@ -76,23 +83,26 @@ class RemoveTorrent(_PluginBase):
torrent_name = self.__get_torrent_name(torrent, self._downloader)
torrent_key = "%s-%s" % (torrent_name, torrent_size)
all_torrents.append(torrent_key)
key_torrents[torrent_key] = torrent
torrent_trackers = self.__get_torrent_trackers(torrent, self._downloader)
if str(self._downloader) == "qb":
# 命中tracker的种子
if str(tracker) in torrent_trackers:
tracker_torrents.append(torrent_key)
key_torrents[torrent_key] = torrent
else:
for torrent_tracker in torrent_trackers:
# 命中tracker的种子
if str(tracker) in torrent_tracker.get('announce'):
tracker_torrents.append(torrent_key)
key_torrents[torrent_key] = torrent
if not tracker_torrents:
logger.error(f"下载器 {self._downloader} 未获取到命中tracker {tracker} 的种子")
return
logger.info(f"下载器 {self._downloader} 获取到命中tracker {tracker} 已完成种子 {len(tracker_torrents)}")
# 查询tracker种子是否有其他辅种
for tracker_torrent in tracker_torrents:
torrent = key_torrents.get(tracker_torrent)
@@ -198,6 +208,27 @@ class RemoveTorrent(_PluginBase):
{
'component': 'VForm',
'content': [
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 3
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'onlyonce',
'label': '立即运行一次',
}
}
]
},
]
},
{
'component': 'VRow',
'content': [
@@ -380,6 +411,7 @@ class RemoveTorrent(_PluginBase):
"delete_type": True,
"delete_torrent": False,
"delete_file": False,
"onlyonce": False,
"trackers": ""
}

View File

@@ -503,7 +503,7 @@ class ShortPlayMonitor(_PluginBase):
if transfer_type == 'link':
# 硬链接
retcode, retmsg = SystemUtils.link(file_item, target_file)
elif transfer_type == 'softlink':
elif transfer_type == 'filesoftlink':
# 软链接
retcode, retmsg = SystemUtils.softlink(file_item, target_file)
elif transfer_type == 'move':
@@ -884,7 +884,7 @@ class ShortPlayMonitor(_PluginBase):
{'title': '移动', 'value': 'move'},
{'title': '复制', 'value': 'copy'},
{'title': '硬链接', 'value': 'link'},
{'title': '软链接', 'value': 'softlink'},
{'title': '软链接', 'value': 'filesoftlink'},
{'title': 'Rclone复制', 'value': 'rclone_copy'},
{'title': 'Rclone移动', 'value': 'rclone_move'}
]