feat:标识兼容v2的插件

This commit is contained in:
jxxghp
2024-09-09 10:26:38 +08:00
15 changed files with 508 additions and 258 deletions

View File

@@ -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助手",

View File

@@ -38,7 +38,7 @@ class AutoSignIn(_PluginBase):
# 插件图标
plugin_icon = "signin.png"
# 插件版本
plugin_version = "2.4.1"
plugin_version = "2.4.2"
# 插件作者
plugin_author = "thsrite"
# 作者主页

View File

@@ -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><head></head><body>签到成功</body></html>
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, '签到失败'

View File

@@ -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:

View File

@@ -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',

View File

@@ -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

View File

@@ -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} ...")

View File

@@ -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):
"""
退出插件

View File

@@ -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'
}
}
]
}
]
}
]
}
], {

View File

@@ -42,7 +42,7 @@ class SiteStatistic(_PluginBase):
# 插件图标
plugin_icon = "statistic.png"
# 插件版本
plugin_version = "4.0"
plugin_version = "4.0.1"
# 插件作者
plugin_author = "lightolly"
# 作者主页

View File

@@ -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))

View File

@@ -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

View File

@@ -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"
# 编码并保存到临时文件

View File

@@ -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": "",
}

View File

@@ -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"))