import threading from datetime import datetime, timedelta from pathlib import Path from threading import Event as ThreadEvent from typing import List, Tuple, Dict, Any import pytz from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger from app.core.config import settings from app.core.event import eventmanager, Event from app.log import logger from app.plugins import _PluginBase from app.plugins.ffmpegthumb.ffmpeg_helper import FfmpegHelper from app.schemas import TransferInfo from app.schemas.types import EventType from app.utils.system import SystemUtils ffmpeg_lock = threading.Lock() class FFmpegThumb(_PluginBase): # 插件名称 plugin_name = "FFmpeg缩略图" # 插件描述 plugin_desc = "TheMovieDb没有背景图片时使用FFmpeg截取视频文件缩略图。" # 插件图标 plugin_icon = "ffmpeg.png" # 插件版本 plugin_version = "1.2" # 插件作者 plugin_author = "jxxghp" # 作者主页 author_url = "https://github.com/jxxghp" # 插件配置项ID前缀 plugin_config_prefix = "ffmpegthumb_" # 加载顺序 plugin_order = 31 # 可使用的用户级别 user_level = 1 # 私有属性 _scheduler = None _enabled = False _onlyonce = False _cron = None _timeline = "00:03:01" _scan_paths = "" _exclude_paths = "" # 退出事件 _event = ThreadEvent() def init_plugin(self, config: dict = None): # 读取配置 if config: self._enabled = config.get("enabled") self._onlyonce = config.get("onlyonce") self._cron = config.get("cron") self._timeline = config.get("timeline") self._scan_paths = config.get("scan_paths") or "" self._exclude_paths = config.get("exclude_paths") or "" # 停止现有任务 self.stop_service() # 启动定时任务 & 立即运行一次 if self._enabled or self._onlyonce: self._scheduler = BackgroundScheduler(timezone=settings.TZ) if self._cron: logger.info(f"FFmpeg缩略图服务启动,周期:{self._cron}") try: self._scheduler.add_job(func=self.__libraryscan, trigger=CronTrigger.from_crontab(self._cron), name="FFmpeg缩略图") except Exception as e: logger.error(f"FFmpeg缩略图服务启动失败,原因:{str(e)}") self.systemmessage.put(f"FFmpeg缩略图服务启动失败,原因:{str(e)}", title="FFmpeg缩略图") if self._onlyonce: logger.info(f"FFmpeg缩略图服务,立即运行一次") self._scheduler.add_job(func=self.__libraryscan, trigger='date', run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), name="FFmpeg缩略图") # 关闭一次性开关 self._onlyonce = False self.update_config({ "onlyonce": False, "enabled": self._enabled, "cron": self._cron, "timeline": self._timeline, "scan_paths": self._scan_paths, "exclude_paths": self._exclude_paths }) if self._scheduler.get_jobs(): # 启动服务 self._scheduler.print_jobs() self._scheduler.start() def get_state(self) -> bool: return self._enabled @staticmethod def get_command() -> List[Dict[str, Any]]: pass def get_api(self) -> List[Dict[str, Any]]: pass def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: return [ { 'component': 'VForm', 'content': [ { 'component': 'VRow', 'content': [ { 'component': 'VCol', 'props': { 'cols': 12, 'md': 6 }, 'content': [ { 'component': 'VSwitch', 'props': { 'model': 'enabled', 'label': '启用插件', } } ] }, { 'component': 'VCol', 'props': { 'cols': 12, 'md': 6 }, 'content': [ { 'component': 'VSwitch', 'props': { 'model': 'onlyonce', 'label': '立即运行一次', } } ] } ] }, { 'component': 'VRow', 'content': [ { 'component': 'VCol', 'props': { 'cols': 12, 'md': 6 }, 'content': [ { 'component': 'VTextField', 'props': { 'model': 'timeline', 'label': '截取时间', 'placeholder': '00:03:01' } } ] }, { 'component': 'VCol', 'props': { 'cols': 12, 'md': 6 }, 'content': [ { 'component': 'VTextField', 'props': { 'model': 'cron', 'label': '定时扫描周期', 'placeholder': '5位cron表达式,留空关闭' } } ] } ] }, { 'component': 'VRow', 'content': [ { 'component': 'VCol', 'props': { 'cols': 12 }, 'content': [ { 'component': 'VTextarea', 'props': { 'model': 'scan_paths', 'label': '定时扫描路径', 'rows': 5, 'placeholder': '每一行一个目录' } } ] } ] }, { 'component': 'VRow', 'content': [ { 'component': 'VCol', 'props': { 'cols': 12 }, 'content': [ { 'component': 'VTextarea', 'props': { 'model': 'exclude_paths', 'label': '定时扫描排除路径', 'rows': 2, 'placeholder': '每一行一个目录' } } ] } ] }, { 'component': 'VRow', 'content': [ { 'component': 'VCol', 'props': { 'cols': 12, }, 'content': [ { 'component': 'VAlert', 'props': { 'type': 'info', 'variant': 'tonal', 'text': '开启插件后默认会实时处理增量整理的媒体文件,需要处理存量媒体文件时才需开启定时;需要提前安装FFmpeg:https://www.ffmpeg.org' } } ] } ] } ] } ], { "enabled": False, "cron": "", "timeline": "00:03:01", "scan_paths": "", "err_hosts": "" } def get_page(self) -> List[dict]: pass @eventmanager.register(EventType.TransferComplete) def scan_rt(self, event: Event): """ 根据事件实时扫描缩略图 """ if not self._enabled: return # 事件数据 transferinfo: TransferInfo = event.event_data.get("transferinfo") if not transferinfo: return file_list = transferinfo.file_list_new for file in file_list: logger.info(f"FFmpeg缩略图处理文件:{file}") file_path = Path(file) if not file_path.exists(): logger.warn(f"{file_path} 不存在") continue if file_path.suffix not in settings.RMT_MEDIAEXT: logger.warn(f"{file_path} 不是支持的视频文件") continue self.gen_file_thumb(file_path) def __libraryscan(self): """ 开始扫描媒体库 """ if not self._scan_paths: return # 排除目录 exclude_paths = self._exclude_paths.split("\n") # 已选择的目录 paths = self._scan_paths.split("\n") for path in paths: if not path: continue scan_path = Path(path) if not scan_path.exists(): logger.warning(f"FFmpeg缩略图扫描路径不存在:{path}") continue logger.info(f"开始FFmpeg缩略图扫描:{path} ...") # 遍历目录下的所有文件 for file_path in SystemUtils.list_files(scan_path, extensions=settings.RMT_MEDIAEXT): if self._event.is_set(): logger.info(f"FFmpeg缩略图扫描服务停止") return # 排除目录 exclude_flag = False for exclude_path in exclude_paths: try: if file_path.is_relative_to(Path(exclude_path)): exclude_flag = True break except Exception as err: print(str(err)) if exclude_flag: logger.debug(f"{file_path} 在排除目录中,跳过 ...") continue # 开始处理文件 self.gen_file_thumb(file_path) logger.info(f"目录 {path} 扫描完成") def gen_file_thumb(self, file_path: Path): """ 处理一个文件 """ # 单线程处理 with ffmpeg_lock: try: thumb_path = file_path.with_name(file_path.stem + "-thumb.jpg") if thumb_path.exists(): logger.info(f"缩略图已存在:{thumb_path}") return if FfmpegHelper.get_thumb(video_path=str(file_path), image_path=str(thumb_path), frames=self._timeline): logger.info(f"{file_path} 缩略图已生成:{thumb_path}") except Exception as err: logger.error(f"FFmpeg处理文件 {file_path} 时发生错误:{str(err)}") def stop_service(self): """ 退出插件 """ try: if self._scheduler: self._scheduler.remove_all_jobs() if self._scheduler.running: self._event.set() self._scheduler.shutdown() self._event.clear() self._scheduler = None except Exception as e: print(str(e))