diff --git a/icons/vcbmonitor.png b/icons/vcbmonitor.png new file mode 100644 index 0000000..95f35c4 Binary files /dev/null and b/icons/vcbmonitor.png differ diff --git a/package.json b/package.json index 804945e..e5329e7 100644 --- a/package.json +++ b/package.json @@ -191,6 +191,14 @@ "author": "233@qingwa", "level": 2 }, + "VCBAnimeMonitor": { + "name": "整理VCB动漫压制组作品", + "description": "提高部分VCB-Studio作品的识别准确率,将VCB-Studio的作品统一转移到指定目录同时进行刮削整理", + "version": "1.6.6", + "icon": "qingwa.png", + "author": "pixel@qingwa", + "level": 2 + }, "TorrentTransfer": { "name": "自动转移做种", "description": "定期转移下载器中的做种任务到另一个下载器。", diff --git a/plugins/vcbanimemonitor/__init__.py b/plugins/vcbanimemonitor/__init__.py new file mode 100644 index 0000000..581bb35 --- /dev/null +++ b/plugins/vcbanimemonitor/__init__.py @@ -0,0 +1,1089 @@ +import datetime +import re +import shutil +import threading +import time +import traceback +from pathlib import Path +from time import sleep +from typing import List, Tuple, Dict, Any, Optional +import pytz +import qbittorrentapi +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from watchdog.events import FileSystemEventHandler +from watchdog.observers import Observer +from watchdog.observers.polling import PollingObserver +from app import schemas +from app.chain.tmdb import TmdbChain +from app.chain.transfer import TransferChain +from app.core.config import settings +from app.core.context import MediaInfo +from app.core.event import eventmanager, Event +from app.core.metainfo import MetaInfoPath +from app.db.downloadhistory_oper import DownloadHistoryOper +from app.db.transferhistory_oper import TransferHistoryOper +from app.log import logger +from app.modules.qbittorrent import Qbittorrent +from app.plugins import _PluginBase +from app.plugins.vcbanimemonitor.remeta import ReMeta +from app.schemas import Notification, NotificationType, TransferInfo +from app.schemas.types import EventType, MediaType, SystemConfigKey +from app.utils.string import StringUtils +from app.utils.system import SystemUtils + +lock = threading.Lock() + + +class FileMonitorHandler(FileSystemEventHandler): + """ + 目录监控响应类 + """ + + def __init__(self, monpath: str, sync: Any, **kwargs): + super(FileMonitorHandler, self).__init__(**kwargs) + self._watch_path = monpath + self.sync = sync + + def on_created(self, event): + self.sync.event_handler(event=event, text="创建", + mon_path=self._watch_path, event_path=event.src_path) + + def on_moved(self, event): + self.sync.event_handler(event=event, text="移动", + mon_path=self._watch_path, event_path=event.dest_path) + + +class TorrentHandler(FileSystemEventHandler): + def __init__(self, monpath: str, sync: Any, **kwargs): + super(TorrentHandler, self).__init__(**kwargs) + self._watch_path = monpath + self.sync = sync + + def on_created(self, event): + self.sync.torrent_event(event=event, text="创建", + mon_path=self._watch_path) + + def on_moved(self, event): + self.sync.torrent_event(event=event, text="移动", + mon_path=self._watch_path) + + +class VCBAnimeMonitor(_PluginBase): + # 插件名称 + plugin_name = "整理VCB动漫压制组作品" + # 插件描述 + plugin_desc = "提高部分VCB-Studio作品的识别准确率,将VCB-Studio的作品统一转移到指定目录同时进行刮削整理" + # 插件图标 + plugin_icon = "vcbmonitor.png" + # 插件版本 + plugin_version = "1.6.6" + # 插件作者 + plugin_author = "pixel@qingwa" + # 作者主页 + author_url = "https://github.com/Pixel-LH" + # 插件配置项ID前缀 + plugin_config_prefix = "vcbanimemonitor_" + # 加载顺序 + plugin_order = 4 + # 可使用的用户级别 + auth_level = 2 + + # 私有属性 + _switch_ova = False + _high_mode = False + _torrents_path = None + new_save_path = None + qb = None + _scheduler = None + transferhis = None + downloadhis = None + transferchian = None + tmdbchain = None + _observer = [] + _enabled = False + _notify = False + _onlyonce = False + _cron = None + _size = 0 + # 模式 compatibility/fast + _mode = "fast" + # 转移方式 + _transfer_type = settings.TRANSFER_TYPE + _monitor_dirs = "" + _exclude_keywords = "" + _interval: int = 10 + # 存储源目录与目的目录关系 + _dirconf: Dict[str, Optional[Path]] = {} + # 存储源目录转移方式 + _transferconf: Dict[str, Optional[str]] = {} + _medias = {} + # 退出事件 + _event = threading.Event() + + def init_plugin(self, config: dict = None): + self.transferhis = TransferHistoryOper() + self.downloadhis = DownloadHistoryOper() + self.transferchian = TransferChain() + self.tmdbchain = TmdbChain() + # 清空配置 + self._dirconf = {} + self._transferconf = {} + + # 读取配置 + if config: + self._enabled = config.get("enabled") + self._notify = config.get("notify") + self._onlyonce = config.get("onlyonce") + self._mode = config.get("mode") + self._transfer_type = config.get("transfer_type") + self._monitor_dirs = config.get("monitor_dirs") or "" + self._exclude_keywords = config.get("exclude_keywords") or "" + self._interval = config.get("interval") or 10 + self._cron = config.get("cron") + self._size = config.get("size") or 0 + self._switch_ova = config.get("ova") + self._high_mode = config.get("high_mode") + self._torrents_path = config.get("torrents_path") or "" + + # 停止现有任务 + self.stop_service() + + if self._enabled or self._onlyonce: + # 定时服务管理器 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + # 追加入库消息统一发送服务 + self._scheduler.add_job(self.send_msg, trigger='interval', seconds=15) + self.qb = Qbittorrent() + + # 启用种子目录监控 + if self._torrents_path is not None and Path(self._torrents_path).exists() and self._enabled: + # 只取第一个目录作为新的保存 + if SystemUtils.is_windows(): + self.new_save_path = self._monitor_dirs.split(':')[0] + ":" + self._monitor_dirs.split(':')[1] + else: + self.new_save_path = self._monitor_dirs.split(':')[0] + print(self.new_save_path) + + try: + observer = Observer() + self._observer.append(observer) + observer.schedule(TorrentHandler(monpath=self._torrents_path, sync=self), path=self._torrents_path, + recursive=True) + observer.daemon = True + observer.start() + logger.info(f"{self._torrents_path} 的种子目录监控服务启动,开启监控新增的VCB-Studio种子文件") + except Exception as e: + logger.error(f"{self._torrents_path} 启动种子目录监控失败:{str(e)}") + else: + logger.info("种子目录为空,不转移qb中正在下载的VCB-Studio文件") + + # 读取目录配置 + monitor_dirs = self._monitor_dirs.split("\n") + if not monitor_dirs: + return + for mon_path in monitor_dirs: + # 格式源目录:目的目录 + if not mon_path: + continue + + # 自定义转移方式 + _transfer_type = self._transfer_type + if mon_path.count("#") == 1: + _transfer_type = mon_path.split("#")[1] + mon_path = mon_path.split("#")[0] + + # 存储目的目录 + if SystemUtils.is_windows(): + if mon_path.count(":") > 1: + paths = [mon_path.split(":")[0] + ":" + mon_path.split(":")[1], + mon_path.split(":")[2] + ":" + mon_path.split(":")[3]] + else: + paths = [mon_path] + else: + paths = mon_path.split(":") + + # 目的目录 + target_path = None + if len(paths) > 1: + mon_path = paths[0] + target_path = Path(paths[1]) + self._dirconf[mon_path] = target_path + else: + self._dirconf[mon_path] = None + + + # 转移方式 + self._transferconf[mon_path] = _transfer_type + + # 启用目录监控 + if self._enabled: + # 检查媒体库目录是不是下载目录的子目录 + try: + if target_path and target_path.is_relative_to(Path(mon_path)): + logger.warn(f"{target_path} 是监控目录 {mon_path} 的子目录,无法监控") + self.systemmessage.put(f"{target_path} 是下载目录 {mon_path} 的子目录,无法监控") + continue + except Exception as e: + logger.debug(str(e)) + pass + + try: + if self._mode == "compatibility": + # 兼容模式,目录同步性能降低且NAS不能休眠,但可以兼容挂载的远程共享目录如SMB + observer = PollingObserver(timeout=10) + else: + # 内部处理系统操作类型选择最优解 + observer = Observer(timeout=10) + self._observer.append(observer) + observer.schedule(FileMonitorHandler(mon_path, self), path=mon_path, recursive=True) + observer.daemon = True + observer.start() + logger.info(f"{mon_path} 的目录监控服务启动") + except Exception as e: + err_msg = str(e) + if "inotify" in err_msg and "reached" in err_msg: + logger.warn( + f"目录监控服务启动出现异常:{err_msg},请在宿主机上(不是docker容器内)执行以下命令并重启:" + + """ + echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf + echo fs.inotify.max_user_instances=524288 | sudo tee -a /etc/sysctl.conf + sudo sysctl -p + """) + else: + logger.error(f"{mon_path} 启动目录监控失败:{err_msg}") + self.systemmessage.put(f"{mon_path} 启动目录监控失败:{err_msg}") + + + # 运行一次定时服务 + if self._onlyonce: + logger.info("目录监控服务启动,立即运行一次") + self._scheduler.add_job(func=self.sync_all, trigger='date', + run_date=datetime.datetime.now( + tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3) + ) + # 关闭一次性开关 + self._onlyonce = False + # 保存配置 + self.__update_config() + + # 启动定时服务 + if self._scheduler.get_jobs(): + self._scheduler.print_jobs() + self._scheduler.start() + + def __update_config(self): + """ + 更新配置 + """ + self.update_config({ + "enabled": self._enabled, + "notify": self._notify, + "onlyonce": self._onlyonce, + "mode": self._mode, + "transfer_type": self._transfer_type, + "monitor_dirs": self._monitor_dirs, + "exclude_keywords": self._exclude_keywords, + "interval": self._interval, + "cron": self._cron, + "size": self._size, + "ova": self._switch_ova, + "high_mode": self._high_mode, + "torrents_path": self._torrents_path + }) + + def __save_data(self, key: str, value: Any): + self.save_data(key, value) + + def __get_data(self, key: str): + return self.get_data(key) + + + def sync_all(self): + """ + 立即运行一次,全量同步目录中所有文件 + """ + logger.info("开始全量同步监控目录 ...") + # 清空历史的ova记录 + self.plugindata.truncate() + + # 遍历所有监控目录 + for mon_path in self._dirconf.keys(): + # 遍历目录下所有文件 + for file_path in SystemUtils.list_files(Path(mon_path), settings.RMT_MEDIAEXT): + self.__handle_file(event_path=str(file_path), mon_path=mon_path) + + logger.info("全量同步监控目录完成!") + + def event_handler(self, event, mon_path: str, text: str, event_path: str): + """ + 处理文件变化 + :param event: 事件 + :param mon_path: 监控目录 + :param text: 事件描述 + :param event_path: 事件文件路径 + """ + if not event.is_directory: + # 文件发生变化 + logger.debug("文件%s:%s" % (text, event_path)) + self.__handle_file(event_path=event_path, mon_path=mon_path) + + def __handle_file(self, event_path: str, mon_path: str): + """ + 同步一个文件 + :param event_path: 事件文件路径 + :param mon_path: 监控目录 + """ + file_path = Path(event_path) + try: + if not file_path.exists(): + return + # 全程加锁 + with lock: + transfer_history = self.transferhis.get_by_src(event_path) + if transfer_history: + logger.debug("文件已处理过:%s" % event_path) + return + + # 回收站及隐藏的文件不处理 + if event_path.find('/@Recycle/') != -1 \ + or event_path.find('/#recycle/') != -1 \ + or event_path.find('/.') != -1 \ + or event_path.find('/@eaDir') != -1: + logger.debug(f"{event_path} 是回收站或隐藏的文件") + return + + # 命中过滤关键字不处理 + if self._exclude_keywords: + for keyword in self._exclude_keywords.split("\n"): + if keyword and re.findall(keyword, event_path): + logger.info(f"{event_path} 命中过滤关键字 {keyword},不处理") + return + + # 整理屏蔽词不处理 + transfer_exclude_words = self.systemconfig.get(SystemConfigKey.TransferExcludeWords) + if transfer_exclude_words: + for keyword in transfer_exclude_words: + if not keyword: + continue + if keyword and re.search(r"%s" % keyword, event_path, re.IGNORECASE): + logger.info(f"{event_path} 命中整理屏蔽词 {keyword},不处理") + return + + # 不是媒体文件不处理 + if file_path.suffix not in settings.RMT_MEDIAEXT: + logger.debug(f"{event_path} 不是媒体文件") + return + + # 查询历史记录,已转移的不处理 + if self.transferhis.get_by_src(str(file_path)): + logger.info(f"{file_path} 已整理过") + return + + # 元数据 + if file_path.parent.name == "SPs": + logger.warn("位于SPs目录下,跳过处理") + return + remeta = ReMeta(ova_switch=self._switch_ova, high_performance=self._high_mode) + file_meta = remeta.handel_file(file_path=file_path) + if file_meta: + if not file_meta.name: + logger.error(f"{file_path.name} 无法识别有效信息") + return + if remeta.is_special and not self._switch_ova: + logger.warn(f"{file_path.name} 为OVA资源,未开启OVA开关,不处理") + return + else: + ova_history_ep = 1 + if self.get_data(key=f"OVA_{file_meta.title}") is not None: + ova_history_ep = int(self.get_data(key=f"OVA_{file_meta.title}")) + self.save_data(key=f"OVA_{file_meta.title}", value=ova_history_ep + 1) + file_meta.begin_episode = ova_history_ep + 1 + else: + file_meta.begin_episode = ova_history_ep + self.save_data(key=f"OVA_{file_meta.title}", value=ova_history_ep + 1) + else: + return + + # 判断文件大小 + if self._size and float(self._size) > 0 and file_path.stat().st_size < float(self._size) * 1024 ** 3: + logger.info(f"{file_path} 文件大小小于监控文件大小,不处理") + return + + # 查询转移目的目录 + target: Path = self._dirconf.get(mon_path) + # 查询转移方式 + transfer_type = self._transferconf.get(mon_path) + + # 根据父路径获取下载历史 + download_history = None + # 按文件全路径查询 + download_file = self.downloadhis.get_file_by_fullpath(str(file_path)) + if download_file: + download_history = self.downloadhis.get_by_hash(download_file.download_hash) + + # 识别媒体信息 + mediainfo: MediaInfo = self.chain.recognize_media(meta=file_meta, + tmdbid=download_history.tmdbid if download_history else None) + + if not mediainfo: + logger.warn(f'未识别到媒体信息,标题:{file_meta.name}') + # self.save_data(plugin_id="vcbanimemonitor", key=file_meta.title, value="null") + # 新增转移成功历史记录 + his = self.transferhis.add_fail( + src_path=file_path, + mode=transfer_type, + meta=file_meta + ) + if self._notify: + self.chain.post_message(Notification( + mtype=NotificationType.Manual, + title=f"{file_path.name} 未识别到媒体信息,无法入库!\n" + f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别转移。" + )) + return + + # 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title + if not settings.SCRAP_FOLLOW_TMDB: + transfer_history = self.transferhis.get_by_type_tmdbid(tmdbid=mediainfo.tmdb_id, + mtype=mediainfo.type.value) + if transfer_history: + mediainfo.title = transfer_history.title + logger.info(f"{file_path.name} 识别为:{mediainfo.type.value} {mediainfo.title_year}") + + # 更新媒体图片 + self.chain.obtain_images(mediainfo=mediainfo) + + # 获取集数据 + if mediainfo.type == MediaType.TV: + episodes_info = self.tmdbchain.tmdb_episodes(tmdbid=mediainfo.tmdb_id, + season=file_meta.begin_season or 1) + else: + episodes_info = None + + # 获取下载Hash + download_hash = None + if download_history: + download_hash = download_history.download_hash + + # 转移 + transferinfo: TransferInfo = self.chain.transfer(mediainfo=mediainfo, + path=file_path, + transfer_type=transfer_type, + target=target, + meta=file_meta, + episodes_info=episodes_info) + + if not transferinfo: + logger.error("文件转移模块运行失败") + return + + if not transferinfo.success: + # 转移失败 + logger.warn(f"{file_path.name} 入库失败:{transferinfo.message}") + # 新增转移失败历史记录 + self.transferhis.add_fail( + src_path=file_path, + mode=transfer_type, + download_hash=download_hash, + meta=file_meta, + mediainfo=mediainfo, + transferinfo=transferinfo + ) + if self._notify: + self.chain.post_message(Notification( + mtype=NotificationType.Manual, + title=f"{mediainfo.title_year}{file_meta.season_episode} 入库失败!", + text=f"原因:{transferinfo.message or '未知'}", + image=mediainfo.get_message_image() + )) + return + + # 新增转移成功历史记录 + self.transferhis.add_success( + src_path=file_path, + mode=transfer_type, + download_hash=download_hash, + meta=file_meta, + mediainfo=mediainfo, + transferinfo=transferinfo + ) + + # 刮削单个文件 + if settings.SCRAP_METADATA: + self.chain.scrape_metadata(path=transferinfo.target_path, + mediainfo=mediainfo, + transfer_type=transfer_type) + + """ + { + "title_year season": { + "files": [ + { + "path":, + "mediainfo":, + "file_meta":, + "transferinfo": + } + ], + "time": "2023-08-24 23:23:23.332" + } + } + """ + # 发送消息汇总 + media_list = self._medias.get(mediainfo.title_year + " " + file_meta.season) or {} + if media_list: + media_files = media_list.get("files") or [] + if media_files: + file_exists = False + for file in media_files: + if str(file_path) == file.get("path"): + file_exists = True + break + if not file_exists: + media_files.append({ + "path": str(file_path), + "mediainfo": mediainfo, + "file_meta": file_meta, + "transferinfo": transferinfo + }) + else: + media_files = [ + { + "path": str(file_path), + "mediainfo": mediainfo, + "file_meta": file_meta, + "transferinfo": transferinfo + } + ] + media_list = { + "files": media_files, + "time": datetime.datetime.now() + } + else: + media_list = { + "files": [ + { + "path": str(file_path), + "mediainfo": mediainfo, + "file_meta": file_meta, + "transferinfo": transferinfo + } + ], + "time": datetime.datetime.now() + } + self._medias[mediainfo.title_year + " " + file_meta.season] = media_list + + # 广播事件 + self.eventmanager.send_event(EventType.TransferComplete, { + 'meta': file_meta, + 'mediainfo': mediainfo, + 'transferinfo': transferinfo + }) + + # 移动模式删除空目录 + if transfer_type == "move": + for file_dir in file_path.parents: + if len(str(file_dir)) <= len(str(Path(mon_path))): + # 重要,删除到监控目录为止 + break + files = SystemUtils.list_files(file_dir, settings.RMT_MEDIAEXT) + if not files: + logger.warn(f"移动模式,删除空目录:{file_dir}") + shutil.rmtree(file_dir, ignore_errors=True) + + except Exception as e: + logger.error("目录监控发生错误:%s - %s" % (str(e), traceback.format_exc())) + + def torrent_event(self, event, mon_path: str, text: str): + """ + 处理种子文件 + :param mon_path: 种子目录 + """ + evc_path = Path(event.src_path) + if not event.is_directory and (evc_path.suffix == ".torrent" or str(evc_path).split('.')[1] == "torrent"): + # 文件发生变化 + logger.debug("文件%s:%s" % (text, mon_path)) + self.__handle_torrent(torrent_path=self._torrents_path) + else: + logger.debug("不是种子文件:%s" % mon_path) + + def __handle_torrent(self, torrent_path: str): + torrent_path = Path(torrent_path) + try: + if not torrent_path.exists(): + return + # 只处理刚刚添加的种子也就是获取正在下载的种子 + logger.info(f"开始转移qb中正在下载的VCB资源,转移目录为:{self.new_save_path}") + # 等待种子文件下载完成 + time.sleep(5) + with lock: + torrents = self.qb.get_downloading_torrents() + for torrent in torrents: + if "VCB-Studio" in torrent.name: + # 原本存在的暂停的种子不处理 + if torrent.state_enum == qbittorrentapi.TorrentState.PAUSED_DOWNLOAD: + continue + if torrent.save_path == self.new_save_path: + continue + torrent.pause() + torrent.set_save_path(save_path=self.new_save_path) + torrent.resume() + else: + continue + except qbittorrentapi.exceptions.APIError as e: + logger.error(f"VCB辅助整理模块转移qb文件移动失败:{e}") + + + def send_msg(self): + """ + 定时检查是否有媒体处理完,发送统一消息 + """ + if not self._medias or not self._medias.keys(): + return + + # 遍历检查是否已刮削完,发送消息 + for medis_title_year_season in list(self._medias.keys()): + media_list = self._medias.get(medis_title_year_season) + logger.info(f"开始处理媒体 {medis_title_year_season} 消息") + + if not media_list: + continue + + # 获取最后更新时间 + last_update_time = media_list.get("time") + media_files = media_list.get("files") + if not last_update_time or not media_files: + continue + + transferinfo = media_files[0].get("transferinfo") + file_meta = media_files[0].get("file_meta") + mediainfo = media_files[0].get("mediainfo") + # 判断剧集最后更新时间距现在是已超过10秒或者电影,发送消息 + if (datetime.datetime.now() - last_update_time).total_seconds() > int(self._interval) \ + or mediainfo.type == MediaType.MOVIE: + # 发送通知 + if self._notify: + + # 汇总处理文件总大小 + total_size = 0 + file_count = 0 + + # 剧集汇总 + episodes = [] + for file in media_files: + transferinfo = file.get("transferinfo") + total_size += transferinfo.total_size + file_count += 1 + + file_meta = file.get("file_meta") + if file_meta and file_meta.begin_episode: + episodes.append(file_meta.begin_episode) + + transferinfo.total_size = total_size + # 汇总处理文件数量 + transferinfo.file_count = file_count + + # 剧集季集信息 S01 E01-E04 || S01 E01、E02、E04 + season_episode = None + # 处理文件多,说明是剧集,显示季入库消息 + if mediainfo.type == MediaType.TV: + # 季集文本 + season_episode = f"{file_meta.season} {StringUtils.format_ep(episodes)}" + # 发送消息 + self.transferchian.send_transfer_message(meta=file_meta, + mediainfo=mediainfo, + transferinfo=transferinfo, + season_episode=season_episode) + # 发送完消息,移出key + del self._medias[medis_title_year_season] + continue + + def get_state(self) -> bool: + return self._enabled + + def get_service(self) -> List[Dict[str, Any]]: + """ + 注册插件公共服务 + [{ + "id": "服务ID", + "name": "服务名称", + "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()", + "func": self.xxx, + "kwargs": {} # 定时器参数 + }] + """ + if self._enabled and self._cron: + return [{ + "id": "vcbanimemonitor", + "name": "vcbanimemonitor", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self.sync_all, + "kwargs": {} + }] + return [] + + def sync(self) -> schemas.Response: + """ + API调用目录同步 + """ + self.sync_all() + return schemas.Response(success=True) + + def get_api(self) -> List[Dict[str, Any]]: + pass + + def get_command(self) -> List[Dict[str, Any]]: + pass + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '发送通知', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'ova', + 'label': '开启识别OVA/OAD文件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'high_mode', + 'label': '高性能处理模式', + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'mode', + 'label': '监控模式', + 'items': [ + {'title': '兼容模式', 'value': 'compatibility'}, + {'title': '性能模式', 'value': 'fast'} + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'transfer_type', + 'label': '转移方式', + 'items': [ + {'title': '移动', 'value': 'move'}, + {'title': '复制', 'value': 'copy'}, + {'title': '硬链接', 'value': 'link'}, + {'title': '软链接', 'value': 'softlink'}, + {'title': 'Rclone复制', 'value': 'rclone_copy'}, + {'title': 'Rclone移动', 'value': 'rclone_move'} + ] + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'interval', + 'label': '入库消息延迟', + 'placeholder': '10' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '定时全量同步周期', + 'placeholder': '5位cron表达式,留空关闭' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'size', + 'label': '监控文件大小(GB)', + 'placeholder': '0' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'torrents_path', + 'label': '监控种子目录', + 'placeholder': '填入路径代表启用' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'monitor_dirs', + 'label': '监控目录', + 'rows': 5, + 'placeholder': '每一行一个目录,支持以下几种配置方式,转移方式支持 move、copy、link、softlink、rclone_copy、rclone_move:\n' + '监控目录\n' + '监控目录#转移方式\n' + '监控目录:转移目的目录\n' + '监控目录:转移目的目录#转移方式' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTextarea', + 'props': { + 'model': 'exclude_keywords', + 'label': '排除关键词', + 'rows': 2, + 'placeholder': '每一行一个关键词' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '核心用法与目录同步插件相同,不同点在于只识别处理VCB-Studio资源,\n' + '不处理SPs目录下的文件,OVA/OAD集数根据入库顺序累加命名,不保证与TMDB集数匹配' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '最佳使用方式:监控目录单独设置一个作为保存VCB-Studio资源的目录,\n' + '填入监控种子目录,开启后会将正在QB(仅支持QB)下载器内的VCB-Studio资源转移到监控目录实现自动整理(' + '仅支持第一个监控目录),\n' + '监控种子目录为空则不转移文件' + } + } + ] + } + ] + }, + ] + }, + ], { + "enabled": False, + "notify": False, + "onlyonce": False, + "mode": "fast", + "transfer_type": settings.TRANSFER_TYPE, + "monitor_dirs": "", + "exclude_keywords": "", + "interval": 10, + "cron": "", + "size": 0, + "ova": False, + "high_mode": False, + "torrents_path": "", + } + + def get_page(self) -> List[dict]: + pass + + def stop_service(self): + """ + 退出插件 + """ + if self._observer: + for observer in self._observer: + try: + observer.stop() + observer.join() + except Exception as e: + print(str(e)) + self._observer = [] + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._event.set() + self._scheduler.shutdown() + self._event.clear() + self._scheduler = None diff --git a/plugins/vcbanimemonitor/remeta.py b/plugins/vcbanimemonitor/remeta.py new file mode 100644 index 0000000..ef70266 --- /dev/null +++ b/plugins/vcbanimemonitor/remeta.py @@ -0,0 +1,188 @@ +import concurrent +import re +from pathlib import Path +from typing import List +import threading +import roman +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 + + +class ReMeta(): + # 解析之后的标题: + title: str = None + # 识别出来的集数 + ep: int = None + # 识别出来的季度 + season: int = None + # 特殊季识别开关 + is_special = False + # OVA/OAD识别开关 + ova_switch: bool = False + # 高性能处理开关 + high_performance = False + + season_patterns = [ + {"pattern": re.compile(r"S(\d+)$"), "group": 1}, + {"pattern": re.compile(r"(\d+)$"), "group": 1}, + {"pattern": re.compile(r"(\d+)(st|nd|rd|th)?\s*[Ss][Ee][Aa][Ss][Oo][Nn]"), "group": 1}, + {"pattern": re.compile(r"(.*) ?\s*[Ss][Ee][Aa][Ss][Oo][Nn] (\d+)"), "group": 2}, + {"pattern": re.compile(r"\s(II|III|IV|V|VI|VII|VIII|IX|X)$"), "group": "1"} + ] + episode_patterns = [ + {"pattern": re.compile(r"\[(\d+)\((\d+)\)]"), "group": 2}, + {"pattern": re.compile(r"\[(\d+)]"), "group": 1}, + {"pattern": re.compile(r'\[(\d+)v\d+]'), "group": 1}, + + ] + _ova_patterns = [re.compile(r"\[.*?(OVA|OAD).*?]"), + 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') + ] + # 自定义添加的季度正则表达式 + _custom_season_patterns = [] + + def __init__(self, ova_switch: bool = False,high_performance: bool = False): + self.ova_switch = ova_switch + self.high_performance = high_performance + + def handel_file(self, file_path: Path): + meta = MetaInfoPath(file_path) + self.title = meta.title + if 'VCB-Studio' not in meta.title: + logger.warn("不属于VCB的作品,不处理!") + return None + if meta.title.count("[") != 4 and meta.title.count("]") != 4: + # 可能是电影,电影只有三组[],因此去除所有[]后只剩下电影名 + logger.warn("不符合VCB-Studio的剧集命名规范,跳过剧集模块处理!交给默认处理逻辑") + meta.title = re.sub(r'\[.*?\]', '', meta.title).strip() + meta.en_name = meta.title + return meta + split_title: List[str] | None = self.split_season_ep(self.title) + if split_title: + self.handle_season_ep(split_title) + if self.season is not None: + meta.begin_season = self.season + else: + logger.warn("未识别出季度,默认处理逻辑返回第一季") + if self.ep is not None: + meta.begin_episode = self.ep + else: + logger.warn("未识别出集数,默认处理逻辑返回第一集") + meta.title = self.title + meta.en_name = self.title + logger.info(f"识别出季度为{self.season},集数为{self.ep},标题为:{self.title}") + + return meta + + # 分离季度部分和集数部分 + def split_season_ep(self, pre_title: str): + split_ep = re.findall(r"(\[.*?])", pre_title)[1] + if not split_ep: + logger.warn("未识别出集数位置信息,结束识别!") + return None + split_title = re.sub(r"\[.*?\]", "", pre_title).strip() + logger.info(f"分离出包含季度的部分:{split_title} \n 分离出包含集数的部分: {split_ep}") + return [split_title, split_ep] + + def handle_season_ep(self, title: List[str]): + if self.high_performance: + with concurrent.futures.ProcessPoolExecutor(max_workers=2) as executor: + title_season_result = executor.submit(self.handle_season, title[0]) + ep_result = executor.submit(self.re_ep, title[1],) + try: + title_season_result = title_season_result.result() # Blocks until the task is complete. + ep_result = ep_result.result() # Blocks until the task is complete. + except Exception as exc: + print('Generated an exception: %s' % exc) + else: + title_season_result = self.handle_season(title[0]) + ep_result = self.re_ep(title[1]) + self.title = title_season_result["title"] + is_ova = ep_result["is_ova"] + if ep_result["ep"] is not None: + self.ep = ep_result["ep"] + if title_season_result["season"]: + self.season = title_season_result["season"] + if is_ova: + self.season = 0 + self.is_special = True + + # 处理季度 + def handle_season(self, pre_title: str) -> dict: + title_season = {"title": pre_title, "season": 1} + for season_pattern in self.season_patterns: + pattern = season_pattern["pattern"] + group = season_pattern["group"] + match = pattern.search(pre_title) + if match: + if type(group) == str: + title_season["season"] = int(roman.fromRoman(match.group(int(group)))) + title_season["title"] = re.sub(pattern, "", pre_title).strip() + else: + title_season["season"] = int(match.group(group)) + title_season["title"] = re.sub(pattern, "", pre_title).strip() + return title_season + for final_season_pattern in self.final_season_patterns: + match = final_season_pattern.search(pre_title) + if match: + logger.info("识别出最终季度,开始处理!") + title_season["title"] = re.sub(final_season_pattern, "", pre_title).strip() + title_season["season"] = self.handle_final_season(title=pre_title) + break + return title_season + + # 处理存在“Final”字样命名的季度 + def handle_final_season(self, title: str) -> int | None: + medias = MediaChain().search(title=title)[1] + if not medias: + logger.warn("没有找到对应的媒体信息!") + return + # 根据类型进行过滤,只取类型是电视剧和动漫的media + medias = [media for media in medias if media.type == MediaType.TV] + if not medias: + logger.warn("没有找到动漫或电视剧的媒体信息!") + return + media = sorted(medias, key=lambda x: x.popularity, reverse=True)[0] + media_tmdb_id = media.tmdb_id + seasons_info = TmdbChain().tmdb_seasons(tmdbid=media_tmdb_id) + if seasons_info is None: + logger.warn("无法获取最终季") + else: + logger.info(f"获取到最终季,季度为{len(seasons_info)}") + return len(seasons_info) + + def re_ep(self, ep_title: str,) -> dict: + """ + # 集数匹配处理模块 + :param ep_title: 从title解析出的集数,ep_title固定格式[集数] + 1.先判断是否存在OVA/OAD,形如:[OVA],[12(OVA)],[12.5]这种形式都是属于OVA/OAD,交给处理OVA模块处理 + 2.集数通常有两种情况一种:[12]直接性,另一种:[12(24)],这一种应该去括号内的为集数 + :return: 集数(int) + """ + ep_ova = {"ep": None, "is_ova": False} + for ova_pattern in self._ova_patterns: + match = ova_pattern.search(ep_title) + if match: + ep_ova["is_ova"] = True + ep_ova["ep"] = 1 + return ep_ova + for ep_pattern in self.episode_patterns: + pattern = ep_pattern["pattern"] + group = ep_pattern["group"] + match = pattern.search(ep_title) + if match: + ep_ova["ep"] = int(match.group(group)) + return ep_ova + return ep_ova + + + + diff --git a/plugins/vcbanimemonitor/requirements.txt b/plugins/vcbanimemonitor/requirements.txt new file mode 100644 index 0000000..7001735 --- /dev/null +++ b/plugins/vcbanimemonitor/requirements.txt @@ -0,0 +1 @@ +roman~=4.1 \ No newline at end of file