From 62d125a9eb5d302f2d9301cbef6acf08d38428e5 Mon Sep 17 00:00:00 2001 From: thsrite Date: Wed, 3 Jan 2024 17:01:23 +0800 Subject: [PATCH] =?UTF-8?q?feat=20=E7=9F=AD=E5=89=A7=E5=88=AE=E5=89=8A?= =?UTF-8?q?=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + docs/ShortPlayMonitor.md | 5 + package.json | 8 + plugins/shortplaymonitor/__init__.py | 430 +++++++++++++++++++++++++++ 4 files changed, 444 insertions(+) create mode 100644 docs/ShortPlayMonitor.md create mode 100644 plugins/shortplaymonitor/__init__.py diff --git a/README.md b/README.md index 67736e8..7b3de3b 100644 --- a/README.md +++ b/README.md @@ -24,4 +24,5 @@ MoviePilot三方插件市场:https://github.com/thsrite/MoviePilot-Plugins/ - [订阅提醒 1.1](docs%2FSubscribeReminder.md) - [Emby观影报告 1.4](docs%2FEmbyReporter.md) - [豆瓣明星热映订阅 1.2](docs%2FActorSubscribe.md) +- [短剧刮削 1.0](docs%2FShortPlayMonitor.md) diff --git a/docs/ShortPlayMonitor.md b/docs/ShortPlayMonitor.md new file mode 100644 index 0000000..f777f69 --- /dev/null +++ b/docs/ShortPlayMonitor.md @@ -0,0 +1,5 @@ +# 短剧刮削 + +### 更新记录 + +- 1.0 监控视频短剧创建,刮削 \ No newline at end of file diff --git a/package.json b/package.json index c58bf8e..5eff358 100644 --- a/package.json +++ b/package.json @@ -118,5 +118,13 @@ "icon": "Mdcng_A.png", "author": "thsrite", "level": 1 + }, + "ShortPlayMonitor": { + "name": "短剧刮削", + "description": "监控视频短剧创建,刮削。", + "version": "1.0", + "icon": "Amule_B.png", + "author": "thsrite", + "level": 1 } } diff --git a/plugins/shortplaymonitor/__init__.py b/plugins/shortplaymonitor/__init__.py new file mode 100644 index 0000000..a1a5431 --- /dev/null +++ b/plugins/shortplaymonitor/__init__.py @@ -0,0 +1,430 @@ +import os +import threading +from pathlib import Path + +from typing import Any, List, Dict, Tuple, Optional + +from apscheduler.schedulers.background import BackgroundScheduler +from watchdog.events import FileSystemEventHandler +from watchdog.observers import Observer +from watchdog.observers.polling import PollingObserver + +from app.core.meta.words import WordsMatcher +from app.log import logger +from app.plugins import _PluginBase +from app.core.config import settings +from app.utils.system import SystemUtils + +ffmpeg_lock = threading.Lock() + + +class FileMonitorHandler(FileSystemEventHandler): + """ + 目录监控响应类 + """ + + def __init__(self, watching_path: str, file_change: Any, **kwargs): + super(FileMonitorHandler, self).__init__(**kwargs) + self._watch_path = watching_path + self.file_change = file_change + + # def on_any_event(self, event): + # logger.info(f"目录监控event_type {event.event_type} 路径 {event.src_path}") + + def on_created(self, event): + self.file_change.event_handler(event=event, source_dir=self._watch_path, event_path=event.src_path) + + def on_moved(self, event): + self.file_change.event_handler(event=event, source_dir=self._watch_path, event_path=event.dest_path) + + +class ShortPlayMonitor(_PluginBase): + # 插件名称 + plugin_name = "短剧刮削" + # 插件描述 + plugin_desc = "监控视频短剧创建,刮削。" + # 插件图标 + plugin_icon = "Amule_B.png" + # 插件版本 + plugin_version = "1.0" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "shortplaymonitor_" + # 加载顺序 + plugin_order = 26 + # 可使用的用户级别 + auth_level = 1 + + # 私有属性 + _enabled = False + _monitor_confs = None + _onlyonce = False + _observer = [] + _video_formats = ('.mp4', '.avi', '.rmvb', '.wmv', '.mov', '.mkv', '.flv', '.ts', '.webm', '.iso', '.mpg', '.m2ts') + _timeline = "00:03:01" + _dirconf = {} + _renameconf = {} + _coverconf = {} + + # 定时器 + _scheduler: Optional[BackgroundScheduler] = None + + def init_plugin(self, config: dict = None): + # 清空配置 + self._dirconf = {} + self._renameconf = {} + self._coverconf = {} + + if config: + self._enabled = config.get("enabled") + # self._onlyonce = config.get("onlyonce") + self._monitor_confs = config.get("monitor_confs") + + # 停止现有任务 + self.stop_service() + + if self._enabled or self._onlyonce: + # 定时服务 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + # 读取目录配置 + monitor_confs = self._monitor_confs.split("\n") + if not monitor_confs: + return + for monitor_conf in monitor_confs: + # 格式 监控方式#监控目录#目的目录#是否重命名#封面比例 + if not monitor_conf: + continue + if str(monitor_conf).count("#") != 4: + logger.error(f"{monitor_conf} 格式错误") + continue + mode = str(monitor_conf).split("#")[0] + source_dir = str(monitor_conf).split("#")[1] + target_dir = str(monitor_conf).split("#")[2] + rename_conf = str(monitor_conf).split("#")[3] + cover_conf = str(monitor_conf).split("#")[4] + + # 存储目录监控配置 + self._dirconf[source_dir] = target_dir + self._renameconf[source_dir] = rename_conf + self._coverconf[source_dir] = cover_conf + + # 启用目录监控 + if self._enabled: + # 检查媒体库目录是不是下载目录的子目录 + try: + if target_dir and Path(target_dir).is_relative_to(Path(source_dir)): + logger.warn(f"{target_dir} 是下载目录 {source_dir} 的子目录,无法监控") + self.systemmessage.put(f"{target_dir} 是下载目录 {source_dir} 的子目录,无法监控") + continue + except Exception as e: + logger.debug(str(e)) + pass + + try: + if 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}") + + # # 运行一次定时服务 + # if self._onlyonce: + # logger.info("云盘监控服务启动,立即运行一次") + # self._scheduler.add_job(func=self.sync_all, 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 event_handler(self, event, source_dir: str, event_path: str): + """ + 处理文件变化 + :param event: 事件 + :param source_dir: 监控目录 + :param event_path: 事件文件路径 + """ + # 回收站及隐藏的文件不处理 + if (event_path.find("/@Recycle") != -1 + or event_path.find("/#recycle") != -1 + or event_path.find("/.") != -1 + or event_path.find("/@eaDir") != -1): + logger.info(f"{event_path} 是回收站或隐藏的文件,跳过处理") + return + + # 文件发生变化 + logger.info(f"变动类型 {event.event_type} 变动路径 {event_path}") + self.__handle_file(event=event, event_path=event_path, source_dir=source_dir) + + def __handle_file(self, event, event_path: str, source_dir: str): + """ + 同步一个文件 + :param event_path: 事件文件路径 + :param source_dir: 监控目录 + """ + try: + # 转移路径 + dest_dir = self._dirconf.get(source_dir) + # 是否重命名 + rename_conf = self._renameconf.get(source_dir) + # 封面比例 + cover_conf = self._coverconf.get(source_dir) + + target_path = event_path.replace(source_dir, dest_dir) + + # 硬链接 + if rename_conf: + # 预处理标题 + title, _ = WordsMatcher().prepare(Path(target_path).name) + else: + title = Path(target_path).name + + target_path = Path(target_path).parent / title + + # 文件夹同步创建 + if event.is_directory: + # 目标文件夹不存在则创建 + if not Path(target_path).exists(): + logger.info(f"创建目标文件夹 {target_path}") + os.makedirs(target_path) + else: + # 目标文件夹不存在则创建 + if not Path(target_path).parent.exists(): + logger.info(f"创建目标文件夹 {Path(target_path).parent}") + os.makedirs(Path(target_path).parent) + + # 文件:nfo、图片、视频文件 + if Path(target_path).exists(): + logger.debug(f"目标文件 {target_path} 已存在") + return + + # 硬链接 + retcode, retmsg = SystemUtils.link(Path(source_dir), target_path) + if retcode == 0: + logger.info(f"文件 {source_dir} 硬链接完成") + + # 生成缩略图 + thumb_path = self.gen_file_thumb(target_path) + if not (target_path.parent / "poster.jpg").exists(): + SystemUtils.copy(thumb_path, target_path.parent / "poster.jpg") + else: + logger.error(f"文件 {source_dir} 硬链接失败,错误码:{retcode}") + + except Exception as e: + logger.error(f"event_handler_created error: {e}") + print(str(e)) + + 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 self.get_thumb(video_path=str(file_path), + image_path=str(thumb_path), frames=self._timeline): + logger.info(f"{file_path} 缩略图已生成:{thumb_path}") + return thumb_path + except Exception as err: + logger.error(f"FFmpeg处理文件 {file_path} 时发生错误:{str(err)}") + return None + + @staticmethod + def get_thumb(video_path: str, image_path: str, frames: str = None, cover_conf: str = None): + """ + 使用ffmpeg从视频文件中截取缩略图 + """ + if not cover_conf: + cover_conf = "720:1080" + else: + covers = cover_conf.split(":") + cover_conf = f"{covers[0] * 360}:{covers[1] * 360}" + 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 -vf “scale={cover_conf}” -f image2 "{image_path}"'.format( + video_path=video_path, + frames=frames, + cover_conf=cover_conf, + image_path=image_path) + result = SystemUtils.execute(cmd) + if result: + return True + return False + + def __update_config(self): + """ + 更新配置 + """ + self.update_config({ + "enabled": self._enabled, + # "onlyonce": self._onlyonce, + "monitor_confs": self._monitor_confs + }) + + 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': 'onlyonce', + # 'label': '立即运行一次', + # } + # } + # ] + # } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'monitor_confs', + 'label': '监控目录', + 'rows': 5, + 'placeholder': '监控目录#目的目录#是否重命名#封面比例' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '配置说明:' + 'https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/docs/CloudStrm.md' + } + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + # "relay": 3, + # "onlyonce": False, + "monitor_confs": "" + } + + 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)) + + if self._observer: + for observer in self._observer: + try: + observer.stop() + observer.join() + except Exception as e: + print(str(e)) + self._observer = []