diff --git a/package.json b/package.json index 675599b..16ac916 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,13 @@ "name": "站点自动签到", "description": "自动模拟登录、签到站点。", "labels": "站点", - "version": "2.4.1", + "version": "2.4.2", "icon": "signin.png", "author": "thsrite", "level": 2, "v2": true, "history": { + "v2.4.2": "修复PT时间签到失败问题", "v2.4.1": "修复海胆签到失败问题", "v2.4": "适配m-team Api地址变化", "v2.3.2": "修复YemaPT登录失败,支持YemaPT自动签到", @@ -34,11 +35,12 @@ "name": "站点数据统计", "description": "自动统计和展示站点数据。", "labels": "站点,仪表板", - "version": "4.0", + "version": "4.0.1", "icon": "statistic.png", "author": "lightolly", "level": 2, "history": { + "v4.0.1": "修复PTT的魔力值统计", "v4.0": "修复插件数据页异常", "v3.9.3": "修复PTT的用户等级统计", "v3.9.2": "修复YemaPT的上传下载统计错误", @@ -170,12 +172,13 @@ "name": "自定义Hosts", "description": "修改系统hosts文件,加速网络访问。", "labels": "网络", - "version": "1.1", + "version": "1.2", "icon": "hosts.png", "author": "thsrite", "level": 1, "v2": true, "history": { + "v1.2": "支持写入注释", "v1.1": "关闭插件时自动恢复系统hosts" } }, @@ -225,12 +228,13 @@ "name": "媒体库服务器通知", "description": "发送Emby/Jellyfin/Plex服务器的播放、入库等通知消息。", "labels": "消息通知,媒体库", - "version": "1.2", + "version": "1.3", "icon": "mediaplay.png", "author": "jxxghp", "level": 1, "v2": true, "history": { + "v1.3": "兼容处理Emby部分客户端暂停重复推送停止播放webhook的场景", "v1.2": "播放通知增加超链接跳转(需要v1.9.4+)" } }, @@ -298,12 +302,14 @@ "name": "IYUU自动辅种", "description": "基于IYUU官方Api实现自动辅种。", "labels": "做种,IYUU", - "version": "1.9.3", + "version": "1.9.5", "icon": "IYUU.png", "author": "jxxghp", "level": 2, "v2": true, "history": { + "v1.9.5": "Revert qBittorrent跳检之后自动开始", + "v1.9.4": "修复qBittorrent辅种后不会自动开始做种", "v1.9.3": "修复Monika因缺少rsskey,种子下载失败的问题", "v1.9.2": "适配馒头使用API下载种子", "v1.9.1": "支持自定义辅种的种子分类", @@ -332,14 +338,17 @@ }, "VCBAnimeMonitor": { "name": "整理VCB动漫压制组作品", - "description": "提高部分VCB-Studio作品的识别准确率,将VCB-Studio的作品统一转移到指定目录同时进行刮削整理", + "description": "一款辅助整理&提高识别VCB-Stuido动漫压制组作品的插件", "labels": "文件整理,识别", - "version": "1.8", + "version": "1.8.2.1", "icon": "vcbmonitor.png", "author": "pixel@qingwa", "level": 2, "v2": true, "history": { + "v1.8.2.1": "修复日志输出&同步目录监控插件功能", + "v1.8.2": "提高识别率", + "v1.8.1": "重构插件,测试版", "v1.8": "增加了元数据刮削开关,升级后需要手动打开,否则默认不刮削", "v1.7.1": "修复偶尔安装失败问题" } @@ -348,12 +357,13 @@ "name": "自动转移做种", "description": "定期转移下载器中的做种任务到另一个下载器。", "labels": "做种", - "version": "1.4", + "version": "1.5", "icon": "seed.png", "author": "jxxghp", "level": 2, "v2": true, "history": { + "v1.5": "修复在转移时只保留了第一个tracker,导致红种问题。此修复确保保留所有的tracker,以提高在不同网络条件下的可达性。", "v1.4": "支持自动删除源下载器在目的下载器中存在的种子" } }, @@ -376,22 +386,26 @@ "name": "下载器文件同步", "description": "同步下载器的文件信息到数据库,删除文件时联动删除下载任务。", "labels": "下载管理", - "version": "1.1", + "version": "1.1.1", "icon": "Youtube-dl_A.png", "author": "thsrite", "level": 1, - "v2": true + "v2": true, + "history": { + "v1.1.1": "修复时区问题导致的上次同步后8h内的种子不同步的问题" + } }, "BrushFlow": { "name": "站点刷流", "description": "自动托管刷流,将会提高对应站点的访问频率。", "labels": "刷流,仪表板", - "version": "3.6", + "version": "3.7", "icon": "brush.jpg", "author": "jxxghp,InfinityPacer", "level": 2, "v2": true, "history": { + "v3.7": "下载数量调整为仅获取刷流标签种子并修复了一些细节问题", "v3.6": "优化检查服务中的时间管控", "v3.5": "移除「删种排除MoviePilot任务」配置项(请使用「删除排除标签」替代),完善刷流任务触发插件事件相关逻辑(联动H&R助手)", "v3.4": "移除「记录更多日志」配置项并调整为DEBUG日志,支持「删除排除标签」配置项,增加刷流任务时支持触发插件事件(联动H&R助手)", diff --git a/plugins/autosignin/__init__.py b/plugins/autosignin/__init__.py index a3d21d0..d81ee4d 100644 --- a/plugins/autosignin/__init__.py +++ b/plugins/autosignin/__init__.py @@ -38,7 +38,7 @@ class AutoSignIn(_PluginBase): # 插件图标 plugin_icon = "signin.png" # 插件版本 - plugin_version = "2.4.1" + plugin_version = "2.4.2" # 插件作者 plugin_author = "thsrite" # 作者主页 diff --git a/plugins/autosignin/sites/pttime.py b/plugins/autosignin/sites/pttime.py new file mode 100644 index 0000000..6c766d2 --- /dev/null +++ b/plugins/autosignin/sites/pttime.py @@ -0,0 +1,64 @@ +from typing import Tuple + +from ruamel.yaml import CommentedMap + +from app.log import logger +from app.plugins.autosignin.sites import _ISiteSigninHandler +from app.utils.string import StringUtils + + +class PTTime(_ISiteSigninHandler): + """ + PT时间签到 + """ + # 匹配的站点Url,每一个实现类都需要设置为自己的站点Url + site_url = "pttime.org" + + # 签到成功 + _succeed_regex = ['签到成功'] + + @classmethod + def match(cls, url: str) -> bool: + """ + 根据站点Url判断是否匹配当前站点签到类,大部分情况使用默认实现即可 + :param url: 站点Url + :return: 是否匹配,如匹配则会调用该类的signin方法 + """ + return True if StringUtils.url_equal(url, cls.site_url) else False + + def signin(self, site_info: CommentedMap) -> Tuple[bool, str]: + """ + 执行签到操作 + :param site_info: 站点信息,含有站点Url、站点Cookie、UA等信息 + :return: 签到结果信息 + """ + site = site_info.get("name") + site_cookie = site_info.get("cookie") + ua = site_info.get("ua") + proxy = site_info.get("proxy") + render = site_info.get("render") + + # 签到 + # 签到返回:签到成功 + html_text = self.get_page_source(url='https://www.pttime.org/attendance.php', + cookie=site_cookie, + ua=ua, + proxy=proxy, + render=render) + + if not html_text: + logger.error(f"{site} 签到失败,请检查站点连通性") + return False, '签到失败,请检查站点连通性' + + if "login.php" in html_text: + logger.error(f"{site} 签到失败,Cookie已失效") + return False, '签到失败,Cookie已失效' + + sign_status = self.sign_in_result(html_res=html_text, + regexs=self._succeed_regex) + if sign_status: + logger.info(f"{site} 签到成功") + return True, '签到成功' + + logger.error(f"{site} 签到失败,签到接口返回 {html_text}") + return False, '签到失败' diff --git a/plugins/brushflow/__init__.py b/plugins/brushflow/__init__.py index 656a013..e3d45b6 100644 --- a/plugins/brushflow/__init__.py +++ b/plugins/brushflow/__init__.py @@ -257,7 +257,7 @@ class BrushFlow(_PluginBase): # 插件图标 plugin_icon = "brush.jpg" # 插件版本 - plugin_version = "3.6" + plugin_version = "3.7" # 插件作者 plugin_author = "jxxghp,InfinityPacer" # 作者主页 @@ -3641,12 +3641,21 @@ class BrushFlow(_PluginBase): """ 获取正在下载的任务数量 """ - brush_config = self.__get_brush_config() - downloader = self.__get_downloader(brush_config.downloader) - if not downloader: + try: + brush_config = self.__get_brush_config() + downloader = self.__get_downloader(brush_config.downloader) + if not downloader: + return 0 + + torrents = downloader.get_downloading_torrents(tags=brush_config.brush_tag) + if torrents is None: + logger.warn("获取下载数量失败,可能是下载器连接发生异常") + return 0 + + return len(torrents) + except Exception as e: + logger.error(f"获取下载数量发生异常: {e}") return 0 - torrents = downloader.get_downloading_torrents() - return len(torrents) or 0 @staticmethod def __get_pubminutes(pubdate: str) -> float: diff --git a/plugins/customhosts/__init__.py b/plugins/customhosts/__init__.py index 849159f..ae0fe6a 100644 --- a/plugins/customhosts/__init__.py +++ b/plugins/customhosts/__init__.py @@ -18,7 +18,7 @@ class CustomHosts(_PluginBase): # 插件图标 plugin_icon = "hosts.png" # 插件版本 - plugin_version = "1.1" + plugin_version = "1.2" # 插件作者 plugin_author = "thsrite" # 作者主页 @@ -235,6 +235,12 @@ class CustomHosts(_PluginBase): for host in hosts: if not host: continue + host = host.strip() + if host.startswith('#'): # 检查是否为注释行 + host_entry = HostsEntry(entry_type='comment', comment=host) + new_entrys.append(host_entry) + continue + host_arr = str(host).split() try: host_entry = HostsEntry(entry_type='ipv4' if IpUtils.is_ipv4(str(host_arr[0])) else 'ipv6', diff --git a/plugins/dirmonitor/__init__.py b/plugins/dirmonitor/__init__.py index 2159523..0f0c957 100644 --- a/plugins/dirmonitor/__init__.py +++ b/plugins/dirmonitor/__init__.py @@ -330,7 +330,7 @@ class DirMonitor(_PluginBase): return # 不是媒体文件不处理 - if file_path.suffix not in settings.RMT_MEDIAEXT: + if file_path.suffix.casefold() not in map(str.casefold, settings.RMT_MEDIAEXT): logger.debug(f"{event_path} 不是媒体文件") return diff --git a/plugins/iyuuautoseed/__init__.py b/plugins/iyuuautoseed/__init__.py index 187a8c2..64e4f2f 100644 --- a/plugins/iyuuautoseed/__init__.py +++ b/plugins/iyuuautoseed/__init__.py @@ -34,7 +34,7 @@ class IYUUAutoSeed(_PluginBase): # 插件图标 plugin_icon = "IYUU.png" # 插件版本 - plugin_version = "1.9.3" + plugin_version = "1.9.5" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -957,6 +957,10 @@ class IYUUAutoSeed(_PluginBase): if self._skipverify: # 跳过校验 logger.info(f"{download_id} 跳过校验,请自行检查...") + # 请注意这里是故意不自动开始的 + # 跳过校验存在直接失败、种子目录相同文件不同等异常情况 + # 必须要用户自行二次确认之后才能开始做种 + # 否则会出现反复下载刷掉分享率、做假种的情况 else: # 追加校验任务 logger.info(f"添加校验检查任务:{download_id} ...") diff --git a/plugins/mediaservermsg/__init__.py b/plugins/mediaservermsg/__init__.py index 125eabd..315f1d1 100644 --- a/plugins/mediaservermsg/__init__.py +++ b/plugins/mediaservermsg/__init__.py @@ -20,7 +20,7 @@ class MediaServerMsg(_PluginBase): # 插件图标 plugin_icon = "mediaplay.png" # 插件版本 - plugin_version = "1.2" + plugin_version = "1.3" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -40,6 +40,7 @@ class MediaServerMsg(_PluginBase): # 私有属性 _enabled = False _types = [] + _webhook_msg_keys = {} # 拼装消息内容 _webhook_actions = { @@ -198,6 +199,13 @@ class MediaServerMsg(_PluginBase): 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}" @@ -255,10 +263,31 @@ class MediaServerMsg(_PluginBase): 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): """ 退出插件 diff --git a/plugins/pushplusmsg/__init__.py b/plugins/pushplusmsg/__init__.py index 3d4485e..b5e287b 100644 --- a/plugins/pushplusmsg/__init__.py +++ b/plugins/pushplusmsg/__init__.py @@ -11,11 +11,11 @@ class PushPlusMsg(_PluginBase): # 插件名称 plugin_name = "PushPlus消息推送" # 插件描述 - plugin_desc = "支持使用PushPlus发送消息通知。" + plugin_desc = "支持使用PushPlus发送消息通知(需实名认证)。" # 插件图标 plugin_icon = "Pushplus_A.png" # 插件版本 - plugin_version = "1.0" + plugin_version = "1.1" # 插件作者 plugin_author = "cheng" # 作者主页 @@ -128,6 +128,27 @@ class PushPlusMsg(_PluginBase): } ] }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal', + 'text': '由于pushplus规则更新,没有实名认证的用户无法发送消息,所以需要用户自己去官网进行认证。官网地址:https://www.pushplus.plus' + } + } + ] + } + ] + } ] } ], { diff --git a/plugins/sitestatistic/__init__.py b/plugins/sitestatistic/__init__.py index 2469e00..9760734 100644 --- a/plugins/sitestatistic/__init__.py +++ b/plugins/sitestatistic/__init__.py @@ -42,7 +42,7 @@ class SiteStatistic(_PluginBase): # 插件图标 plugin_icon = "statistic.png" # 插件版本 - plugin_version = "4.0" + plugin_version = "4.0.1" # 插件作者 plugin_author = "lightolly" # 作者主页 diff --git a/plugins/sitestatistic/siteuserinfo/nexus_php.py b/plugins/sitestatistic/siteuserinfo/nexus_php.py index c1deced..13b357b 100644 --- a/plugins/sitestatistic/siteuserinfo/nexus_php.py +++ b/plugins/sitestatistic/siteuserinfo/nexus_php.py @@ -118,7 +118,7 @@ class NexusPhpSiteUserInfo(ISiteUserInfo): if bonus_match and bonus_match.group(1).strip(): self.bonus = StringUtils.str_float(bonus_match.group(1)) return - bonus_match = re.search(r"mybonus.[\[\]::<>/a-zA-Z_\-=\"'\s#;.(使用魔力值豆]+\s*([\d,.]+)[<()&\s]", html_text) + bonus_match = re.search(r"mybonus.[\[\]::<>/a-zA-Z_\-=\"'\s#;.(使用&说明魔力值豆]+\s*([\d,.]+)[\[<()&\s]", html_text) try: if bonus_match and bonus_match.group(1).strip(): self.bonus = StringUtils.str_float(bonus_match.group(1)) diff --git a/plugins/syncdownloadfiles/__init__.py b/plugins/syncdownloadfiles/__init__.py index 32be61e..15c8a42 100644 --- a/plugins/syncdownloadfiles/__init__.py +++ b/plugins/syncdownloadfiles/__init__.py @@ -22,7 +22,7 @@ class SyncDownloadFiles(_PluginBase): # 插件图标 plugin_icon = "Youtube-dl_A.png" # 插件版本 - plugin_version = "1.1" + plugin_version = "1.1.1" # 插件作者 plugin_author = "thsrite" # 作者主页 @@ -265,7 +265,7 @@ class SyncDownloadFiles(_PluginBase): if last_sync_time: # 获取种子时间 if dl_tpe == "qbittorrent": - torrent_date = time.gmtime(torrent.get("added_on")) # 将时间戳转换为时间元组 + 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 diff --git a/plugins/torrenttransfer/__init__.py b/plugins/torrenttransfer/__init__.py index c304a59..fa22714 100644 --- a/plugins/torrenttransfer/__init__.py +++ b/plugins/torrenttransfer/__init__.py @@ -27,7 +27,7 @@ class TorrentTransfer(_PluginBase): # 插件图标 plugin_icon = "seed.png" # 插件版本 - plugin_version = "1.4" + plugin_version = "1.5" # 插件作者 plugin_author = "jxxghp" # 作者主页 @@ -724,6 +724,9 @@ class TorrentTransfer(_PluginBase): 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" # 编码并保存到临时文件 diff --git a/plugins/vcbanimemonitor/__init__.py b/plugins/vcbanimemonitor/__init__.py index de3edd1..f81f257 100644 --- a/plugins/vcbanimemonitor/__init__.py +++ b/plugins/vcbanimemonitor/__init__.py @@ -15,12 +15,11 @@ from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer from watchdog.observers.polling import PollingObserver from app import schemas +from app.chain.media import MediaChain 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 @@ -73,11 +72,11 @@ class VCBAnimeMonitor(_PluginBase): # 插件名称 plugin_name = "整理VCB动漫压制组作品" # 插件描述 - plugin_desc = "提高部分VCB-Studio作品的识别准确率,将VCB-Studio的作品统一转移到指定目录同时进行刮削整理" + plugin_desc = "一款辅助整理&提高识别VCB-Stuido动漫压制组作品的插件" # 插件图标 plugin_icon = "vcbmonitor.png" # 插件版本 - plugin_version = "1.8" + plugin_version = "1.8.2.2" # 插件作者 plugin_author = "pixel@qingwa" # 作者主页 @@ -91,7 +90,6 @@ class VCBAnimeMonitor(_PluginBase): # 私有属性 _switch_ova = False - _high_mode = False _torrents_path = None new_save_path = None qb = None @@ -100,6 +98,7 @@ class VCBAnimeMonitor(_PluginBase): downloadhis = None transferchian = None tmdbchain = None + mediaChain = None _observer = [] _enabled = False _notify = False @@ -126,6 +125,7 @@ class VCBAnimeMonitor(_PluginBase): self.transferhis = TransferHistoryOper() self.downloadhis = DownloadHistoryOper() self.transferchian = TransferChain() + self.mediaChain = MediaChain() self.tmdbchain = TmdbChain() # 清空配置 self._dirconf = {} @@ -145,7 +145,6 @@ class VCBAnimeMonitor(_PluginBase): self._size = config.get("size") or 0 self._scrape = config.get("scrape") self._switch_ova = config.get("ova") - self._high_mode = config.get("high_mode") self._torrents_path = config.get("torrents_path") or "" # 停止现有任务 @@ -164,13 +163,16 @@ class VCBAnimeMonitor(_PluginBase): return # 启用种子目录监控 - if self._torrents_path is not None and Path(self._torrents_path).exists() and self._enabled: + if self._torrents_path and Path(self._torrents_path).exists() and self._enabled: # 只取第一个目录作为新的保存 - first_path = monitor_dirs[0] - if SystemUtils.is_windows(): - self.new_save_path = first_path.split(':')[0] + ":" + first_path.split(':')[1] - else: - self.new_save_path = first_path.split(':')[0] + try: + first_path = monitor_dirs[0] + if SystemUtils.is_windows(): + self.new_save_path = first_path.split(':')[0] + ":" + first_path.split(':')[1] + else: + self.new_save_path = first_path.split(':')[0] + except Exception: + logger.error(f"目录保存失败,请检查输入目录是否合法") # print(self.new_save_path) try: observer = Observer() @@ -181,7 +183,7 @@ class VCBAnimeMonitor(_PluginBase): observer.start() logger.info(f"{self._torrents_path} 的种子目录监控服务启动,开启监控新增的VCB-Studio种子文件") except Exception as e: - logger.error(f"{self._torrents_path} 启动种子目录监控失败:{str(e)}") + logger.debug(f"{self._torrents_path} 启动种子目录监控失败:{str(e)}") else: logger.info("种子目录为空,不转移qb中正在下载的VCB-Studio文件") @@ -224,7 +226,8 @@ class VCBAnimeMonitor(_PluginBase): 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} 的子目录,无法监控", title="整理VCB动漫压制组作品") + self.systemmessage.put(f"{target_path} 是下载目录 {mon_path} 的子目录,无法监控", + title="整理VCB动漫压制组作品") continue except Exception as e: logger.debug(str(e)) @@ -290,7 +293,6 @@ class VCBAnimeMonitor(_PluginBase): "size": self._size, "scrape": self._scrape, "ova": self._switch_ova, - "high_mode": self._high_mode, "torrents_path": self._torrents_path }) @@ -376,33 +378,56 @@ class VCBAnimeMonitor(_PluginBase): logger.debug(f"{event_path} 不是媒体文件") 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 file_path.parent.name == "SPs": - logger.warn("位于SPs目录下,跳过处理") + if file_path.parent.name.lower() in ["sps", "scans", "cds", "previews", "extras"]: + logger.warn("位于特典或其他特殊目录下,跳过处理") return - remeta = ReMeta(ova_switch=self._switch_ova, high_performance=self._high_mode) + + if 'VCB-Studio' not in file_path.stem.strip(): + logger.warn("不属于VCB的作品,不处理!") + return + + remeta = ReMeta(ova_switch=self._switch_ova) 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: + if remeta.is_ova and not self._switch_ova: logger.warn(f"{file_path.name} 为OVA资源,未开启OVA开关,不处理") return - if remeta.is_special and self._switch_ova: - logger.info(f"{file_path.name} 为OVA资源,开始处理") - 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}")) + 1 - file_meta.begin_episode = ova_history_ep - self.save_data(key=f"OVA_{file_meta.title}", value=ova_history_ep) + if remeta.is_ova and self._switch_ova: + logger.info(f"{file_path.name} 为OVA资源,开始历史记录处理") + ova_history_ep_list = self.get_data(file_meta.title) + if ova_history_ep_list and isinstance(ova_history_ep_list, list): + ep = file_meta.begin_episode + if ep in ova_history_ep_list: + for i in range(1, 100): + if ep + i not in ova_history_ep_list: + ova_history_ep_list.append(ep + i) + file_meta.begin_episode = ep + i + logger.info( + f"{file_path.name} 为OVA资源,历史记录中已存在,自动识别为第{ep + i}集") + break + else: + ova_history_ep_list.append(ep) + self.save_data(file_meta.title, ova_history_ep_list) else: - file_meta.begin_episode = 1 - self.save_data(key=f"OVA_{file_meta.title}", value=1) + self.save_data(file_meta.title, [file_meta.begin_episode]) else: return @@ -418,14 +443,23 @@ class VCBAnimeMonitor(_PluginBase): # 根据父路径获取下载历史 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) + if bluray_flag: + # 蓝光原盘,按目录名查询 + # FIXME 理论上DownloadHistory表中的path应该是全路径,但实际表中登记的数据只有目录名,暂按目录名查询 + download_history = self.downloadhis.get_by_path(file_path.name) + else: + # 按文件全路径查询 + 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 download_history and download_history.tmdbid: + mediainfo: MediaInfo = self.mediaChain.recognize_media(mtype=MediaType(download_history.type), + tmdbid=download_history.tmdbid, + doubanid=download_history.doubanid) + else: + mediainfo: MediaInfo = self.mediaChain.recognize_by_meta(file_meta) if not mediainfo: logger.warn(f'未识别到媒体信息,标题:{file_meta.name}') @@ -615,13 +649,13 @@ class VCBAnimeMonitor(_PluginBase): 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: + logger.info(f"开始转移qb中正在下载的VCB资源,转移目录为:{self.new_save_path}") # 原本存在的暂停的种子不处理 if torrent.state_enum == qbittorrentapi.TorrentState.PAUSED_DOWNLOAD: continue @@ -813,22 +847,6 @@ class VCBAnimeMonitor(_PluginBase): } ] }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'high_mode', - 'label': '高性能处理模式', - } - } - ] - }, { 'component': 'VCol', 'props': { @@ -983,7 +1001,7 @@ class VCBAnimeMonitor(_PluginBase): 'props': { 'model': 'monitor_dirs', 'label': '监控目录', - 'rows': 5, + 'rows': 4, 'placeholder': '每一行一个目录,支持以下几种配置方式,转移方式支持 move、copy、link、softlink、rclone_copy、rclone_move:\n' '监控目录\n' '监控目录#转移方式\n' @@ -1031,8 +1049,10 @@ class VCBAnimeMonitor(_PluginBase): 'props': { 'type': 'info', 'variant': 'tonal', - 'text': '核心用法与目录同步插件相同,不同点在于只识别处理VCB-Studio资源,\n' - '不处理SPs目录下的文件,OVA/OAD集数根据入库顺序累加命名,不保证与TMDB集数匹配' + 'text': '核心用法与目录同步插件相同,不同点在于只识别处理VCB-Studio资源。' + '默认不处理SPs、CDs、SCans目录下的文件,OVA/OAD集数暂时根据入库顺序累加命名,' + '因此不保证与TMDB集数匹配。部分季度以罗马音音译为名的作品暂时无法识别出准确季度。' + '有想法,有问题欢迎点击插件作者主页提issue!' } } ] @@ -1053,9 +1073,9 @@ class VCBAnimeMonitor(_PluginBase): 'props': { 'type': 'info', 'variant': 'tonal', - 'text': '最佳使用方式:监控目录单独设置一个作为保存VCB-Studio资源的目录,\n' - '填入监控种子目录,开启后会将正在QB(仅支持QB)下载器内的VCB-Studio资源转移到监控目录实现自动整理(' - '仅支持第一个监控目录),\n' + 'text': '最佳使用方式:监控目录单独设置一个作为保存VCB-Studio资源的目录,' + '填入监控种子目录,开启后会将正在QB(仅支持QB)下载器内正在下载的VCB-Studio资源转移到监控目录实现自动整理(' + '仅支持第一个监控目录),' '监控种子目录为空则不转移文件' } } @@ -1077,7 +1097,6 @@ class VCBAnimeMonitor(_PluginBase): "cron": "", "size": 0, "ova": False, - "high_mode": False, "torrents_path": "", } diff --git a/plugins/vcbanimemonitor/remeta.py b/plugins/vcbanimemonitor/remeta.py index 4624cc7..ea261eb 100644 --- a/plugins/vcbanimemonitor/remeta.py +++ b/plugins/vcbanimemonitor/remeta.py @@ -1,5 +1,6 @@ import concurrent import re +from dataclasses import dataclass from pathlib import Path from typing import List from app.chain.media import MediaChain @@ -8,196 +9,276 @@ 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}, +] -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 +ova_patterns = [ + re.compile(r".*?(OVA|OAD).*?", re.IGNORECASE), + re.compile(r"\d+\.5"), + re.compile(r"00") +] - 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 +final_season_patterns = [ + re.compile('final season', re.IGNORECASE), + re.compile('The Final', re.IGNORECASE), + re.compile(r'\sFinal') +] - return total +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: - # 解析之后的标题: - 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): + 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.high_performance = high_performance + 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) - self.title = meta.title - self.title = Path(self.title).stem.strip() - 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}") - + 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, 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 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 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 tv_mode(self): + logger.info("开始分离季度和集数部分") + self.split_season_ep() + if not self.vcb_meta.ep_title: + return + self.parse_season() + self.parse_episode() - # 处理季度 - 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) + def parse_season(self): + """ + 从标题中解析季度 + """ + flag = False + for pattern in season_patterns: + match = pattern["pattern"].search(self.vcb_meta.season_title) if match: - if type(group) == str: - title_season["season"] = roman_to_int(match.group(int(group))) - title_season["title"] = re.sub(pattern, "", pre_title).strip() + if isinstance(pattern["group"], int): + self.vcb_meta.season = int(match.group(pattern["group"])) 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 + 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 - # 处理存在“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 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 re_ep(self, ep_title: str, ) -> dict: + def culling_blocked_words(self): """ - # 集数匹配处理模块 - :param ep_title: 从title解析出的集数,ep_title固定格式[集数] - 1.先判断是否存在OVA/OAD,形如:[OVA],[12(OVA)],[12.5]这种形式都是属于OVA/OAD,交给处理OVA模块处理 - 2.集数通常有两种情况一种:[12]直接性,另一种:[12(24)],这一种应该去括号内的为集数 - :return: 集数(int) + 从ep_title中剔除不相关的内容 """ - 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 + 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"))