mirror of
https://github.com/d0zingcat/MoviePilot-Plugins.git
synced 2026-05-13 23:16:47 +00:00
refactor:定时清理媒体库 v2
This commit is contained in:
@@ -1,20 +1,20 @@
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Dict, Tuple, Optional
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from app.chain.transfer import TransferChain
|
||||
from app import schemas
|
||||
from app.chain.storage import StorageChain
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager
|
||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||
from app.db.transferhistory_oper import TransferHistoryOper
|
||||
from app.plugins import _PluginBase
|
||||
from typing import Any, List, Dict, Tuple, Optional
|
||||
from app.log import logger
|
||||
from app.plugins import _PluginBase
|
||||
from app.schemas import NotificationType, DownloadHistory
|
||||
from app.schemas.types import EventType
|
||||
|
||||
@@ -27,7 +27,7 @@ class AutoClean(_PluginBase):
|
||||
# 插件图标
|
||||
plugin_icon = "clean.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.1"
|
||||
plugin_version = "2.0"
|
||||
# 插件作者
|
||||
plugin_author = "thsrite"
|
||||
# 作者主页
|
||||
@@ -205,12 +205,14 @@ class AutoClean(_PluginBase):
|
||||
for history in transferhis_list:
|
||||
# 册除媒体库文件
|
||||
if clean_type in ["dest", "all"]:
|
||||
TransferChain().delete_files(Path(history.dest))
|
||||
dest_fileitem = schemas.FileItem(**history.dest_fileitem)
|
||||
StorageChain().delete_file(dest_fileitem)
|
||||
# 删除记录
|
||||
self._transferhis.delete(history.id)
|
||||
# 删除源文件
|
||||
if clean_type in ["src", "all"]:
|
||||
TransferChain().delete_files(Path(history.src))
|
||||
src_fileitem = schemas.FileItem(**history.src_fileitem)
|
||||
StorageChain().delete_file(src_fileitem)
|
||||
# 发送事件
|
||||
eventmanager.send_event(
|
||||
EventType.DownloadFileDeleted,
|
||||
|
||||
@@ -1,708 +0,0 @@
|
||||
from datetime import datetime, timedelta
|
||||
from functools import reduce
|
||||
from pathlib import Path
|
||||
from threading import RLock
|
||||
from typing import Optional, Any, List, Dict, Tuple
|
||||
from xml.dom.minidom import parseString
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from requests import Response
|
||||
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.event import eventmanager
|
||||
from app.log import logger
|
||||
from app.modules.emby import Emby
|
||||
from app.modules.jellyfin import Jellyfin
|
||||
from app.modules.plex import Plex
|
||||
from app.plugins import _PluginBase
|
||||
from app.schemas import WebhookEventInfo
|
||||
from app.schemas.types import MediaType, EventType
|
||||
from app.utils.http import RequestUtils
|
||||
|
||||
lock = RLock()
|
||||
|
||||
|
||||
class BestFilmVersion(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "收藏洗版"
|
||||
# 插件描述
|
||||
plugin_desc = "Jellyfin/Emby/Plex点击收藏电影后,自动订阅洗版。"
|
||||
# 插件图标
|
||||
plugin_icon = "like.jpg"
|
||||
# 插件版本
|
||||
plugin_version = "2.3"
|
||||
# 插件作者
|
||||
plugin_author = "wlj"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/developer-wlj"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "bestfilmversion_"
|
||||
# 加载顺序
|
||||
plugin_order = 13
|
||||
# 可使用的用户级别
|
||||
auth_level = 2
|
||||
|
||||
# 私有变量
|
||||
_scheduler: Optional[BackgroundScheduler] = None
|
||||
_cache_path: Optional[Path] = None
|
||||
subscribechain = None
|
||||
|
||||
# 配置属性
|
||||
_enabled: bool = False
|
||||
_cron: str = ""
|
||||
_notify: bool = False
|
||||
_webhook_enabled: bool = False
|
||||
_only_once: bool = False
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
self._cache_path = settings.TEMP_PATH / "__best_film_version_cache__"
|
||||
self.subscribechain = SubscribeChain()
|
||||
|
||||
# 停止现有任务
|
||||
self.stop_service()
|
||||
|
||||
# 配置
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._cron = config.get("cron")
|
||||
self._notify = config.get("notify")
|
||||
self._webhook_enabled = config.get("webhook_enabled")
|
||||
self._only_once = config.get("only_once")
|
||||
|
||||
if self._only_once:
|
||||
self._only_once = False
|
||||
self.update_config({
|
||||
"enabled": self._enabled,
|
||||
"cron": self._cron,
|
||||
"notify": self._notify,
|
||||
"webhook_enabled": self._webhook_enabled,
|
||||
"only_once": self._only_once
|
||||
})
|
||||
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
self._scheduler.add_job(self.sync, 'date',
|
||||
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
|
||||
name="立即运行收藏洗版")
|
||||
# 启动任务
|
||||
if self._scheduler.get_jobs():
|
||||
self._scheduler.print_jobs()
|
||||
self._scheduler.start()
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取插件API
|
||||
[{
|
||||
"path": "/xx",
|
||||
"endpoint": self.xxx,
|
||||
"methods": ["GET", "POST"],
|
||||
"summary": "API说明"
|
||||
}]
|
||||
"""
|
||||
pass
|
||||
|
||||
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 not self._webhook_enabled:
|
||||
if self._cron:
|
||||
return [{
|
||||
"id": "BestFilmVersion",
|
||||
"name": "收藏洗版定时服务",
|
||||
"trigger": CronTrigger.from_crontab(self._cron),
|
||||
"func": self.sync,
|
||||
"kwargs": {}
|
||||
}]
|
||||
return [
|
||||
{
|
||||
"id": "BestFilmVersion",
|
||||
"name": "收藏洗版定时服务",
|
||||
"trigger": "interval",
|
||||
"func": self.sync,
|
||||
"kwargs": {
|
||||
"minutes": 30
|
||||
}
|
||||
}
|
||||
]
|
||||
return []
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 3
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enabled',
|
||||
'label': '启用插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 3
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'notify',
|
||||
'label': '发送通知',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 3
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'only_once',
|
||||
'label': '立即运行一次',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 3
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'webhook_enabled',
|
||||
'label': 'Webhook',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'cron',
|
||||
'label': '执行周期',
|
||||
'placeholder': '5位cron表达式,留空自动'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '支持主动定时获取媒体库数据和Webhook实时触发两种方式,两者只能选其一,'
|
||||
'Webhook需要在媒体服务器设置发送Webhook报文。'
|
||||
'Plex使用主动获取时,建议执行周期设置大于1小时,'
|
||||
'收藏Api调用Plex官网接口,有频率限制。'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"notify": False,
|
||||
"cron": "*/30 * * * *",
|
||||
"webhook_enabled": False,
|
||||
"only_once": False
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
"""
|
||||
拼装插件详情页面,需要返回页面配置,同时附带数据
|
||||
"""
|
||||
# 查询同步详情
|
||||
historys = self.get_data('history')
|
||||
if not historys:
|
||||
return [
|
||||
{
|
||||
'component': 'div',
|
||||
'text': '暂无数据',
|
||||
'props': {
|
||||
'class': 'text-center',
|
||||
}
|
||||
}
|
||||
]
|
||||
# 数据按时间降序排序
|
||||
historys = sorted(historys, key=lambda x: x.get('time'), reverse=True)
|
||||
# 拼装页面
|
||||
contents = []
|
||||
for history in historys:
|
||||
title = history.get("title")
|
||||
poster = history.get("poster")
|
||||
mtype = history.get("type")
|
||||
time_str = history.get("time")
|
||||
tmdbid = history.get("tmdbid")
|
||||
contents.append(
|
||||
{
|
||||
'component': 'VCard',
|
||||
'content': [
|
||||
{
|
||||
'component': 'div',
|
||||
'props': {
|
||||
'class': 'd-flex justify-space-start flex-nowrap flex-row',
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'div',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VImg',
|
||||
'props': {
|
||||
'src': poster,
|
||||
'height': 120,
|
||||
'width': 80,
|
||||
'aspect-ratio': '2/3',
|
||||
'class': 'object-cover shadow ring-gray-500',
|
||||
'cover': True
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'div',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCardTitle',
|
||||
'props': {
|
||||
'class': 'ps-1 pe-5 break-words whitespace-break-spaces'
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'a',
|
||||
'props': {
|
||||
'href': f"https://www.themoviedb.org/movie/{tmdbid}",
|
||||
'target': '_blank'
|
||||
},
|
||||
'text': title
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'类型:{mtype}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'时间:{time_str}'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
'component': 'div',
|
||||
'props': {
|
||||
'class': 'grid gap-3 grid-info-card',
|
||||
},
|
||||
'content': contents
|
||||
}
|
||||
]
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
退出插件
|
||||
"""
|
||||
try:
|
||||
if self._scheduler:
|
||||
self._scheduler.remove_all_jobs()
|
||||
if self._scheduler.running:
|
||||
self._scheduler.shutdown()
|
||||
self._scheduler = None
|
||||
except Exception as e:
|
||||
logger.error("退出插件失败:%s" % str(e))
|
||||
|
||||
def sync(self):
|
||||
"""
|
||||
通过流媒体管理工具收藏,自动洗版
|
||||
"""
|
||||
# 获取锁
|
||||
_is_lock: bool = lock.acquire(timeout=60)
|
||||
if not _is_lock:
|
||||
return
|
||||
try:
|
||||
# 读取缓存
|
||||
caches = self._cache_path.read_text().split("\n") if self._cache_path.exists() else []
|
||||
# 读取历史记录
|
||||
history = self.get_data('history') or []
|
||||
|
||||
# 媒体服务器类型,多个以,分隔
|
||||
if not settings.MEDIASERVER:
|
||||
return
|
||||
media_servers = settings.MEDIASERVER.split(',')
|
||||
|
||||
# 读取收藏
|
||||
all_items = {}
|
||||
for media_server in media_servers:
|
||||
if media_server == 'jellyfin':
|
||||
all_items['jellyfin'] = self.jellyfin_get_items()
|
||||
elif media_server == 'emby':
|
||||
all_items['emby'] = self.emby_get_items()
|
||||
else:
|
||||
all_items['plex'] = self.plex_get_watchlist()
|
||||
|
||||
def function(y, x):
|
||||
return y if (x['Name'] in [i['Name'] for i in y]) else (lambda z, u: (z.append(u), z))(y, x)[1]
|
||||
|
||||
# 处理所有结果
|
||||
for server, all_item in all_items.items():
|
||||
# all_item 根据电影名去重
|
||||
result = reduce(function, all_item, [])
|
||||
for data in result:
|
||||
# 检查缓存
|
||||
if data.get('Name') in caches:
|
||||
continue
|
||||
|
||||
# 获取详情
|
||||
if server == 'jellyfin':
|
||||
item_info_resp = Jellyfin().get_iteminfo(itemid=data.get('Id'))
|
||||
elif server == 'emby':
|
||||
item_info_resp = Emby().get_iteminfo(itemid=data.get('Id'))
|
||||
else:
|
||||
item_info_resp = self.plex_get_iteminfo(itemid=data.get('Id'))
|
||||
logger.debug(f'BestFilmVersion插件 item打印 {item_info_resp}')
|
||||
if not item_info_resp:
|
||||
continue
|
||||
|
||||
# 只接受Movie类型
|
||||
if data.get('Type') != 'Movie':
|
||||
continue
|
||||
|
||||
# 获取tmdb_id
|
||||
tmdb_id = item_info_resp.get("tmdbid") if server == 'plex' else item_info_resp.tmdbid
|
||||
if not tmdb_id:
|
||||
continue
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.chain.recognize_media(tmdbid=tmdb_id, mtype=MediaType.MOVIE)
|
||||
if not mediainfo:
|
||||
logger.warn(f'未识别到媒体信息,标题:{data.get("Name")},tmdbid:{tmdb_id}')
|
||||
continue
|
||||
# 添加订阅
|
||||
self.subscribechain.add(mtype=MediaType.MOVIE,
|
||||
title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
best_version=True,
|
||||
username="收藏洗版",
|
||||
exist_ok=True)
|
||||
# 加入缓存
|
||||
caches.append(data.get('Name'))
|
||||
# 存储历史记录
|
||||
if mediainfo.tmdb_id not in [h.get("tmdbid") for h in history]:
|
||||
history.append({
|
||||
"title": mediainfo.title,
|
||||
"type": mediainfo.type.value,
|
||||
"year": mediainfo.year,
|
||||
"poster": mediainfo.get_poster_image(),
|
||||
"overview": mediainfo.overview,
|
||||
"tmdbid": mediainfo.tmdb_id,
|
||||
"time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
})
|
||||
# 保存历史记录
|
||||
self.save_data('history', history)
|
||||
# 保存缓存
|
||||
self._cache_path.write_text("\n".join(caches))
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
def jellyfin_get_items(self) -> List[dict]:
|
||||
# 获取所有user
|
||||
users_url = "[HOST]Users?&apikey=[APIKEY]"
|
||||
users = self.get_users(Jellyfin().get_data(users_url))
|
||||
if not users:
|
||||
logger.info(f"bestfilmversion/users_url: {users_url}")
|
||||
return []
|
||||
all_items = []
|
||||
for user in users:
|
||||
# 根据加入日期 降序排序
|
||||
url = "[HOST]Users/" + user + "/Items?SortBy=DateCreated%2CSortName" \
|
||||
"&SortOrder=Descending" \
|
||||
"&Filters=IsFavorite" \
|
||||
"&Recursive=true" \
|
||||
"&Fields=PrimaryImageAspectRatio%2CBasicSyncInfo" \
|
||||
"&CollapseBoxSetItems=false" \
|
||||
"&ExcludeLocationTypes=Virtual" \
|
||||
"&EnableTotalRecordCount=false" \
|
||||
"&Limit=20" \
|
||||
"&apikey=[APIKEY]"
|
||||
resp = self.get_items(Jellyfin().get_data(url))
|
||||
if not resp:
|
||||
continue
|
||||
all_items.extend(resp)
|
||||
return all_items
|
||||
|
||||
def emby_get_items(self) -> List[dict]:
|
||||
# 获取所有user
|
||||
get_users_url = "[HOST]Users?&api_key=[APIKEY]"
|
||||
users = self.get_users(Emby().get_data(get_users_url))
|
||||
if not users:
|
||||
return []
|
||||
all_items = []
|
||||
for user in users:
|
||||
# 根据加入日期 降序排序
|
||||
url = "[HOST]emby/Users/" + user + "/Items?SortBy=DateCreated%2CSortName" \
|
||||
"&SortOrder=Descending" \
|
||||
"&Filters=IsFavorite" \
|
||||
"&Recursive=true" \
|
||||
"&Fields=PrimaryImageAspectRatio%2CBasicSyncInfo" \
|
||||
"&CollapseBoxSetItems=false" \
|
||||
"&ExcludeLocationTypes=Virtual" \
|
||||
"&EnableTotalRecordCount=false" \
|
||||
"&Limit=20&api_key=[APIKEY]"
|
||||
resp = self.get_items(Emby().get_data(url))
|
||||
if not resp:
|
||||
continue
|
||||
all_items.extend(resp)
|
||||
return all_items
|
||||
|
||||
@staticmethod
|
||||
def get_items(resp: Response):
|
||||
try:
|
||||
if resp:
|
||||
return resp.json().get("Items") or []
|
||||
else:
|
||||
return []
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def get_users(resp: Response):
|
||||
try:
|
||||
if resp:
|
||||
return [data['Id'] for data in resp.json()]
|
||||
else:
|
||||
logger.error(f"BestFilmVersion/Users 未获取到返回数据")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"连接BestFilmVersion/Users 出错:" + str(e))
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def plex_get_watchlist() -> List[dict]:
|
||||
# 根据加入日期 降序排序
|
||||
url = f"https://metadata.provider.plex.tv/library/sections/watchlist/all?type=1&sort=addedAt%3Adesc" \
|
||||
f"&X-Plex-Container-Start=0&X-Plex-Container-Size=50" \
|
||||
f"&X-Plex-Token={settings.PLEX_TOKEN}"
|
||||
res = []
|
||||
try:
|
||||
resp = RequestUtils().get_res(url=url)
|
||||
if resp:
|
||||
dom = parseString(resp.text)
|
||||
# 获取文档元素对象
|
||||
elem = dom.documentElement
|
||||
# 获取 指定元素
|
||||
eles = elem.getElementsByTagName('Video')
|
||||
if not eles:
|
||||
return []
|
||||
for ele in eles:
|
||||
data = {}
|
||||
# 获取标签中内容
|
||||
ele_id = ele.attributes['ratingKey'].nodeValue
|
||||
ele_title = ele.attributes['title'].nodeValue
|
||||
ele_type = ele.attributes['type'].nodeValue
|
||||
_type = "Movie" if ele_type == "movie" else ""
|
||||
data['Id'] = ele_id
|
||||
data['Name'] = ele_title
|
||||
data['Type'] = _type
|
||||
res.append(data)
|
||||
return res
|
||||
else:
|
||||
logger.error(f"Plex/Watchlist 未获取到返回数据")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"连接Plex/Watchlist 出错:" + str(e))
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def plex_get_iteminfo(itemid) -> dict:
|
||||
url = f"https://metadata.provider.plex.tv/library/metadata/{itemid}" \
|
||||
f"?X-Plex-Token={settings.PLEX_TOKEN}"
|
||||
try:
|
||||
resp = RequestUtils(accept_type="application/json, text/plain, */*").get_res(url=url)
|
||||
if resp:
|
||||
metadata = resp.json().get('MediaContainer').get('Metadata')
|
||||
for item in metadata:
|
||||
_guid = item.get('Guid')
|
||||
if not _guid:
|
||||
continue
|
||||
|
||||
id_list = [h.get('id') for h in _guid if h.get('id').__contains__("tmdb")]
|
||||
if not id_list:
|
||||
continue
|
||||
|
||||
return {'tmdbid': id_list[0].split("/")[-1]}
|
||||
|
||||
return {}
|
||||
else:
|
||||
logger.error(f"Plex/Items 未获取到返回数据")
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.error(f"连接Plex/Items 出错:" + str(e))
|
||||
return {}
|
||||
|
||||
@eventmanager.register(EventType.WebhookMessage)
|
||||
def webhook_message_action(self, event):
|
||||
|
||||
if not self._enabled:
|
||||
return
|
||||
if not self._webhook_enabled:
|
||||
return
|
||||
|
||||
data: WebhookEventInfo = event.event_data
|
||||
# 排除不是收藏调用
|
||||
if data.channel not in ['jellyfin', 'emby', 'plex']:
|
||||
return
|
||||
if data.channel in ['emby', 'plex'] and data.event != 'item.rate':
|
||||
return
|
||||
if data.channel == 'jellyfin' and data.save_reason != 'UpdateUserRating':
|
||||
return
|
||||
logger.info(f'BestFilmVersion/webhook_message_action WebhookEventInfo打印:{data}')
|
||||
|
||||
# 获取锁
|
||||
_is_lock: bool = lock.acquire(timeout=60)
|
||||
if not _is_lock:
|
||||
return
|
||||
try:
|
||||
if not data.tmdb_id:
|
||||
info = None
|
||||
if (data.channel == 'jellyfin'
|
||||
and data.save_reason == 'UpdateUserRating'
|
||||
and data.item_favorite):
|
||||
info = Jellyfin().get_iteminfo(itemid=data.item_id)
|
||||
elif data.channel == 'emby' and data.event == 'item.rate':
|
||||
info = Emby().get_iteminfo(itemid=data.item_id)
|
||||
elif data.channel == 'plex' and data.event == 'item.rate':
|
||||
info = Plex().get_iteminfo(itemid=data.item_id)
|
||||
logger.debug(f'BestFilmVersion/webhook_message_action item打印:{info}')
|
||||
if not info:
|
||||
return
|
||||
if info.item_type not in ['Movie', 'MOV', 'movie']:
|
||||
return
|
||||
# 获取tmdb_id
|
||||
tmdb_id = info.tmdbid
|
||||
else:
|
||||
tmdb_id = data.tmdb_id
|
||||
if (data.channel == 'jellyfin'
|
||||
and (data.save_reason != 'UpdateUserRating' or not data.item_favorite)):
|
||||
return
|
||||
if data.item_type not in ['Movie', 'MOV', 'movie']:
|
||||
return
|
||||
# 识别媒体信息
|
||||
mediainfo = self.chain.recognize_media(tmdbid=tmdb_id, mtype=MediaType.MOVIE)
|
||||
if not mediainfo:
|
||||
logger.warn(f'未识别到媒体信息,标题:{data.item_name},tmdbID:{tmdb_id}')
|
||||
return
|
||||
# 读取缓存
|
||||
caches = self._cache_path.read_text().split("\n") if self._cache_path.exists() else []
|
||||
# 检查缓存
|
||||
if data.item_name in caches:
|
||||
return
|
||||
# 读取历史记录
|
||||
history = self.get_data('history') or []
|
||||
# 添加订阅
|
||||
self.subscribechain.add(mtype=MediaType.MOVIE,
|
||||
title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
best_version=True,
|
||||
username="收藏洗版",
|
||||
exist_ok=True)
|
||||
# 加入缓存
|
||||
caches.append(data.item_name)
|
||||
# 存储历史记录
|
||||
if mediainfo.tmdb_id not in [h.get("tmdbid") for h in history]:
|
||||
history.append({
|
||||
"title": mediainfo.title,
|
||||
"type": mediainfo.type.value,
|
||||
"year": mediainfo.year,
|
||||
"poster": mediainfo.get_poster_image(),
|
||||
"overview": mediainfo.overview,
|
||||
"tmdbid": mediainfo.tmdb_id,
|
||||
"time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
})
|
||||
# 保存历史记录
|
||||
self.save_data('history', history)
|
||||
# 保存缓存
|
||||
self._cache_path.write_text("\n".join(caches))
|
||||
finally:
|
||||
lock.release()
|
||||
@@ -1,918 +0,0 @@
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from app.modules.qbittorrent import Qbittorrent
|
||||
from app.utils.string import StringUtils
|
||||
from app.schemas.types import EventType
|
||||
from app.core.event import eventmanager, Event
|
||||
|
||||
from app.core.config import settings
|
||||
from app.plugins import _PluginBase
|
||||
from typing import Any, List, Dict, Tuple, Optional
|
||||
from app.log import logger
|
||||
from app.schemas import NotificationType
|
||||
|
||||
|
||||
class CleanInvalidSeed(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "清理QB无效做种"
|
||||
# 插件描述
|
||||
plugin_desc = "清理已经被站点删除的种子及源文件,仅支持QB"
|
||||
# 插件图标
|
||||
plugin_icon = "clean_a.png"
|
||||
# 插件版本
|
||||
plugin_version = "2.2"
|
||||
# 插件作者
|
||||
plugin_author = "DzAvril"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/DzAvril"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "cleaninvalidseed"
|
||||
# 加载顺序
|
||||
plugin_order = 1
|
||||
# 可使用的用户级别
|
||||
auth_level = 1
|
||||
|
||||
# 私有属性
|
||||
_enabled = False
|
||||
_cron = None
|
||||
_notify = False
|
||||
_onlyonce = False
|
||||
_qb = None
|
||||
_detect_invalid_files = False
|
||||
_delete_invalid_files = False
|
||||
_delete_invalid_torrents = False
|
||||
_notify_all = False
|
||||
_label_only = False
|
||||
_label = ""
|
||||
_download_dirs = ""
|
||||
_exclude_keywords = ""
|
||||
_exclude_categories = ""
|
||||
_exclude_labels = ""
|
||||
_more_logs = False
|
||||
# 定时器
|
||||
_scheduler: Optional[BackgroundScheduler] = None
|
||||
_error_msg = [
|
||||
"torrent not registered with this tracker",
|
||||
"Torrent not registered with this tracker",
|
||||
"torrent banned",
|
||||
"err torrent banned",
|
||||
]
|
||||
_custom_error_msg = ""
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
# 停止现有任务
|
||||
self.stop_service()
|
||||
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._cron = config.get("cron")
|
||||
self._notify = config.get("notify")
|
||||
self._onlyonce = config.get("onlyonce")
|
||||
self._delete_invalid_torrents = config.get("delete_invalid_torrents")
|
||||
self._delete_invalid_files = config.get("delete_invalid_files")
|
||||
self._detect_invalid_files = config.get("detect_invalid_files")
|
||||
self._notify_all = config.get("notify_all")
|
||||
self._label_only = config.get("label_only")
|
||||
self._label = config.get("label")
|
||||
self._download_dirs = config.get("download_dirs")
|
||||
self._exclude_keywords = config.get("exclude_keywords")
|
||||
self._exclude_categories = config.get("exclude_categories")
|
||||
self._exclude_labels = config.get("exclude_labels")
|
||||
self._custom_error_msg = config.get("custom_error_msg")
|
||||
self._more_logs = config.get("more_logs")
|
||||
self._qb = Qbittorrent()
|
||||
|
||||
# 加载模块
|
||||
if self._onlyonce:
|
||||
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
logger.info(f"清理无效种子服务启动,立即运行一次")
|
||||
self._scheduler.add_job(
|
||||
func=self.clean_invalid_seed,
|
||||
trigger="date",
|
||||
run_date=datetime.now(tz=pytz.timezone(settings.TZ))
|
||||
+ timedelta(seconds=3),
|
||||
name="清理无效种子",
|
||||
)
|
||||
# 关闭一次性开关
|
||||
self._onlyonce = False
|
||||
self._update_config()
|
||||
|
||||
# 启动任务
|
||||
if self._scheduler.get_jobs():
|
||||
self._scheduler.print_jobs()
|
||||
self._scheduler.start()
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
def _update_config(self):
|
||||
self.update_config(
|
||||
{
|
||||
"onlyonce": False,
|
||||
"cron": self._cron,
|
||||
"enabled": self._enabled,
|
||||
"notify": self._notify,
|
||||
"delete_invalid_torrents": self._delete_invalid_torrents,
|
||||
"delete_invalid_files": self._delete_invalid_files,
|
||||
"detect_invalid_files": self._detect_invalid_files,
|
||||
"notify_all": self._notify_all,
|
||||
"label_only": self._label_only,
|
||||
"label": self._label,
|
||||
"download_dirs": self._download_dirs,
|
||||
"exclude_keywords": self._exclude_keywords,
|
||||
"exclude_categories": self._exclude_categories,
|
||||
"exclude_labels": self._exclude_labels,
|
||||
"custom_error_msg": self._custom_error_msg,
|
||||
"more_logs": self._more_logs,
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
定义远程控制命令
|
||||
:return: 命令关键字、事件、描述、附带数据
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"cmd": "/detect_invalid_torrents",
|
||||
"event": EventType.PluginAction,
|
||||
"desc": "检测无效做种",
|
||||
"category": "QB",
|
||||
"data": {"action": "detect_invalid_torrents"},
|
||||
},
|
||||
{
|
||||
"cmd": "/delete_invalid_torrents",
|
||||
"event": EventType.PluginAction,
|
||||
"desc": "清理无效做种",
|
||||
"category": "QB",
|
||||
"data": {"action": "delete_invalid_torrents"},
|
||||
},
|
||||
{
|
||||
"cmd": "/detect_invalid_files",
|
||||
"event": EventType.PluginAction,
|
||||
"desc": "检测无效源文件",
|
||||
"category": "QB",
|
||||
"data": {"action": "detect_invalid_files"},
|
||||
},
|
||||
{
|
||||
"cmd": "/delete_invalid_files",
|
||||
"event": EventType.PluginAction,
|
||||
"desc": "清理无效源文件",
|
||||
"category": "QB",
|
||||
"data": {"action": "delete_invalid_files"},
|
||||
},
|
||||
{
|
||||
"cmd": "/toggle_notify_all",
|
||||
"event": EventType.PluginAction,
|
||||
"desc": "QB清理插件切换全量通知",
|
||||
"category": "QB",
|
||||
"data": {"action": "toggle_notify_all"},
|
||||
},
|
||||
]
|
||||
|
||||
@eventmanager.register(EventType.PluginAction)
|
||||
def handle_commands(self, event: Event):
|
||||
if event:
|
||||
event_data = event.event_data
|
||||
if event_data:
|
||||
if not (
|
||||
event_data.get("action") == "detect_invalid_torrents"
|
||||
or event_data.get("action") == "delete_invalid_torrents"
|
||||
or event_data.get("action") == "detect_invalid_files"
|
||||
or event_data.get("action") == "delete_invalid_files"
|
||||
or event_data.get("action") == "toggle_notify_all"
|
||||
):
|
||||
return
|
||||
self.post_message(
|
||||
channel=event.event_data.get("channel"),
|
||||
title="开始执行远程命令...",
|
||||
userid=event.event_data.get("user"),
|
||||
)
|
||||
old_delete_invalid_torrents = self._delete_invalid_torrents
|
||||
old_detect_invalid_files = self._detect_invalid_files
|
||||
old_delete_invalid_files = self._delete_invalid_files
|
||||
if event_data.get("action") == "detect_invalid_torrents":
|
||||
logger.info("收到远程命令,开始检测无效做种")
|
||||
self._delete_invalid_torrents = False
|
||||
self._detect_invalid_files = False
|
||||
self._delete_invalid_files = False
|
||||
self.clean_invalid_seed()
|
||||
elif event_data.get("action") == "delete_invalid_torrents":
|
||||
logger.info("收到远程命令,开始清理无效做种")
|
||||
self._delete_invalid_torrents = True
|
||||
self._detect_invalid_files = False
|
||||
self._delete_invalid_files = False
|
||||
self.clean_invalid_seed()
|
||||
elif event_data.get("action") == "detect_invalid_files":
|
||||
logger.info("收到远程命令,开始检测无效源文件")
|
||||
self._delete_invalid_files = False
|
||||
self.detect_invalid_files()
|
||||
elif event_data.get("action") == "delete_invalid_files":
|
||||
logger.info("收到远程命令,开始清理无效源文件")
|
||||
self._delete_invalid_files = True
|
||||
self.detect_invalid_files()
|
||||
elif event_data.get("action") == "toggle_notify_all":
|
||||
self._notify_all = not self._notify_all
|
||||
self._update_config()
|
||||
if self._notify_all:
|
||||
self.post_message(
|
||||
channel=event.event_data.get("channel"),
|
||||
title="已开启全量通知",
|
||||
userid=event.event_data.get("user"),
|
||||
)
|
||||
else:
|
||||
self.post_message(
|
||||
channel=event.event_data.get("channel"),
|
||||
title="已关闭全量通知",
|
||||
userid=event.event_data.get("user"),
|
||||
)
|
||||
return
|
||||
else:
|
||||
logger.error("收到未知远程命令")
|
||||
return
|
||||
self._delete_invalid_torrents = old_delete_invalid_torrents
|
||||
self._detect_invalid_files = old_detect_invalid_files
|
||||
self._delete_invalid_files = old_delete_invalid_files
|
||||
self.post_message(
|
||||
channel=event.event_data.get("channel"),
|
||||
title="远程命令执行完成!",
|
||||
userid=event.event_data.get("user"),
|
||||
)
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
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": "CleanInvalidSeed",
|
||||
"name": "清理QB无效做种",
|
||||
"trigger": CronTrigger.from_crontab(self._cron),
|
||||
"func": self.clean_invalid_seed,
|
||||
"kwargs": {},
|
||||
}
|
||||
]
|
||||
|
||||
def get_all_torrents(self):
|
||||
all_torrents, error = self._qb.get_torrents()
|
||||
if error:
|
||||
logger.error(f"获取QB种子失败: {error}")
|
||||
if self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【清理QB无效做种】",
|
||||
text=f"获取QB种子失败,请检查QB配置",
|
||||
)
|
||||
return []
|
||||
|
||||
if not all_torrents:
|
||||
logger.warning("QB没有种子")
|
||||
if self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【清理QB无效做种】",
|
||||
text=f"QB中没有种子",
|
||||
)
|
||||
return []
|
||||
return all_torrents
|
||||
|
||||
def clean_invalid_seed(self):
|
||||
logger.info("开始清理QB无效做种")
|
||||
all_torrents = self.get_all_torrents()
|
||||
temp_invalid_torrents = []
|
||||
# tracker未工作,但暂时不能判定为失效做种,需人工判断
|
||||
tracker_not_working_torrents = []
|
||||
working_tracker_set = set()
|
||||
exclude_categories = (
|
||||
self._exclude_categories.split("\n") if self._exclude_categories else []
|
||||
)
|
||||
exclude_labels = (
|
||||
self._exclude_labels.split("\n") if self._exclude_labels else []
|
||||
)
|
||||
custom_msgs = (
|
||||
self._custom_error_msg.split("\n") if self._custom_error_msg else []
|
||||
)
|
||||
error_msgs = self._error_msg + custom_msgs
|
||||
# 第一轮筛选出所有未工作的种子
|
||||
for torrent in all_torrents:
|
||||
trackers = torrent.trackers
|
||||
is_invalid = True
|
||||
is_tracker_working = False
|
||||
for tracker in trackers:
|
||||
if tracker.get("tier") == -1:
|
||||
continue
|
||||
tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1]
|
||||
# 有一个tracker工作即为有效做种
|
||||
if (tracker.get("status") == 2) or (tracker.get("status") == 3):
|
||||
is_tracker_working = True
|
||||
|
||||
if not (
|
||||
(tracker.get("status") == 4) and (tracker.get("msg") in error_msgs)
|
||||
):
|
||||
is_invalid = False
|
||||
working_tracker_set.add(tracker_domian)
|
||||
|
||||
if self._more_logs:
|
||||
logger.info(f"处理 [{torrent.name}] tracker [{tracker_domian}]: 分类: [{torrent.category}], 标签: [{torrent.tags}], 状态: [{tracker.get('status')}], msg: [{tracker.get('msg')}], is_invalid: [{is_invalid}], is_working: [{is_tracker_working}]")
|
||||
if is_invalid:
|
||||
temp_invalid_torrents.append(torrent)
|
||||
elif not is_tracker_working:
|
||||
# 排除已暂停的种子
|
||||
if not torrent.state_enum.is_paused:
|
||||
tracker_not_working_torrents.append(torrent)
|
||||
|
||||
logger.info(f"初筛共有{len(temp_invalid_torrents)}个无效做种")
|
||||
# 第二轮筛选出tracker有正常工作种子而当前种子未工作的,避免因临时关站或tracker失效导致误删的问题
|
||||
# 失效做种但通过种子分类排除的种子
|
||||
invalid_torrents_exclude_categories = []
|
||||
# 失效做种但通过种子标签排除的种子
|
||||
invalid_torrents_exclude_labels = []
|
||||
# 将invalid_torrents基本信息保存起来,在种子被删除后依然可以打印这些信息
|
||||
invalid_torrent_tuple_list = []
|
||||
deleted_torrent_tuple_list = []
|
||||
for torrent in temp_invalid_torrents:
|
||||
trackers = torrent.trackers
|
||||
for tracker in trackers:
|
||||
if tracker.get("tier") == -1:
|
||||
continue
|
||||
tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1]
|
||||
if tracker_domian in working_tracker_set:
|
||||
# tracker是正常的,说明该种子是无效的
|
||||
invalid_torrent_tuple_list.append(
|
||||
(
|
||||
torrent.name,
|
||||
torrent.category,
|
||||
torrent.tags,
|
||||
torrent.size,
|
||||
tracker_domian,
|
||||
tracker.msg,
|
||||
)
|
||||
)
|
||||
if self._delete_invalid_torrents or self._label_only:
|
||||
# 检查种子分类和标签是否排除
|
||||
is_excluded = False
|
||||
if torrent.category in exclude_categories:
|
||||
is_excluded = True
|
||||
invalid_torrents_exclude_categories.append(torrent)
|
||||
torrent_labels = [
|
||||
tag.strip() for tag in torrent.tags.split(",")
|
||||
]
|
||||
for label in torrent_labels:
|
||||
if label in exclude_labels:
|
||||
is_excluded = True
|
||||
invalid_torrents_exclude_labels.append(torrent)
|
||||
if not is_excluded:
|
||||
if self._label_only:
|
||||
# 仅标记
|
||||
self._qb.set_torrents_tag(ids=torrent.get("hash"), tags=[self._label if self._label != "" else "无效做种"])
|
||||
else:
|
||||
# 只删除种子不删除文件,以防其它站点辅种
|
||||
self._qb.delete_torrents(False, torrent.get("hash"))
|
||||
# 标记已处理种子信息
|
||||
deleted_torrent_tuple_list.append(
|
||||
(
|
||||
torrent.name,
|
||||
torrent.category,
|
||||
torrent.tags,
|
||||
torrent.size,
|
||||
tracker_domian,
|
||||
tracker.msg,
|
||||
)
|
||||
)
|
||||
break
|
||||
invalid_msg = f"检测到{len(invalid_torrent_tuple_list)}个失效做种\n"
|
||||
tracker_not_working_msg = f"检测到{len(tracker_not_working_torrents)}个tracker未工作做种,请检查种子状态\n"
|
||||
|
||||
if self._label_only or self._delete_invalid_torrents:
|
||||
if self._label_only:
|
||||
deleted_msg = f"标记了{len(deleted_torrent_tuple_list)}个失效种子\n"
|
||||
else:
|
||||
deleted_msg = f"删除了{len(deleted_torrent_tuple_list)}个失效种子\n"
|
||||
if len(exclude_categories) != 0:
|
||||
exclude_categories_msg = f"分类排除{len(invalid_torrents_exclude_categories)}个失效种子未删除,请手动处理\n"
|
||||
if len(exclude_labels) != 0:
|
||||
exclude_labels_msg = f"标签排除{len(invalid_torrents_exclude_labels)}个失效种子未删除,请手动处理\n"
|
||||
for index in range(len(invalid_torrent_tuple_list)):
|
||||
torrent = invalid_torrent_tuple_list[index]
|
||||
invalid_msg += f"{index + 1}. {torrent[0]},分类:{torrent[1]},标签:{torrent[2]}, 大小:{StringUtils.str_filesize(torrent[3])},Trackers: {torrent[4]}:{torrent[5]}\n"
|
||||
|
||||
for index in range(len(tracker_not_working_torrents)):
|
||||
torrent = tracker_not_working_torrents[index]
|
||||
trackers = torrent.trackers
|
||||
tracker_msg = ""
|
||||
for tracker in trackers:
|
||||
if tracker.get("tier") == -1:
|
||||
continue
|
||||
tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1]
|
||||
tracker_msg += f" {tracker_domian}:{tracker.msg} "
|
||||
tracker_not_working_msg += f"{index + 1}. {torrent.name},分类:{torrent.category},标签:{torrent.tags}, 大小:{StringUtils.str_filesize(torrent.size)},Trackers: {tracker_msg}\n"
|
||||
|
||||
for index in range(len(invalid_torrents_exclude_categories)):
|
||||
torrent = invalid_torrents_exclude_categories[index]
|
||||
trackers = torrent.trackers
|
||||
tracker_msg = ""
|
||||
for tracker in trackers:
|
||||
if tracker.get("tier") == -1:
|
||||
continue
|
||||
tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1]
|
||||
tracker_msg += f" {tracker_domian}:{tracker.msg} "
|
||||
exclude_categories_msg += f"{index + 1}. {torrent.name},分类:{torrent.category},标签:{torrent.tags}, 大小:{StringUtils.str_filesize(torrent.size)},Trackers: {tracker_msg}\n"
|
||||
|
||||
for index in range(len(invalid_torrents_exclude_labels)):
|
||||
torrent = invalid_torrents_exclude_labels[index]
|
||||
trackers = torrent.trackers
|
||||
tracker_msg = ""
|
||||
for tracker in trackers:
|
||||
if tracker.get("tier") == -1:
|
||||
continue
|
||||
tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1]
|
||||
tracker_msg += f" {tracker_domian}:{tracker.msg} "
|
||||
exclude_labels_msg += f"{index + 1}. {torrent.name},分类:{torrent.category},标签:{torrent.tags}, 大小:{StringUtils.str_filesize(torrent.size)},Trackers: {tracker_msg}\n"
|
||||
|
||||
for index in range(len(deleted_torrent_tuple_list)):
|
||||
torrent = deleted_torrent_tuple_list[index]
|
||||
deleted_msg += f"{index + 1}. {torrent[0]},分类:{torrent[1]},标签:{torrent[2]}, 大小:{StringUtils.str_filesize(torrent[3])},Trackers: {torrent[4]}:{torrent[5]}\n"
|
||||
|
||||
# 日志
|
||||
logger.info(invalid_msg)
|
||||
logger.info(tracker_not_working_msg)
|
||||
if self._delete_invalid_torrents:
|
||||
logger.info(deleted_msg)
|
||||
if len(exclude_categories) != 0:
|
||||
logger.info(exclude_categories_msg)
|
||||
if len(exclude_labels) != 0:
|
||||
logger.info(exclude_labels_msg)
|
||||
# 通知
|
||||
if self._notify:
|
||||
invalid_msg = invalid_msg.replace("_", "\_")
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【清理无效做种】",
|
||||
text=invalid_msg,
|
||||
)
|
||||
if self._notify_all:
|
||||
tracker_not_working_msg = tracker_not_working_msg.replace("_", "\_")
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【清理无效做种】",
|
||||
text=tracker_not_working_msg,
|
||||
)
|
||||
if self._label_only or self._delete_invalid_torrents:
|
||||
deleted_msg = deleted_msg.replace("_", "\_")
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【清理无效做种】",
|
||||
text=deleted_msg,
|
||||
)
|
||||
if self._notify_all:
|
||||
exclude_categories_msg = exclude_categories_msg.replace("_", "\_")
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【清理无效做种】",
|
||||
text=exclude_categories_msg,
|
||||
)
|
||||
exclude_labels_msg = exclude_labels_msg.replace("_", "\_")
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【清理无效做种】",
|
||||
text=exclude_labels_msg,
|
||||
)
|
||||
logger.info("检测无效做种任务结束")
|
||||
if self._detect_invalid_files:
|
||||
self.detect_invalid_files()
|
||||
|
||||
def detect_invalid_files(self):
|
||||
logger.info("开始检测未做种的无效源文件")
|
||||
all_torrents = self.get_all_torrents()
|
||||
source_path_map = {}
|
||||
source_paths = []
|
||||
total_size = 0
|
||||
deleted_file_cnt = 0
|
||||
exclude_key_words = (
|
||||
self._exclude_keywords.split("\n") if self._exclude_keywords else []
|
||||
)
|
||||
if not self._download_dirs:
|
||||
logger.error("未配置下载目录,无法检测未做种无效源文件")
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【检测无效源文件】",
|
||||
text="未配置下载目录,无法检测未做种无效源文件",
|
||||
)
|
||||
return
|
||||
for path in self._download_dirs.split("\n"):
|
||||
mp_path, qb_path = path.split(":")
|
||||
source_path_map[mp_path] = qb_path
|
||||
source_paths.append(mp_path)
|
||||
# 所有做种源文件路径
|
||||
content_path_set = set()
|
||||
for torrent in all_torrents:
|
||||
content_path_set.add(torrent.content_path)
|
||||
|
||||
message = "检测未做种无效源文件:\n"
|
||||
for source_path_str in source_paths:
|
||||
source_path = Path(source_path_str)
|
||||
# 判断source_path是否存在
|
||||
if not source_path.exists():
|
||||
logger.error(f"{source_path} 不存在,无法检测未做种无效源文件")
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【检测无效源文件】",
|
||||
text=f"{source_path} 不存在,无法检测未做种无效源文件",
|
||||
)
|
||||
continue
|
||||
source_files = []
|
||||
# 获取source_path下的所有文件包括文件夹
|
||||
for file in source_path.iterdir():
|
||||
source_files.append(file)
|
||||
for source_file in source_files:
|
||||
skip = False
|
||||
for key_word in exclude_key_words:
|
||||
if key_word in source_file.name:
|
||||
logger.info(f"{str(source_file)}命中关键字{key_word},不做处理")
|
||||
skip = True
|
||||
break
|
||||
if skip:
|
||||
continue
|
||||
# 将mp_path替换成 qb_path
|
||||
qb_path = (str(source_file)).replace(
|
||||
source_path_str, source_path_map[source_path_str]
|
||||
)
|
||||
# todo: 优化性能
|
||||
is_exist = False
|
||||
for content_path in content_path_set:
|
||||
if qb_path in content_path:
|
||||
is_exist = True
|
||||
break
|
||||
|
||||
if not is_exist:
|
||||
deleted_file_cnt += 1
|
||||
message += f"{deleted_file_cnt}. {str(source_file)}\n"
|
||||
total_size += self.get_size(source_file)
|
||||
if self._delete_invalid_files:
|
||||
if source_file.is_file():
|
||||
source_file.unlink()
|
||||
elif source_file.is_dir():
|
||||
shutil.rmtree(source_file)
|
||||
|
||||
message += f"检测到{deleted_file_cnt}个未做种的无效源文件,共占用{StringUtils.str_filesize(total_size)}空间。\n"
|
||||
if self._delete_invalid_files:
|
||||
message += f"***已删除无效源文件,释放{StringUtils.str_filesize(total_size)}空间!***\n"
|
||||
logger.info(message)
|
||||
if self._notify:
|
||||
message = message.replace("_", "\_")
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【清理无效做种】",
|
||||
text=message,
|
||||
)
|
||||
logger.info("检测无效源文件任务结束")
|
||||
|
||||
def get_size(self, path: Path):
|
||||
total_size = 0
|
||||
if path.is_file():
|
||||
return path.stat().st_size
|
||||
# rglob 方法用于递归遍历所有文件和目录
|
||||
for entry in path.rglob("*"):
|
||||
if entry.is_file():
|
||||
total_size += entry.stat().st_size
|
||||
return total_size
|
||||
|
||||
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": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "delete_invalid_torrents",
|
||||
"label": "删除无效种子(确认无误后再开启)",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "detect_invalid_files",
|
||||
"label": "检测无效源文件",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "delete_invalid_files",
|
||||
"label": "删除无效源文件(确认无误后再开启)",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "notify_all",
|
||||
"label": "全量通知",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "label_only",
|
||||
"label": "仅标记模式(开启后不会删种)",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "more_logs",
|
||||
"label": "打印更多日志",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": { "cols": 12, "md": 6 },
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextField",
|
||||
"props": {
|
||||
"model": "cron",
|
||||
"label": "执行周期",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": { "cols": 12, "md": 6 },
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextField",
|
||||
"props": {
|
||||
"model": "label",
|
||||
"label": "增加标签",
|
||||
"placeholder": "仅标记模式下生效,给待处理的种子打标签",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextarea",
|
||||
"props": {
|
||||
"model": "download_dirs",
|
||||
"label": "下载目录映射",
|
||||
"rows": 2,
|
||||
"placeholder": "填写要监控的源文件目录,并设置MP和QB的目录映射关系,如/mp/download:/qb/download,多个目录请换行",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"props": {"style": {"margin-top": "0px"}},
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextarea",
|
||||
"props": {
|
||||
"model": "exclude_keywords",
|
||||
"label": "过滤删源文件关键字",
|
||||
"rows": 2,
|
||||
"placeholder": "多个关键字请换行,仅针对删除源文件",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextarea",
|
||||
"props": {
|
||||
"model": "exclude_categories",
|
||||
"label": "过滤删种分类",
|
||||
"rows": 2,
|
||||
"placeholder": "多个分类请换行,仅针对删除种子",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 4},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextarea",
|
||||
"props": {
|
||||
"model": "exclude_labels",
|
||||
"label": "过滤删种标签",
|
||||
"rows": 2,
|
||||
"placeholder": "多个标签请换行,仅针对删除种子",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextarea",
|
||||
"props": {
|
||||
"model": "custom_error_msg",
|
||||
"label": "自定义无效做种tracker错误信息",
|
||||
"rows": 5,
|
||||
"placeholder": "填入想要清理的种子的tracker错误信息,如'skipping tracker announce (unreachable)',多个信息请换行",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VAlert",
|
||||
"props": {
|
||||
"type": "error",
|
||||
"variant": "tonal",
|
||||
"text": "谨慎起见删除种子/源文件功能做了开关,请确认无误后再开启删除功能",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VAlert",
|
||||
"props": {
|
||||
"type": "info",
|
||||
"variant": "tonal",
|
||||
"text": "下载目录映射填入源文件根目录,并设置MP和QB的目录映射关系。如某种子下载的源文件A存放路径为/qb/download/A,则目录映射填入/mp/download:/qb/download,多个目录请换行。注意映射目录不要有多余的'/'",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"notify": False,
|
||||
"download_dirs": "",
|
||||
"delete_invalid_torrents": False,
|
||||
"delete_invalid_files": False,
|
||||
"detect_invalid_files": False,
|
||||
"notify_all": False,
|
||||
"onlyonce": False,
|
||||
"cron": "0 0 * * *",
|
||||
"label_only": False,
|
||||
"label": "",
|
||||
"more_logs": False,
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
退出插件
|
||||
"""
|
||||
try:
|
||||
if self._scheduler:
|
||||
self._scheduler.remove_all_jobs()
|
||||
if self._scheduler.running:
|
||||
self._scheduler.shutdown()
|
||||
self._scheduler = None
|
||||
except Exception as e:
|
||||
logger.error("退出插件失败:%s" % str(e))
|
||||
@@ -1,540 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.log import logger
|
||||
from app.plugins import _PluginBase
|
||||
from typing import Any, List, Dict, Tuple
|
||||
|
||||
from app.schemas.types import EventType, MediaImageType, NotificationType, MediaType
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class CloudDiskDel(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "云盘文件删除"
|
||||
# 插件描述
|
||||
plugin_desc = "媒体库删除strm文件后同步删除云盘资源。"
|
||||
# 插件图标
|
||||
plugin_icon = "clouddisk.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.3"
|
||||
# 插件作者
|
||||
plugin_author = "thsrite"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/thsrite"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "clouddiskdel_"
|
||||
# 加载顺序
|
||||
plugin_order = 26
|
||||
# 可使用的用户级别
|
||||
auth_level = 1
|
||||
|
||||
# 私有属性
|
||||
_enabled = False
|
||||
# 任务执行间隔
|
||||
_paths = {}
|
||||
_notify = False
|
||||
_del_history = False
|
||||
|
||||
_video_formats = ('.mp4', '.avi', '.rmvb', '.wmv', '.mov', '.mkv', '.flv', '.ts', '.webm', '.iso', '.mpg')
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._notify = config.get("notify")
|
||||
self._del_history = config.get("del_history")
|
||||
for path in str(config.get("path")).split("\n"):
|
||||
paths = path.split(":")
|
||||
self._paths[paths[0]] = paths[1]
|
||||
|
||||
# 清理插件历史
|
||||
if self._del_history:
|
||||
self.del_data(key="history")
|
||||
self.update_config({
|
||||
"enabled": self._enabled,
|
||||
"notify": self._notify,
|
||||
"path": config.get("path"),
|
||||
"del_history": False
|
||||
})
|
||||
|
||||
@eventmanager.register(EventType.PluginAction)
|
||||
def clouddisk_del(self, event: Event):
|
||||
if not self._enabled:
|
||||
return
|
||||
if not event:
|
||||
return
|
||||
|
||||
event_data = event.event_data
|
||||
if not event_data or event_data.get("action") != "networkdisk_del":
|
||||
return
|
||||
|
||||
logger.info(f"获取到云盘删除请求 {event_data}")
|
||||
|
||||
media_path = event_data.get("media_path")
|
||||
if not media_path:
|
||||
logger.error("未获取到删除路径")
|
||||
return
|
||||
|
||||
media_name = event_data.get("media_name")
|
||||
tmdb_id = event_data.get("tmdb_id")
|
||||
media_type = event_data.get("media_type")
|
||||
season_num = event_data.get("season_num")
|
||||
episode_num = event_data.get("episode_num")
|
||||
|
||||
# 不是网盘监控路径,直接排除
|
||||
cloud_file_flag = False
|
||||
|
||||
# 判断删除媒体路径是否与配置的媒体库路径相符,相符则继续删除,不符则跳过
|
||||
for library_path in list(self._paths.keys()):
|
||||
if str(media_path).startswith(library_path):
|
||||
cloud_file_flag = True
|
||||
# 替换网盘路径
|
||||
media_path = str(media_path).replace(library_path, self._paths.get(library_path))
|
||||
logger.info(f"获取到moviepilot本地云盘挂载路径 {media_path}")
|
||||
path = Path(media_path)
|
||||
if path.is_file() or media_path.endswith(".strm"):
|
||||
# 删除文件、nfo、jpg等同名文件
|
||||
pattern = path.stem.replace('[', '?').replace(']', '?')
|
||||
logger.info(f"开始筛选同名文件 {pattern}")
|
||||
files = path.parent.glob(f"{pattern}.*")
|
||||
|
||||
remove_flag = False
|
||||
for file in files:
|
||||
Path(file).unlink()
|
||||
logger.info(f"云盘文件 {file} 已删除")
|
||||
self.__remove_json(file)
|
||||
remove_flag = True
|
||||
|
||||
if not remove_flag:
|
||||
for ext in self._video_formats:
|
||||
file = path.stem + ext
|
||||
if Path(file).exists():
|
||||
Path(file).unlink()
|
||||
logger.info(f"云盘文件 {file} 已删除")
|
||||
self.__remove_json(file)
|
||||
else:
|
||||
# 非根目录,才删除目录
|
||||
shutil.rmtree(path)
|
||||
# 删除目录
|
||||
logger.warn(f"云盘目录 {path} 已删除")
|
||||
self.__remove_json(path)
|
||||
|
||||
# 判断当前媒体父路径下是否有媒体文件,如有则无需遍历父级
|
||||
if not SystemUtils.exits_files(path.parent, settings.RMT_MEDIAEXT):
|
||||
# 判断父目录是否为空, 为空则删除
|
||||
for parent_path in path.parents:
|
||||
if str(parent_path.parent) != str(path.root):
|
||||
# 父目录非根目录,才删除父目录
|
||||
if not SystemUtils.exits_files(parent_path, settings.RMT_MEDIAEXT):
|
||||
# 当前路径下没有媒体文件则删除
|
||||
shutil.rmtree(parent_path)
|
||||
logger.warn(f"云盘目录 {parent_path} 已删除")
|
||||
self.__remove_json(parent_path)
|
||||
break
|
||||
|
||||
if cloud_file_flag:
|
||||
# 发送消息
|
||||
image = 'https://emby.media/notificationicon.png'
|
||||
media_type = MediaType.MOVIE if media_type in ["Movie", "MOV"] else MediaType.TV
|
||||
if self._notify:
|
||||
backrop_image = self.chain.obtain_specific_image(
|
||||
mediaid=tmdb_id,
|
||||
mtype=media_type,
|
||||
image_type=MediaImageType.Backdrop,
|
||||
season=season_num,
|
||||
episode=episode_num
|
||||
) or image
|
||||
|
||||
# 类型
|
||||
if media_type == MediaType.MOVIE:
|
||||
msg = f'电影 {media_name} {tmdb_id}'
|
||||
# 删除电视剧
|
||||
elif media_type == MediaType.TV and not season_num and not episode_num:
|
||||
msg = f'剧集 {media_name} {tmdb_id}'
|
||||
# 删除季 S02
|
||||
elif media_type == MediaType.TV and season_num and not episode_num:
|
||||
msg = f'剧集 {media_name} S{season_num} {tmdb_id}'
|
||||
# 删除剧集S02E02
|
||||
elif media_type == MediaType.TV and season_num and episode_num:
|
||||
msg = f'剧集 {media_name} S{season_num}E{episode_num} {tmdb_id}'
|
||||
else:
|
||||
msg = media_name
|
||||
|
||||
# 发送通知
|
||||
self.post_message(
|
||||
mtype=NotificationType.MediaServer,
|
||||
title="云盘同步删除任务完成",
|
||||
image=backrop_image,
|
||||
text=f"{msg}\n"
|
||||
f"时间 {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}"
|
||||
)
|
||||
|
||||
# 读取历史记录
|
||||
history = self.get_data('history') or []
|
||||
|
||||
# 获取poster
|
||||
poster_image = self.chain.obtain_specific_image(
|
||||
mediaid=tmdb_id,
|
||||
mtype=media_type,
|
||||
image_type=MediaImageType.Poster,
|
||||
) or image
|
||||
history.append({
|
||||
"type": media_type.value,
|
||||
"title": media_name,
|
||||
"path": media_path,
|
||||
"season": season_num if season_num and str(season_num).isdigit() else None,
|
||||
"episode": episode_num if episode_num and str(episode_num).isdigit() else None,
|
||||
"image": poster_image,
|
||||
"del_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time()))
|
||||
})
|
||||
|
||||
# 保存历史
|
||||
self.save_data("history", history)
|
||||
|
||||
def __remove_json(self, path):
|
||||
"""
|
||||
删除json中的文件内容
|
||||
"""
|
||||
try:
|
||||
# 删除本地缓存文件
|
||||
cloud_files_json = os.path.join(settings.PLUGIN_DATA_PATH, "CloudStrm", "cloud_files.json")
|
||||
if Path(cloud_files_json).exists():
|
||||
# 删除json文件中已删除部分文件
|
||||
# 尝试加载本地
|
||||
with open(cloud_files_json, 'r') as file:
|
||||
content = file.read()
|
||||
if content:
|
||||
__cloud_files = json.loads(content)
|
||||
if __cloud_files:
|
||||
if not isinstance(__cloud_files, list):
|
||||
__cloud_files = [__cloud_files]
|
||||
if str(path) in __cloud_files:
|
||||
# 删除已删除文件
|
||||
__cloud_files.remove(str(path))
|
||||
# 重新写入本地
|
||||
file = open(cloud_files_json, 'w')
|
||||
file.write(json.dumps(__cloud_files))
|
||||
file.close()
|
||||
else:
|
||||
remove_flag = False
|
||||
# 删除目录下文件
|
||||
for cloud_file in __cloud_files:
|
||||
if str(cloud_file).startswith(str(path)):
|
||||
__cloud_files.remove(cloud_file)
|
||||
remove_flag = True
|
||||
if remove_flag:
|
||||
# 重新写入本地
|
||||
file = open(cloud_files_json, 'w')
|
||||
file.write(json.dumps(__cloud_files))
|
||||
file.close()
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
定义远程控制命令
|
||||
:return: 命令关键字、事件、描述、附带数据
|
||||
"""
|
||||
return [{
|
||||
"cmd": "/networkdisk_del",
|
||||
"event": EventType.PluginAction,
|
||||
"desc": "云盘文件删除",
|
||||
"category": "",
|
||||
"data": {
|
||||
"action": "networkdisk_del"
|
||||
}
|
||||
}]
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 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': 'del_history',
|
||||
'label': '删除历史',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextarea',
|
||||
'props': {
|
||||
'model': 'path',
|
||||
'rows': '2',
|
||||
'label': '媒体库路径映射',
|
||||
'placeholder': '媒体服务器路径:moviepilot内云盘挂载路径(一行一个)'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '需要开启媒体库删除插件且正确配置排除路径。'
|
||||
'主要针对于strm文件删除后同步删除云盘资源。'
|
||||
'如遇删除失败,请检查文件权限问题。'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '关于路径映射:'
|
||||
'emby:/data/series/A.mp4,'
|
||||
'moviepilot内云盘挂载路径:/mnt/link/series/A.mp4。'
|
||||
'路径映射填/data:/mnt/link'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"path": "",
|
||||
"notify": False,
|
||||
"del_history": False
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
"""
|
||||
拼装插件详情页面,需要返回页面配置,同时附带数据
|
||||
"""
|
||||
# 查询同步详情
|
||||
historys = self.get_data('history')
|
||||
if not historys:
|
||||
return [
|
||||
{
|
||||
'component': 'div',
|
||||
'text': '暂无数据',
|
||||
'props': {
|
||||
'class': 'text-center',
|
||||
}
|
||||
}
|
||||
]
|
||||
# 数据按时间降序排序
|
||||
historys = sorted(historys, key=lambda x: x.get('del_time'), reverse=True)
|
||||
# 拼装页面
|
||||
contents = []
|
||||
for history in historys:
|
||||
htype = history.get("type")
|
||||
title = history.get("title")
|
||||
season = history.get("season")
|
||||
episode = history.get("episode")
|
||||
image = history.get("image")
|
||||
del_time = history.get("del_time")
|
||||
|
||||
if season:
|
||||
sub_contents = [
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'类型:{htype}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'标题:{title}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'季:{season}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'集:{episode}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'时间:{del_time}'
|
||||
}
|
||||
]
|
||||
else:
|
||||
sub_contents = [
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'类型:{htype}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'标题:{title}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'时间:{del_time}'
|
||||
}
|
||||
]
|
||||
|
||||
contents.append(
|
||||
{
|
||||
'component': 'VCard',
|
||||
'content': [
|
||||
{
|
||||
'component': 'div',
|
||||
'props': {
|
||||
'class': 'd-flex justify-space-start flex-nowrap flex-row',
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'div',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VImg',
|
||||
'props': {
|
||||
'src': image,
|
||||
'height': 120,
|
||||
'width': 80,
|
||||
'aspect-ratio': '2/3',
|
||||
'class': 'object-cover shadow ring-gray-500',
|
||||
'cover': True
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'div',
|
||||
'content': sub_contents
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
'component': 'div',
|
||||
'props': {
|
||||
'class': 'grid gap-3 grid-info-card',
|
||||
},
|
||||
'content': contents
|
||||
}
|
||||
]
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
退出插件
|
||||
"""
|
||||
pass
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,456 +0,0 @@
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.modules.emby import Emby
|
||||
from app.core.config import settings
|
||||
from app.plugins import _PluginBase
|
||||
from app.log import logger
|
||||
from typing import List, Tuple, Dict, Any, Optional
|
||||
import pytz
|
||||
from app.schemas import WebhookEventInfo
|
||||
from app.schemas.types import EventType
|
||||
from app.core.event import eventmanager, Event
|
||||
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
|
||||
class DiagParamAdjust(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "诊断参数调整"
|
||||
# 插件描述
|
||||
plugin_desc = "Emby专用插件|暂时性解决emby字幕偏移问题,需要emby安装Diagnostics插件。"
|
||||
# 插件图标
|
||||
plugin_icon = "Gatus_A.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.3"
|
||||
# 插件作者
|
||||
plugin_author = "jeblove"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/jeblove"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "dpa_"
|
||||
# 加载顺序
|
||||
plugin_order = 14
|
||||
# 可使用的用户级别
|
||||
auth_level = 1
|
||||
|
||||
# 私有属性
|
||||
_enabled: bool = False
|
||||
# 修正字幕偏移用途(播放时执行)
|
||||
_offset_play = True
|
||||
_onlyonce = False
|
||||
_base_url = None
|
||||
_endpoint = None
|
||||
_api_key = None
|
||||
_search_text = None
|
||||
_replace_text = None
|
||||
_cron = None
|
||||
_cron_switch = False
|
||||
|
||||
# 请求接口
|
||||
_url = "[HOST]emby/EncodingDiagnostics/DiagnosticOptions?api_key=[APIKEY]"
|
||||
# 定时器
|
||||
_scheduler: Optional[BackgroundScheduler] = None
|
||||
|
||||
# 目标消息
|
||||
_webhook_actions = {
|
||||
"playback.start": "开始播放",
|
||||
}
|
||||
|
||||
# 分辨率标识
|
||||
_resolution = None
|
||||
# 分辨率改动
|
||||
_last_resolution = None
|
||||
# 目标参数
|
||||
_target_search_text = None
|
||||
_target_replace_text = None
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
# 停止现有任务
|
||||
self.stop_service()
|
||||
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._offset_play = config.get("offset_play")
|
||||
self._onlyonce = config.get("onlyonce")
|
||||
self._search_text = config.get("search")
|
||||
self._replace_text = config.get("replace")
|
||||
self._cron = config.get("cron")
|
||||
self._cron_switch = config.get("cron_switch")
|
||||
|
||||
if self._onlyonce:
|
||||
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
logger.info(f"诊断参数调整服务启动,立刻运行一次")
|
||||
self._scheduler.add_job(func=self.run, trigger='date',
|
||||
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
|
||||
name="诊断参数调整")
|
||||
|
||||
# 关闭一次性开关
|
||||
self._onlyonce = False
|
||||
self.update_config({
|
||||
"enabled": self._enabled,
|
||||
"offset_play": self._offset_play,
|
||||
"onlyonce": False,
|
||||
"search": self._search_text,
|
||||
"replace": self._replace_text,
|
||||
"cron": self._cron,
|
||||
"cron_switch": self._cron_switch,
|
||||
})
|
||||
|
||||
# 启动任务
|
||||
if self._scheduler.get_jobs():
|
||||
self._scheduler.print_jobs()
|
||||
self._scheduler.start()
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_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 and self._cron_switch:
|
||||
return [{
|
||||
"id": "DiagParamAdjust",
|
||||
"name": "诊断参数调整定时服务",
|
||||
"trigger": CronTrigger.from_crontab(self._cron),
|
||||
"func": self.run,
|
||||
"kwargs": {}
|
||||
}]
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enabled',
|
||||
'label': '启用插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'offset_play',
|
||||
'label': '修正字幕偏移(播放时执行)',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'onlyonce',
|
||||
'label': '立即运行一次',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'search',
|
||||
'label': '搜索文本'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'replace',
|
||||
'label': '替换文本'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'cron',
|
||||
'label': '检测执行周期',
|
||||
'placeholder': '*/5 * * * *'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 3
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'cron_switch',
|
||||
'label': '周期模式',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '- 暂时性解决emby字幕偏移问题,如默认参数不合适请在基础上修改【替换文本】x、y至适合(4K视频情况下!),如[x=W/4:y=h/5]。\n - 【修正字幕偏移(播放时执行)】需要emby配置webhooks消息通知:勾选[播放-开始](具体可参考【媒体库服务器通知】插件)',
|
||||
'style': 'white-space: pre-line;'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '- 播放视频分辨率与上次视频分辨率不一致时,在通知延迟和已加载旧位置字幕影响下,需要片刻后才会加载到新位置字幕,或关闭视频再次打开(建议)。\n - 此替换文本参数应用于emby-Diagnostics-Parameter Adjustment。\n - 默认参数用于修改ffmpeg中字幕覆盖在视频上的位置。\n - 方案来源于https://opve.cn/archives/983.html',
|
||||
'style': 'white-space: pre-line;'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"offset_play": True,
|
||||
"onlyonce": False,
|
||||
"search": "x=(W-w)/2:y=(H-h):repeatlast=0",
|
||||
"replace": "x=W/4:y=h/4:repeatlast=0",
|
||||
"cron": "*/5 * * * *",
|
||||
"cron_switch": False,
|
||||
}
|
||||
|
||||
def detect(self):
|
||||
"""
|
||||
检测是否存在目标参数(修正字幕偏移用途)
|
||||
|
||||
:return True: 存在; False: 不存在
|
||||
"""
|
||||
logger.info('字幕偏移修正,检测目标参数')
|
||||
try:
|
||||
res = Emby().get_data(self._url)
|
||||
result = res.json()
|
||||
data = result['Object']['CommandLineOptions']
|
||||
searchText = data['SearchText']
|
||||
replaceText = data['ReplaceText']
|
||||
except json.JSONDecodeError:
|
||||
logger.error('服务停止,Emby请安装【Diagnostics】插件')
|
||||
return None
|
||||
except KeyError:
|
||||
# 已装插件,未设置过该参数
|
||||
# logger.info('目标参数为空')
|
||||
return False
|
||||
|
||||
# 符合所有情况
|
||||
if (('repeatlast' in replaceText
|
||||
and 'x=(W-w)/2:y=(H-h):repeatlast=0' in searchText
|
||||
and result['Object']['TranscodingOptions']['DisableHardwareSubtitleOverlay'] is True)
|
||||
or (searchText == "" and replaceText == "")) \
|
||||
and self._resolution == self._last_resolution:
|
||||
# (A or B) and C
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def set_options(self):
|
||||
"""
|
||||
向Emby发送请求设置参数
|
||||
"""
|
||||
|
||||
# 根据分辨率情况而选择是否替换
|
||||
if self._resolution == 0 and self._offset_play is True:
|
||||
# 1080p,不替换(清空文本)
|
||||
self._target_search_text = ""
|
||||
self._target_replace_text = ""
|
||||
logger.info('清空替换参数')
|
||||
else:
|
||||
# >1080p or 非字幕偏移用途
|
||||
self._target_search_text = self._search_text
|
||||
self._target_replace_text = self._replace_text
|
||||
logger.info("替换值为:{}".format(self._target_replace_text))
|
||||
|
||||
data = {
|
||||
"CommandLineOptions": {
|
||||
"SearchText": self._target_search_text,
|
||||
"ReplaceText": self._target_replace_text
|
||||
},
|
||||
"TranscodingOptions": {
|
||||
"DisableHardwareSubtitleOverlay": True
|
||||
}
|
||||
}
|
||||
data = json.dumps(data)
|
||||
headers = {
|
||||
'Content-Type': 'application/octet-stream'
|
||||
}
|
||||
res = Emby().post_data(self._url, data, headers)
|
||||
if res.status_code // 100 == 2:
|
||||
logger.info('参数设置成功')
|
||||
return True
|
||||
else:
|
||||
logger.error('参数设置失败 {}'.format(res.status_code))
|
||||
return False
|
||||
|
||||
@eventmanager.register(EventType.WebhookMessage)
|
||||
def get_msg(self, event: Event):
|
||||
# 消息方式开关
|
||||
if not self._enabled or not self._offset_play:
|
||||
return
|
||||
|
||||
# 消息获取
|
||||
event_info: WebhookEventInfo = event.event_data
|
||||
if not event_info:
|
||||
return
|
||||
|
||||
# 非目标消息
|
||||
if not self._webhook_actions.get(event_info.event):
|
||||
return
|
||||
|
||||
# 根据视频名获得分辨率信息
|
||||
item_path = event_info.item_path
|
||||
video_resolution = re.findall(r"\d{3,4}p", item_path)
|
||||
video_width = int(video_resolution[0][:-1])
|
||||
logger.info('视频分辨率:{}'.format(video_width))
|
||||
|
||||
self._last_resolution = self._resolution
|
||||
# 分辨率变化情况
|
||||
if video_width > 1080:
|
||||
# 2160p/4k
|
||||
self._resolution = 1
|
||||
else:
|
||||
self._resolution = 0
|
||||
self.run()
|
||||
|
||||
def run(self):
|
||||
# 字幕偏移修正,则带检测
|
||||
if self._offset_play:
|
||||
state = self.detect()
|
||||
if state:
|
||||
logger.info('参数正常,无需修正')
|
||||
return True
|
||||
elif state is None:
|
||||
logger.info('插件退出')
|
||||
return None
|
||||
|
||||
self.set_options()
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
退出插件
|
||||
"""
|
||||
try:
|
||||
if self._scheduler:
|
||||
self._scheduler.remove_all_jobs()
|
||||
if self._scheduler.running:
|
||||
self._scheduler.shutdown()
|
||||
self._scheduler = None
|
||||
except Exception as e:
|
||||
logger.error("退出插件失败:%s" % str(e))
|
||||
@@ -1,872 +0,0 @@
|
||||
import base64
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Dict, Tuple, Optional, Union
|
||||
|
||||
from pydantic import BaseModel
|
||||
from requests import RequestException
|
||||
|
||||
from app import schemas
|
||||
from app.chain.mediaserver import MediaServerChain
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.core.meta import MetaBase
|
||||
from app.log import logger
|
||||
from app.modules.emby import Emby
|
||||
from app.modules.jellyfin import Jellyfin
|
||||
from app.modules.plex import Plex
|
||||
from app.modules.themoviedb.tmdbv3api import TV
|
||||
from app.plugins import _PluginBase
|
||||
from app.schemas.types import EventType
|
||||
from app.utils.common import retry
|
||||
from app.utils.http import RequestUtils
|
||||
|
||||
|
||||
class ExistMediaInfo(BaseModel):
|
||||
# 类型 电影、电视剧
|
||||
type: Optional[schemas.MediaType]
|
||||
# 季, 集
|
||||
groupep: Optional[Dict[int, list]] = {}
|
||||
# 集在媒体服务器的ID
|
||||
groupid: Optional[Dict[int, List[list]]] = {}
|
||||
# 媒体服务器
|
||||
server: Optional[str] = None
|
||||
# 媒体ID
|
||||
itemid: Optional[Union[str, int]] = None
|
||||
|
||||
|
||||
class EpisodeGroupMeta(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "TMDB剧集组刮削"
|
||||
# 插件描述
|
||||
plugin_desc = "从TMDB剧集组刮削季集的实际顺序。"
|
||||
# 插件图标
|
||||
plugin_icon = "Element_A.png"
|
||||
# 主题色
|
||||
plugin_color = "#098663"
|
||||
# 插件版本
|
||||
plugin_version = "1.1"
|
||||
# 插件作者
|
||||
plugin_author = "叮叮当"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/cikezhu"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "EpisodeGroupMeta_"
|
||||
# 加载顺序
|
||||
plugin_order = 29
|
||||
# 可使用的用户级别
|
||||
auth_level = 1
|
||||
|
||||
# 退出事件
|
||||
_event = threading.Event()
|
||||
|
||||
# 私有属性
|
||||
mschain = None
|
||||
tv = None
|
||||
emby = None
|
||||
plex = None
|
||||
jellyfin = None
|
||||
|
||||
_enabled = False
|
||||
_ignorelock = False
|
||||
_delay = 0
|
||||
_allowlist = []
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
self.mschain = MediaServerChain()
|
||||
self.tv = TV()
|
||||
self.emby = Emby()
|
||||
self.plex = Plex()
|
||||
self.jellyfin = Jellyfin()
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._ignorelock = config.get("ignorelock")
|
||||
self._delay = config.get("delay") or 120
|
||||
self._allowlist = []
|
||||
for s in str(config.get("allowlist", "")).split(","):
|
||||
s = s.strip()
|
||||
if s and s not in self._allowlist:
|
||||
self._allowlist.append(s)
|
||||
self.log_info(f"白名单数量: {len(self._allowlist)} > {self._allowlist}")
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enabled',
|
||||
'label': '启用插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'ignorelock',
|
||||
'label': '媒体信息锁定时也进行刮削',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'delay',
|
||||
'label': '入库延迟时间(秒)',
|
||||
'placeholder': '120'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextarea',
|
||||
'props': {
|
||||
'model': 'allowlist',
|
||||
'label': '刮削白名单',
|
||||
'rows': 6,
|
||||
'placeholder': '使用,分隔电视剧名称'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '注意:刮削白名单(留空), 则全部刮削. 否则仅刮削白名单.'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '注意:如需刮削已经入库的项目, 可通过mp重新整理单集即可.'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"ignorelock": False,
|
||||
"allowlist": "",
|
||||
"delay": 120
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
@eventmanager.register(EventType.TransferComplete)
|
||||
def scrap_rt(self, event: Event):
|
||||
"""
|
||||
根据事件实时刮削剧集组信息
|
||||
"""
|
||||
if not self.get_state():
|
||||
return
|
||||
# 事件数据
|
||||
mediainfo: schemas.MediaInfo = event.event_data.get("mediainfo")
|
||||
meta: MetaBase = event.event_data.get("meta")
|
||||
# self.log_error(f"{event.event_data}")
|
||||
if not mediainfo or not meta:
|
||||
return
|
||||
# 非TV类型不处理
|
||||
if mediainfo.type != schemas.MediaType.TV:
|
||||
self.log_warn(f"{mediainfo.title} 非TV类型, 无需处理")
|
||||
return
|
||||
# 没有tmdbID不处理
|
||||
if not mediainfo.tmdb_id:
|
||||
self.log_warn(f"{mediainfo.title} 没有tmdbID, 无需处理")
|
||||
return
|
||||
if len(self._allowlist) != 0 \
|
||||
and mediainfo.title not in self._allowlist:
|
||||
self.log_warn(f"{mediainfo.title} 不在白名单, 无需处理")
|
||||
return
|
||||
# 获取剧集组信息
|
||||
try:
|
||||
episode_groups = self.tv.episode_groups(mediainfo.tmdb_id)
|
||||
if not episode_groups:
|
||||
self.log_warn(f"{mediainfo.title} 没有剧集组, 无需处理")
|
||||
return
|
||||
self.log_info(f"{mediainfo.title_year} 剧集组数量: {len(episode_groups)} - {episode_groups}")
|
||||
# episodegroup = self.tv.group_episodes(episode_groups[0].get('id'))
|
||||
except Exception as e:
|
||||
self.log_error(f"{mediainfo.title} {str(e)}")
|
||||
return
|
||||
# 延迟
|
||||
if self._delay:
|
||||
self.log_warn(f"{mediainfo.title} 将在 {self._delay} 秒后开始处理..")
|
||||
time.sleep(int(self._delay))
|
||||
# 获取可用的媒体服务器
|
||||
_existsinfo = self.chain.media_exists(mediainfo=mediainfo)
|
||||
existsinfo: ExistMediaInfo = self.__media_exists(server=_existsinfo.server, mediainfo=mediainfo,
|
||||
existsinfo=_existsinfo)
|
||||
if not existsinfo or not existsinfo.itemid:
|
||||
self.log_warn(f"{mediainfo.title_year} 在媒体库中不存在")
|
||||
return
|
||||
# 新增需要的属性
|
||||
existsinfo.server = _existsinfo.server
|
||||
existsinfo.type = _existsinfo.type
|
||||
self.log_info(f"{mediainfo.title_year} 存在于媒体服务器: {_existsinfo.server}")
|
||||
# 获取全部剧集组信息
|
||||
copy_keys = ['Id', 'Name', 'ChannelNumber', 'OriginalTitle', 'ForcedSortName', 'SortName', 'CommunityRating',
|
||||
'CriticRating', 'IndexNumber', 'ParentIndexNumber', 'SortParentIndexNumber', 'SortIndexNumber',
|
||||
'DisplayOrder', 'Album', 'AlbumArtists', 'ArtistItems', 'Overview', 'Status', 'Genres', 'Tags',
|
||||
'TagItems', 'Studios', 'PremiereDate', 'DateCreated', 'ProductionYear', 'Video3DFormat',
|
||||
'OfficialRating', 'CustomRating', 'People', 'LockData', 'LockedFields', 'ProviderIds',
|
||||
'PreferredMetadataLanguage', 'PreferredMetadataCountryCode', 'Taglines']
|
||||
for episode_group in episode_groups:
|
||||
if not bool(existsinfo.groupep):
|
||||
break
|
||||
try:
|
||||
id = episode_group.get('id')
|
||||
name = episode_group.get('name')
|
||||
if not id:
|
||||
continue
|
||||
# 处理
|
||||
self.log_info(f"正在匹配剧集组: {id}")
|
||||
groups_meta = self.tv.group_episodes(id)
|
||||
if not groups_meta:
|
||||
continue
|
||||
for groups in groups_meta:
|
||||
if not bool(existsinfo.groupep):
|
||||
break
|
||||
# 剧集组中的季
|
||||
order = groups.get("order")
|
||||
# 剧集组中的集列表
|
||||
episodes = groups.get("episodes")
|
||||
if not order or not episodes or len(episodes) == 0:
|
||||
continue
|
||||
# 进行集数匹配, 确定剧集组信息
|
||||
ep = existsinfo.groupep.get(order)
|
||||
if not ep or len(ep) != len(episodes):
|
||||
continue
|
||||
self.log_info(f"已匹配剧集组: {name}, {id}, 第 {order} 季")
|
||||
# 遍历全部媒体项并更新
|
||||
for _index, _ids in enumerate(existsinfo.groupid.get(order)):
|
||||
# 提取出媒体库中集id对应的集数index
|
||||
ep_num = ep[_index]
|
||||
for _id in _ids:
|
||||
# 获取媒体服务器媒体项
|
||||
iteminfo = self.get_iteminfo(server=existsinfo.server, itemid=_id)
|
||||
if not iteminfo:
|
||||
self.log_info(f"未找到媒体项 - itemid: {_id}, 第 {order} 季, 第 {ep_num} 集")
|
||||
continue
|
||||
# 是否无视项目锁定
|
||||
if not self._ignorelock:
|
||||
if iteminfo.get("LockData") or (
|
||||
"Name" in iteminfo.get("LockedFields", [])
|
||||
and "Overview" in iteminfo.get("LockedFields", [])):
|
||||
self.log_warn(f"已锁定媒体项 - itemid: {_id}, 第 {order} 季, 第 {ep_num} 集")
|
||||
continue
|
||||
# 替换项目数据
|
||||
episode = episodes[ep_num - 1]
|
||||
new_dict = {}
|
||||
new_dict.update({k: v for k, v in iteminfo.items() if k in copy_keys})
|
||||
new_dict["Name"] = episode["name"]
|
||||
new_dict["Overview"] = episode["overview"]
|
||||
new_dict["ParentIndexNumber"] = str(order)
|
||||
new_dict["IndexNumber"] = str(ep_num)
|
||||
new_dict["LockData"] = True
|
||||
if episode.get("vote_average"):
|
||||
new_dict["CommunityRating"] = episode.get("vote_average")
|
||||
if not new_dict["LockedFields"]:
|
||||
new_dict["LockedFields"] = []
|
||||
self.__append_to_list(new_dict["LockedFields"], "Name")
|
||||
self.__append_to_list(new_dict["LockedFields"], "Overview")
|
||||
# 更新数据
|
||||
self.set_iteminfo(server=existsinfo.server, itemid=_id, iteminfo=new_dict)
|
||||
# still_path 图片
|
||||
if episode.get("still_path"):
|
||||
self.set_item_image(server=existsinfo.server, itemid=_id,
|
||||
imageurl=f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{episode['still_path']}")
|
||||
self.log_info(f"已修改剧集 - itemid: {_id}, 第 {order} 季, 第 {ep_num} 集")
|
||||
# 移除已经处理成功的季
|
||||
existsinfo.groupep.pop(order, 0)
|
||||
existsinfo.groupid.pop(order, 0)
|
||||
continue
|
||||
except Exception as e:
|
||||
self.log_warn(f"错误忽略: {str(e)}")
|
||||
continue
|
||||
|
||||
self.log_info(f"{mediainfo.title_year} 已经运行完毕了..")
|
||||
|
||||
@staticmethod
|
||||
def __append_to_list(list, item):
|
||||
if item not in list:
|
||||
list.append(item)
|
||||
|
||||
def __media_exists(self, server: str, mediainfo: schemas.MediaInfo,
|
||||
existsinfo: schemas.ExistMediaInfo) -> ExistMediaInfo:
|
||||
"""
|
||||
根据媒体信息,返回剧集列表与剧集ID列表
|
||||
:param mediainfo: 媒体信息
|
||||
:return: 剧集列表与剧集ID列表
|
||||
"""
|
||||
|
||||
def __emby_media_exists():
|
||||
# 获取系列id
|
||||
item_id = None
|
||||
try:
|
||||
res = self.emby.get_data(("[HOST]emby/Items?"
|
||||
"IncludeItemTypes=Series"
|
||||
"&Fields=ProductionYear"
|
||||
"&StartIndex=0"
|
||||
"&Recursive=true"
|
||||
"&SearchTerm=%s"
|
||||
"&Limit=10"
|
||||
"&IncludeSearchTypes=false"
|
||||
"&api_key=[APIKEY]") % mediainfo.title)
|
||||
res_items = res.json().get("Items")
|
||||
if res_items:
|
||||
for res_item in res_items:
|
||||
if res_item.get('Name') == mediainfo.title and (
|
||||
not mediainfo.year or str(res_item.get('ProductionYear')) == str(mediainfo.year)):
|
||||
item_id = res_item.get('Id')
|
||||
except Exception as e:
|
||||
self.log_error(f"连接Items出错:" + str(e))
|
||||
if not item_id:
|
||||
return None
|
||||
# 验证tmdbid是否相同
|
||||
item_info = self.emby.get_iteminfo(item_id)
|
||||
if item_info:
|
||||
if mediainfo.tmdb_id and item_info.tmdbid:
|
||||
if str(mediainfo.tmdb_id) != str(item_info.tmdbid):
|
||||
self.log_error(f"tmdbid不匹配或不存在")
|
||||
return None
|
||||
try:
|
||||
res_json = self.emby.get_data(
|
||||
"[HOST]emby/Shows/%s/Episodes?Season=&IsMissing=false&api_key=[APIKEY]" % item_id)
|
||||
if res_json:
|
||||
tv_item = res_json.json()
|
||||
res_items = tv_item.get("Items")
|
||||
group_ep = {}
|
||||
group_id = {}
|
||||
for res_item in res_items:
|
||||
season_index = res_item.get("ParentIndexNumber")
|
||||
if not season_index:
|
||||
continue
|
||||
episode_index = res_item.get("IndexNumber")
|
||||
if not episode_index:
|
||||
continue
|
||||
if season_index not in group_ep:
|
||||
group_ep[season_index] = []
|
||||
group_id[season_index] = []
|
||||
if episode_index not in group_ep[season_index]:
|
||||
group_ep[season_index].append(episode_index)
|
||||
group_id[season_index].append([])
|
||||
# 找到准确的插入索引
|
||||
_index = group_ep[season_index].index(episode_index)
|
||||
if res_item.get("Id") not in group_id[season_index][_index]:
|
||||
group_id[season_index][_index].append(res_item.get("Id"))
|
||||
# 返回
|
||||
return ExistMediaInfo(
|
||||
itemid=item_id,
|
||||
groupep=group_ep,
|
||||
groupid=group_id
|
||||
)
|
||||
except Exception as e:
|
||||
self.log_error(f"连接Shows/Id/Episodes出错:{str(e)}")
|
||||
return None
|
||||
|
||||
def __jellyfin_media_exists():
|
||||
# 获取系列id
|
||||
item_id = None
|
||||
try:
|
||||
res = self.jellyfin.get_data(url=f"[HOST]Users/[USER]/Items?api_key=[APIKEY]"
|
||||
f"&searchTerm={mediainfo.title}"
|
||||
f"&IncludeItemTypes=Series"
|
||||
f"&Limit=10&Recursive=true")
|
||||
res_items = res.json().get("Items")
|
||||
if res_items:
|
||||
for res_item in res_items:
|
||||
if res_item.get('Name') == mediainfo.title and (
|
||||
not mediainfo.year or str(res_item.get('ProductionYear')) == str(mediainfo.year)):
|
||||
item_id = res_item.get('Id')
|
||||
except Exception as e:
|
||||
self.log_error(f"连接Items出错:" + str(e))
|
||||
if not item_id:
|
||||
return None
|
||||
# 验证tmdbid是否相同
|
||||
item_info = self.jellyfin.get_iteminfo(item_id)
|
||||
if item_info:
|
||||
if mediainfo.tmdb_id and item_info.tmdbid:
|
||||
if str(mediainfo.tmdb_id) != str(item_info.tmdbid):
|
||||
self.log_error(f"tmdbid不匹配或不存在")
|
||||
return None
|
||||
try:
|
||||
res_json = self.jellyfin.get_data(
|
||||
"[HOST]emby/Shows/%s/Episodes?Season=&IsMissing=false&api_key=[APIKEY]" % item_id)
|
||||
if res_json:
|
||||
tv_item = res_json.json()
|
||||
res_items = tv_item.get("Items")
|
||||
group_ep = {}
|
||||
group_id = {}
|
||||
for res_item in res_items:
|
||||
season_index = res_item.get("ParentIndexNumber")
|
||||
if not season_index:
|
||||
continue
|
||||
episode_index = res_item.get("IndexNumber")
|
||||
if not episode_index:
|
||||
continue
|
||||
if season_index not in group_ep:
|
||||
group_ep[season_index] = []
|
||||
group_id[season_index] = []
|
||||
if episode_index not in group_ep[season_index]:
|
||||
group_ep[season_index].append(episode_index)
|
||||
group_id[season_index].append([])
|
||||
# 找到准确的插入索引
|
||||
_index = group_ep[season_index].index(episode_index)
|
||||
if res_item.get("Id") not in group_id[season_index][_index]:
|
||||
group_id[season_index][_index].append(res_item.get("Id"))
|
||||
# 返回
|
||||
return ExistMediaInfo(
|
||||
itemid=item_id,
|
||||
groupep=group_ep,
|
||||
groupid=group_id
|
||||
)
|
||||
except Exception as e:
|
||||
self.log_error(f"连接Shows/Id/Episodes出错:{str(e)}")
|
||||
return None
|
||||
|
||||
def __plex_media_exists():
|
||||
try:
|
||||
_plex = self.plex.get_plex()
|
||||
if not _plex:
|
||||
return None
|
||||
if existsinfo.itemid:
|
||||
videos = _plex.fetchItem(existsinfo.itemid)
|
||||
else:
|
||||
# 根据标题和年份模糊搜索,该结果不够准确
|
||||
videos = _plex.library.search(title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
libtype="show")
|
||||
if (not videos
|
||||
and mediainfo.original_title
|
||||
and str(mediainfo.original_title) != str(mediainfo.title)):
|
||||
videos = _plex.library.search(title=mediainfo.original_title,
|
||||
year=mediainfo.year,
|
||||
libtype="show")
|
||||
if not videos:
|
||||
return None
|
||||
if isinstance(videos, list):
|
||||
videos = videos[0]
|
||||
video_tmdbid = __get_ids(videos.guids).get('tmdb_id')
|
||||
if mediainfo.tmdb_id and video_tmdbid:
|
||||
if str(video_tmdbid) != str(mediainfo.tmdb_id):
|
||||
self.log_error(f"tmdbid不匹配或不存在")
|
||||
return None
|
||||
episodes = videos.episodes()
|
||||
group_ep = {}
|
||||
group_id = {}
|
||||
for episode in episodes:
|
||||
season_index = episode.seasonNumber
|
||||
if not season_index:
|
||||
continue
|
||||
episode_index = episode.index
|
||||
if not episode_index:
|
||||
continue
|
||||
episode_id = episode.key
|
||||
if not episode_id:
|
||||
continue
|
||||
if season_index not in group_ep:
|
||||
group_ep[season_index] = []
|
||||
group_id[season_index] = []
|
||||
if episode_index not in group_ep[season_index]:
|
||||
group_ep[season_index].append(episode_index)
|
||||
group_id[season_index].append([])
|
||||
# 找到准确的插入索引
|
||||
_index = group_ep[season_index].index(episode_index)
|
||||
if episode_id not in group_id[season_index][_index]:
|
||||
group_id[season_index][_index].append(episode_id)
|
||||
# 返回
|
||||
return ExistMediaInfo(
|
||||
itemid=videos.key,
|
||||
groupep=group_ep,
|
||||
groupid=group_id
|
||||
)
|
||||
except Exception as e:
|
||||
self.log_error(f"连接Shows/Id/Episodes出错:{str(e)}")
|
||||
return None
|
||||
|
||||
def __get_ids(guids: List[Any]) -> dict:
|
||||
guid_mapping = {
|
||||
"imdb://": "imdb_id",
|
||||
"tmdb://": "tmdb_id",
|
||||
"tvdb://": "tvdb_id"
|
||||
}
|
||||
ids = {}
|
||||
for prefix, varname in guid_mapping.items():
|
||||
ids[varname] = None
|
||||
for guid in guids:
|
||||
for prefix, varname in guid_mapping.items():
|
||||
if isinstance(guid, dict):
|
||||
if guid['id'].startswith(prefix):
|
||||
# 找到匹配的ID
|
||||
ids[varname] = guid['id'][len(prefix):]
|
||||
break
|
||||
else:
|
||||
if guid.id.startswith(prefix):
|
||||
# 找到匹配的ID
|
||||
ids[varname] = guid.id[len(prefix):]
|
||||
break
|
||||
return ids
|
||||
|
||||
if server == "emby":
|
||||
return __emby_media_exists()
|
||||
elif server == "jellyfin":
|
||||
return __jellyfin_media_exists()
|
||||
else:
|
||||
return __plex_media_exists()
|
||||
|
||||
def get_iteminfo(self, server: str, itemid: str) -> dict:
|
||||
"""
|
||||
获得媒体项详情
|
||||
"""
|
||||
|
||||
def __get_emby_iteminfo() -> dict:
|
||||
"""
|
||||
获得Emby媒体项详情
|
||||
"""
|
||||
try:
|
||||
url = f'[HOST]emby/Users/[USER]/Items/{itemid}?' \
|
||||
f'Fields=ChannelMappingInfo&api_key=[APIKEY]'
|
||||
res = self.emby.get_data(url=url)
|
||||
if res:
|
||||
return res.json()
|
||||
except Exception as err:
|
||||
self.log_error(f"获取Emby媒体项详情失败:{str(err)}")
|
||||
return {}
|
||||
|
||||
def __get_jellyfin_iteminfo() -> dict:
|
||||
"""
|
||||
获得Jellyfin媒体项详情
|
||||
"""
|
||||
try:
|
||||
url = f'[HOST]Users/[USER]/Items/{itemid}?Fields=ChannelMappingInfo&api_key=[APIKEY]'
|
||||
res = self.jellyfin.get_data(url=url)
|
||||
if res:
|
||||
result = res.json()
|
||||
if result:
|
||||
result['FileName'] = Path(result['Path']).name
|
||||
return result
|
||||
except Exception as err:
|
||||
self.log_error(f"获取Jellyfin媒体项详情失败:{str(err)}")
|
||||
return {}
|
||||
|
||||
def __get_plex_iteminfo() -> dict:
|
||||
"""
|
||||
获得Plex媒体项详情
|
||||
"""
|
||||
iteminfo = {}
|
||||
try:
|
||||
plexitem = self.plex.get_plex().library.fetchItem(ekey=itemid)
|
||||
if 'movie' in plexitem.METADATA_TYPE:
|
||||
iteminfo['Type'] = 'Movie'
|
||||
iteminfo['IsFolder'] = False
|
||||
elif 'episode' in plexitem.METADATA_TYPE:
|
||||
iteminfo['Type'] = 'Series'
|
||||
iteminfo['IsFolder'] = False
|
||||
if 'show' in plexitem.TYPE:
|
||||
iteminfo['ChildCount'] = plexitem.childCount
|
||||
iteminfo['Name'] = plexitem.title
|
||||
iteminfo['Id'] = plexitem.key
|
||||
iteminfo['ProductionYear'] = plexitem.year
|
||||
iteminfo['ProviderIds'] = {}
|
||||
for guid in plexitem.guids:
|
||||
idlist = str(guid.id).split(sep='://')
|
||||
if len(idlist) < 2:
|
||||
continue
|
||||
iteminfo['ProviderIds'][idlist[0]] = idlist[1]
|
||||
for location in plexitem.locations:
|
||||
iteminfo['Path'] = location
|
||||
iteminfo['FileName'] = Path(location).name
|
||||
iteminfo['Overview'] = plexitem.summary
|
||||
iteminfo['CommunityRating'] = plexitem.audienceRating
|
||||
# 增加锁定属性列表
|
||||
iteminfo['LockedFields'] = []
|
||||
try:
|
||||
if plexitem.title.locked:
|
||||
iteminfo['LockedFields'].append('Name')
|
||||
except Exception as err:
|
||||
logger.warn(f"获取Plex媒体项详情失败:{str(err)}")
|
||||
pass
|
||||
try:
|
||||
if plexitem.summary.locked:
|
||||
iteminfo['LockedFields'].append('Overview')
|
||||
except Exception as err:
|
||||
logger.warn(f"获取Plex媒体项详情失败:{str(err)}")
|
||||
pass
|
||||
return iteminfo
|
||||
except Exception as err:
|
||||
self.log_error(f"获取Plex媒体项详情失败:{str(err)}")
|
||||
return {}
|
||||
|
||||
if server == "emby":
|
||||
return __get_emby_iteminfo()
|
||||
elif server == "jellyfin":
|
||||
return __get_jellyfin_iteminfo()
|
||||
else:
|
||||
return __get_plex_iteminfo()
|
||||
|
||||
def set_iteminfo(self, server: str, itemid: str, iteminfo: dict):
|
||||
"""
|
||||
更新媒体项详情
|
||||
"""
|
||||
|
||||
def __set_emby_iteminfo():
|
||||
"""
|
||||
更新Emby媒体项详情
|
||||
"""
|
||||
try:
|
||||
res = self.emby.post_data(
|
||||
url=f'[HOST]emby/Items/{itemid}?api_key=[APIKEY]&reqformat=json',
|
||||
data=json.dumps(iteminfo),
|
||||
headers={
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
if res and res.status_code in [200, 204]:
|
||||
return True
|
||||
else:
|
||||
self.log_error(f"更新Emby媒体项详情失败,错误码:{res.status_code}")
|
||||
return False
|
||||
except Exception as err:
|
||||
self.log_error(f"更新Emby媒体项详情失败:{str(err)}")
|
||||
return False
|
||||
|
||||
def __set_jellyfin_iteminfo():
|
||||
"""
|
||||
更新Jellyfin媒体项详情
|
||||
"""
|
||||
try:
|
||||
res = self.jellyfin.post_data(
|
||||
url=f'[HOST]Items/{itemid}?api_key=[APIKEY]',
|
||||
data=json.dumps(iteminfo),
|
||||
headers={
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
if res and res.status_code in [200, 204]:
|
||||
return True
|
||||
else:
|
||||
self.log_error(f"更新Jellyfin媒体项详情失败,错误码:{res.status_code}")
|
||||
return False
|
||||
except Exception as err:
|
||||
self.log_error(f"更新Jellyfin媒体项详情失败:{str(err)}")
|
||||
return False
|
||||
|
||||
def __set_plex_iteminfo():
|
||||
"""
|
||||
更新Plex媒体项详情
|
||||
"""
|
||||
try:
|
||||
plexitem = self.plex.get_plex().library.fetchItem(ekey=itemid)
|
||||
if 'CommunityRating' in iteminfo and iteminfo['CommunityRating']:
|
||||
edits = {
|
||||
'audienceRating.value': iteminfo['CommunityRating'],
|
||||
'audienceRating.locked': 1
|
||||
}
|
||||
plexitem.edit(**edits)
|
||||
plexitem.editTitle(iteminfo['Name']).editSummary(iteminfo['Overview']).reload()
|
||||
return True
|
||||
except Exception as err:
|
||||
self.log_error(f"更新Plex媒体项详情失败:{str(err)}")
|
||||
return False
|
||||
|
||||
if server == "emby":
|
||||
return __set_emby_iteminfo()
|
||||
elif server == "jellyfin":
|
||||
return __set_jellyfin_iteminfo()
|
||||
else:
|
||||
return __set_plex_iteminfo()
|
||||
|
||||
@retry(RequestException, logger=logger)
|
||||
def set_item_image(self, server: str, itemid: str, imageurl: str):
|
||||
"""
|
||||
更新媒体项图片
|
||||
"""
|
||||
|
||||
def __download_image():
|
||||
"""
|
||||
下载图片
|
||||
"""
|
||||
try:
|
||||
if "doubanio.com" in imageurl:
|
||||
r = RequestUtils(headers={
|
||||
'Referer': "https://movie.douban.com/"
|
||||
}, ua=settings.USER_AGENT).get_res(url=imageurl, raise_exception=True)
|
||||
else:
|
||||
r = RequestUtils().get_res(url=imageurl, raise_exception=True)
|
||||
if r:
|
||||
return base64.b64encode(r.content).decode()
|
||||
else:
|
||||
self.log_error(f"{imageurl} 图片下载失败,请检查网络连通性")
|
||||
except Exception as err:
|
||||
self.log_error(f"下载图片失败:{str(err)}")
|
||||
return None
|
||||
|
||||
def __set_emby_item_image(_base64: str):
|
||||
"""
|
||||
更新Emby媒体项图片
|
||||
"""
|
||||
try:
|
||||
url = f'[HOST]emby/Items/{itemid}/Images/Primary?api_key=[APIKEY]'
|
||||
res = self.emby.post_data(
|
||||
url=url,
|
||||
data=_base64,
|
||||
headers={
|
||||
"Content-Type": "image/png"
|
||||
}
|
||||
)
|
||||
if res and res.status_code in [200, 204]:
|
||||
return True
|
||||
else:
|
||||
self.log_error(f"更新Emby媒体项图片失败,错误码:{res.status_code}")
|
||||
return False
|
||||
except Exception as result:
|
||||
self.log_error(f"更新Emby媒体项图片失败:{result}")
|
||||
return False
|
||||
|
||||
def __set_jellyfin_item_image():
|
||||
"""
|
||||
更新Jellyfin媒体项图片
|
||||
# FIXME 改为预下载图片
|
||||
"""
|
||||
try:
|
||||
url = f'[HOST]Items/{itemid}/RemoteImages/Download?' \
|
||||
f'Type=Primary&ImageUrl={imageurl}&ProviderName=TheMovieDb&api_key=[APIKEY]'
|
||||
res = self.jellyfin.post_data(url=url)
|
||||
if res and res.status_code in [200, 204]:
|
||||
return True
|
||||
else:
|
||||
self.log_error(f"更新Jellyfin媒体项图片失败,错误码:{res.status_code}")
|
||||
return False
|
||||
except Exception as err:
|
||||
self.log_error(f"更新Jellyfin媒体项图片失败:{err}")
|
||||
return False
|
||||
|
||||
def __set_plex_item_image():
|
||||
"""
|
||||
更新Plex媒体项图片
|
||||
# FIXME 改为预下载图片
|
||||
"""
|
||||
try:
|
||||
plexitem = self.plex.get_plex().library.fetchItem(ekey=itemid)
|
||||
plexitem.uploadPoster(url=imageurl)
|
||||
return True
|
||||
except Exception as err:
|
||||
self.log_error(f"更新Plex媒体项图片失败:{err}")
|
||||
return False
|
||||
|
||||
if server == "emby":
|
||||
# 下载图片获取base64
|
||||
image_base64 = __download_image()
|
||||
if image_base64:
|
||||
return __set_emby_item_image(image_base64)
|
||||
elif server == "jellyfin":
|
||||
return __set_jellyfin_item_image()
|
||||
else:
|
||||
return __set_plex_item_image()
|
||||
return None
|
||||
|
||||
def log_error(self, ss: str):
|
||||
logger.error(f"<{self.plugin_name}> {str(ss)}")
|
||||
|
||||
def log_warn(self, ss: str):
|
||||
logger.warn(f"<{self.plugin_name}> {str(ss)}")
|
||||
|
||||
def log_info(self, ss: str):
|
||||
logger.info(f"<{self.plugin_name}> {str(ss)}")
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
停止服务
|
||||
"""
|
||||
pass
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,430 +0,0 @@
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.config import settings
|
||||
from app.plugins import _PluginBase
|
||||
from app.core.event import eventmanager
|
||||
from app.schemas.types import EventType, MessageChannel
|
||||
from app.utils.http import RequestUtils
|
||||
from typing import Any, List, Dict, Tuple, Optional
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class MessageForward(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "消息转发"
|
||||
# 插件描述
|
||||
plugin_desc = "根据正则转发通知到其他WeChat应用。"
|
||||
# 插件图标
|
||||
plugin_icon = "forward.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.1"
|
||||
# 插件作者
|
||||
plugin_author = "thsrite"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/thsrite"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "messageforward_"
|
||||
# 加载顺序
|
||||
plugin_order = 16
|
||||
# 可使用的用户级别
|
||||
auth_level = 1
|
||||
|
||||
# 私有属性
|
||||
_enabled = False
|
||||
_wechat = None
|
||||
_pattern = None
|
||||
_pattern_token = {}
|
||||
|
||||
# 企业微信发送消息URL
|
||||
_send_msg_url = f"{settings.WECHAT_PROXY}/cgi-bin/message/send?access_token=%s"
|
||||
# 企业微信获取TokenURL
|
||||
_token_url = f"{settings.WECHAT_PROXY}/cgi-bin/gettoken?corpid=%s&corpsecret=%s"
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._wechat = config.get("wechat")
|
||||
self._pattern = config.get("pattern")
|
||||
|
||||
# 获取token存库
|
||||
if self._enabled and self._wechat:
|
||||
self.__save_wechat_token()
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enabled',
|
||||
'label': '开启转发'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextarea',
|
||||
'props': {
|
||||
'model': 'wechat',
|
||||
'rows': '5',
|
||||
'label': '应用配置',
|
||||
'placeholder': 'appid:corpid:appsecret(一行一个配置)'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextarea',
|
||||
'props': {
|
||||
'model': 'pattern',
|
||||
'rows': '6',
|
||||
'label': '正则配置',
|
||||
'placeholder': '对应上方应用配置,一行一个,一一对应'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '根据正则表达式,把MoviePilot的消息转发到多个微信应用。'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '应用配置可加注释:'
|
||||
'appid:corpid:appsecret#站点通知'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"wechat": "",
|
||||
"pattern": ""
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
@eventmanager.register(EventType.NoticeMessage)
|
||||
def send(self, event):
|
||||
"""
|
||||
消息转发
|
||||
"""
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
# 消息体
|
||||
data = event.event_data
|
||||
channel = data['channel']
|
||||
if channel and channel != MessageChannel.Wechat:
|
||||
return
|
||||
|
||||
title = data['title']
|
||||
text = data['text']
|
||||
image = data['image']
|
||||
userid = data['userid']
|
||||
|
||||
# 正则匹配
|
||||
patterns = self._pattern.split("\n")
|
||||
for index, pattern in enumerate(patterns):
|
||||
msg_match = re.search(pattern, title)
|
||||
if msg_match:
|
||||
access_token, appid = self.__flush_access_token(index)
|
||||
if not access_token:
|
||||
logger.error("未获取到有效token,请检查配置")
|
||||
continue
|
||||
|
||||
# 发送消息
|
||||
if image:
|
||||
self.__send_image_message(title, text, image, userid, access_token, appid, index)
|
||||
else:
|
||||
self.__send_message(title, text, userid, access_token, appid, index)
|
||||
|
||||
def __save_wechat_token(self):
|
||||
"""
|
||||
获取并存储wechat token
|
||||
"""
|
||||
# 解析配置
|
||||
wechats = self._wechat.split("\n")
|
||||
for index, wechat in enumerate(wechats):
|
||||
# 排除注释
|
||||
wechat = wechat.split("#")[0]
|
||||
wechat_config = wechat.split(":")
|
||||
if len(wechat_config) != 3:
|
||||
logger.error(f"{wechat} 应用配置不正确")
|
||||
continue
|
||||
appid = wechat_config[0]
|
||||
corpid = wechat_config[1]
|
||||
appsecret = wechat_config[2]
|
||||
|
||||
# 已过期,重新获取token
|
||||
access_token, expires_in, access_token_time = self.__get_access_token(corpid=corpid,
|
||||
appsecret=appsecret)
|
||||
if not access_token:
|
||||
# 没有token,获取token
|
||||
logger.error(f"wechat配置 appid = {appid} 获取token失败,请检查配置")
|
||||
continue
|
||||
|
||||
self._pattern_token[index] = {
|
||||
"appid": appid,
|
||||
"corpid": corpid,
|
||||
"appsecret": appsecret,
|
||||
"access_token": access_token,
|
||||
"expires_in": expires_in,
|
||||
"access_token_time": access_token_time,
|
||||
}
|
||||
|
||||
def __flush_access_token(self, index: int, force: bool = False):
|
||||
"""
|
||||
获取第i个配置wechat token
|
||||
"""
|
||||
wechat_token = self._pattern_token[index]
|
||||
if not wechat_token:
|
||||
logger.error(f"未获取到第 {index} 条正则对应的wechat应用token,请检查配置")
|
||||
return None
|
||||
access_token = wechat_token['access_token']
|
||||
expires_in = wechat_token['expires_in']
|
||||
access_token_time = wechat_token['access_token_time']
|
||||
appid = wechat_token['appid']
|
||||
corpid = wechat_token['corpid']
|
||||
appsecret = wechat_token['appsecret']
|
||||
|
||||
# 判断token有效期
|
||||
if force or (datetime.now() - access_token_time).seconds >= expires_in:
|
||||
# 重新获取token
|
||||
access_token, expires_in, access_token_time = self.__get_access_token(corpid=corpid,
|
||||
appsecret=appsecret)
|
||||
if not access_token:
|
||||
logger.error(f"wechat配置 appid = {appid} 获取token失败,请检查配置")
|
||||
return None, None
|
||||
|
||||
self._pattern_token[index] = {
|
||||
"appid": appid,
|
||||
"corpid": corpid,
|
||||
"appsecret": appsecret,
|
||||
"access_token": access_token,
|
||||
"expires_in": expires_in,
|
||||
"access_token_time": access_token_time,
|
||||
}
|
||||
return access_token, appid
|
||||
|
||||
def __send_message(self, title: str, text: str = None, userid: str = None, access_token: str = None,
|
||||
appid: str = None, index: int = None) -> Optional[bool]:
|
||||
"""
|
||||
发送文本消息
|
||||
:param title: 消息标题
|
||||
:param text: 消息内容
|
||||
:param userid: 消息发送对象的ID,为空则发给所有人
|
||||
:return: 发送状态,错误信息
|
||||
"""
|
||||
if text:
|
||||
conent = "%s\n%s" % (title, text.replace("\n\n", "\n"))
|
||||
else:
|
||||
conent = title
|
||||
|
||||
if not userid:
|
||||
userid = "@all"
|
||||
req_json = {
|
||||
"touser": userid,
|
||||
"msgtype": "text",
|
||||
"agentid": appid,
|
||||
"text": {
|
||||
"content": conent
|
||||
},
|
||||
"safe": 0,
|
||||
"enable_id_trans": 0,
|
||||
"enable_duplicate_check": 0
|
||||
}
|
||||
return self.__post_request(access_token=access_token, req_json=req_json, index=index, title=title)
|
||||
|
||||
def __send_image_message(self, title: str, text: str, image_url: str, userid: str = None,
|
||||
access_token: str = None, appid: str = None, index: int = None) -> Optional[bool]:
|
||||
"""
|
||||
发送图文消息
|
||||
:param title: 消息标题
|
||||
:param text: 消息内容
|
||||
:param image_url: 图片地址
|
||||
:param userid: 消息发送对象的ID,为空则发给所有人
|
||||
:return: 发送状态,错误信息
|
||||
"""
|
||||
if text:
|
||||
text = text.replace("\n\n", "\n")
|
||||
if not userid:
|
||||
userid = "@all"
|
||||
req_json = {
|
||||
"touser": userid,
|
||||
"msgtype": "news",
|
||||
"agentid": appid,
|
||||
"news": {
|
||||
"articles": [
|
||||
{
|
||||
"title": title,
|
||||
"description": text,
|
||||
"picurl": image_url,
|
||||
"url": ''
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
return self.__post_request(access_token=access_token, req_json=req_json, index=index, title=title)
|
||||
|
||||
def __post_request(self, access_token: str, req_json: dict, index: int, title: str, retry: int = 0) -> bool:
|
||||
message_url = self._send_msg_url % access_token
|
||||
"""
|
||||
向微信发送请求
|
||||
"""
|
||||
try:
|
||||
res = RequestUtils(content_type='application/json').post(
|
||||
message_url,
|
||||
data=json.dumps(req_json, ensure_ascii=False).encode('utf-8')
|
||||
)
|
||||
if res and res.status_code == 200:
|
||||
ret_json = res.json()
|
||||
if ret_json.get('errcode') == 0:
|
||||
logger.info(f"转发消息 {title} 成功")
|
||||
return True
|
||||
else:
|
||||
if ret_json.get('errcode') == 81013:
|
||||
return False
|
||||
|
||||
logger.error(f"转发消息 {title} 失败,错误信息:{ret_json}")
|
||||
if ret_json.get('errcode') == 42001 or ret_json.get('errcode') == 40014:
|
||||
logger.info("token已过期,正在重新刷新token重试")
|
||||
# 重新获取token
|
||||
access_token, appid = self.__flush_access_token(index=index,
|
||||
force=True)
|
||||
if access_token:
|
||||
retry += 1
|
||||
# 重发请求
|
||||
if retry <= 3:
|
||||
return self.__post_request(access_token=access_token,
|
||||
req_json=req_json,
|
||||
index=index,
|
||||
title=title,
|
||||
retry=retry)
|
||||
return False
|
||||
elif res is not None:
|
||||
logger.error(f"转发消息 {title} 失败,错误码:{res.status_code},错误原因:{res.reason}")
|
||||
return False
|
||||
else:
|
||||
logger.error(f"转发消息 {title} 失败,未获取到返回信息")
|
||||
return False
|
||||
except Exception as err:
|
||||
logger.error(f"转发消息 {title} 异常,错误信息:{str(err)}")
|
||||
return False
|
||||
|
||||
def __get_access_token(self, corpid: str, appsecret: str):
|
||||
"""
|
||||
获取微信Token
|
||||
:return: 微信Token
|
||||
"""
|
||||
try:
|
||||
token_url = self._token_url % (corpid, appsecret)
|
||||
res = RequestUtils().get_res(token_url)
|
||||
if res:
|
||||
ret_json = res.json()
|
||||
if ret_json.get('errcode') == 0:
|
||||
access_token = ret_json.get('access_token')
|
||||
expires_in = ret_json.get('expires_in')
|
||||
access_token_time = datetime.now()
|
||||
|
||||
return access_token, expires_in, access_token_time
|
||||
else:
|
||||
logger.error(f"{ret_json.get('errmsg')}")
|
||||
return None, None, None
|
||||
else:
|
||||
logger.error(f"{corpid} {appsecret} 获取token失败")
|
||||
return None, None, None
|
||||
except Exception as e:
|
||||
logger.error(f"获取微信access_token失败,错误信息:{str(e)}")
|
||||
return None, None, None
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
退出插件
|
||||
"""
|
||||
pass
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,579 +0,0 @@
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Dict, Tuple, Optional
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
from app.core.config import settings
|
||||
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.modules.transmission import Transmission
|
||||
from app.plugins import _PluginBase
|
||||
|
||||
|
||||
class SyncDownloadFiles(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "下载器文件同步"
|
||||
# 插件描述
|
||||
plugin_desc = "同步下载器的文件信息到数据库,删除文件时联动删除下载任务。"
|
||||
# 插件图标
|
||||
plugin_icon = "Youtube-dl_A.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.1.1"
|
||||
# 插件作者
|
||||
plugin_author = "thsrite"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/thsrite"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "syncdownloadfiles_"
|
||||
# 加载顺序
|
||||
plugin_order = 20
|
||||
# 可使用的用户级别
|
||||
auth_level = 1
|
||||
|
||||
# 私有属性
|
||||
_enabled = False
|
||||
# 任务执行间隔
|
||||
_time = None
|
||||
qb = None
|
||||
tr = None
|
||||
_onlyonce = False
|
||||
_history = False
|
||||
_clear = False
|
||||
_downloaders = []
|
||||
_dirs = None
|
||||
downloadhis = None
|
||||
transferhis = None
|
||||
|
||||
# 定时器
|
||||
_scheduler: Optional[BackgroundScheduler] = None
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
# 停止现有任务
|
||||
self.stop_service()
|
||||
|
||||
self.qb = Qbittorrent()
|
||||
self.tr = Transmission()
|
||||
self.downloadhis = DownloadHistoryOper()
|
||||
self.transferhis = TransferHistoryOper()
|
||||
|
||||
if config:
|
||||
self._enabled = config.get('enabled')
|
||||
self._time = config.get('time') or 6
|
||||
self._history = config.get('history')
|
||||
self._clear = config.get('clear')
|
||||
self._onlyonce = config.get("onlyonce")
|
||||
self._downloaders = config.get('downloaders') or []
|
||||
self._dirs = config.get("dirs") or ""
|
||||
|
||||
if self._clear:
|
||||
# 清理下载器文件记录
|
||||
self.downloadhis.truncate_files()
|
||||
# 清理下载器最后处理记录
|
||||
for downloader in self._downloaders:
|
||||
# 获取最后同步时间
|
||||
self.del_data(f"last_sync_time_{downloader}")
|
||||
# 关闭clear
|
||||
self._clear = False
|
||||
self.__update_config()
|
||||
|
||||
if self._onlyonce:
|
||||
# 执行一次
|
||||
# 关闭onlyonce
|
||||
self._onlyonce = False
|
||||
self.__update_config()
|
||||
|
||||
self.sync()
|
||||
|
||||
def sync(self):
|
||||
"""
|
||||
同步所选下载器种子记录
|
||||
"""
|
||||
start_time = datetime.now()
|
||||
logger.info("开始同步下载器任务文件记录")
|
||||
|
||||
if not self._downloaders:
|
||||
logger.error("未选择同步下载器,停止运行")
|
||||
return
|
||||
|
||||
# 遍历下载器同步记录
|
||||
for downloader in self._downloaders:
|
||||
# 获取最后同步时间
|
||||
last_sync_time = self.get_data(f"last_sync_time_{downloader}")
|
||||
|
||||
logger.info(f"开始扫描下载器 {downloader} ...")
|
||||
downloader_obj = self.__get_downloader(downloader)
|
||||
# 获取下载器中已完成的种子
|
||||
torrents = downloader_obj.get_completed_torrents()
|
||||
if torrents:
|
||||
logger.info(f"下载器 {downloader} 已完成种子数:{len(torrents)}")
|
||||
else:
|
||||
logger.info(f"下载器 {downloader} 没有已完成种子")
|
||||
continue
|
||||
|
||||
# 把种子按照名称和种子大小分组,获取添加时间最早的一个,认定为是源种子,其余为辅种
|
||||
torrents = self.__get_origin_torrents(torrents, downloader)
|
||||
logger.info(f"下载器 {downloader} 去除辅种,获取到源种子数:{len(torrents)}")
|
||||
|
||||
for torrent in torrents:
|
||||
# 返回false,标识后续种子已被同步
|
||||
sync_flag = self.__compare_time(torrent, downloader, last_sync_time)
|
||||
|
||||
if not sync_flag:
|
||||
logger.info(f"最后同步时间{last_sync_time}, 之前种子已被同步,结束当前下载器 {downloader} 任务")
|
||||
break
|
||||
|
||||
# 获取种子hash
|
||||
hash_str = self.__get_hash(torrent, downloader)
|
||||
|
||||
# 判断是否是mp下载,判断download_hash是否在downloadhistory表中,是则不处理
|
||||
downloadhis = self.downloadhis.get_by_hash(hash_str)
|
||||
if downloadhis:
|
||||
downlod_files = self.downloadhis.get_files_by_hash(hash_str)
|
||||
if downlod_files:
|
||||
logger.info(f"种子 {hash_str} 通过MoviePilot下载,跳过处理")
|
||||
continue
|
||||
|
||||
# 获取种子download_dir
|
||||
download_dir = self.__get_download_dir(torrent, downloader)
|
||||
|
||||
# 处理路径映射
|
||||
if self._dirs:
|
||||
paths = self._dirs.split("\n")
|
||||
for path in paths:
|
||||
sub_paths = path.split(":")
|
||||
download_dir = download_dir.replace(sub_paths[0], sub_paths[1]).replace('\\', '/')
|
||||
|
||||
# 获取种子name
|
||||
torrent_name = self.__get_torrent_name(torrent, downloader)
|
||||
# 种子保存目录
|
||||
save_path = Path(download_dir).joinpath(torrent_name)
|
||||
# 获取种子文件
|
||||
torrent_files = self.__get_torrent_files(torrent, downloader, downloader_obj)
|
||||
logger.info(f"开始同步种子 {hash_str}, 文件数 {len(torrent_files)}")
|
||||
|
||||
download_files = []
|
||||
for file in torrent_files:
|
||||
# 过滤掉没下载的文件
|
||||
if not self.__is_download(file, downloader):
|
||||
continue
|
||||
# 种子文件路径
|
||||
file_path_str = self.__get_file_path(file, downloader)
|
||||
file_path = Path(file_path_str)
|
||||
# 只处理视频格式
|
||||
if not file_path.suffix \
|
||||
or file_path.suffix not in settings.RMT_MEDIAEXT:
|
||||
continue
|
||||
# 种子文件根路程
|
||||
root_path = file_path.parts[0]
|
||||
# 不含种子名称的种子文件相对路径
|
||||
if root_path == torrent_name:
|
||||
rel_path = str(file_path.relative_to(root_path))
|
||||
else:
|
||||
rel_path = str(file_path)
|
||||
# 完整路径
|
||||
full_path = save_path.joinpath(rel_path)
|
||||
if self._history:
|
||||
transferhis = self.transferhis.get_by_src(str(full_path))
|
||||
if transferhis and not transferhis.download_hash:
|
||||
logger.info(f"开始补充转移记录:{transferhis.id} download_hash {hash_str}")
|
||||
self.transferhis.update_download_hash(historyid=transferhis.id,
|
||||
download_hash=hash_str)
|
||||
|
||||
# 种子文件记录
|
||||
download_files.append(
|
||||
{
|
||||
"download_hash": hash_str,
|
||||
"downloader": downloader,
|
||||
"fullpath": str(full_path),
|
||||
"savepath": str(save_path),
|
||||
"filepath": rel_path,
|
||||
"torrentname": torrent_name,
|
||||
}
|
||||
)
|
||||
|
||||
if download_files:
|
||||
# 登记下载文件
|
||||
self.downloadhis.add_files(download_files)
|
||||
logger.info(f"种子 {hash_str} 同步完成")
|
||||
|
||||
logger.info(f"下载器种子文件同步完成!")
|
||||
self.save_data(f"last_sync_time_{downloader}",
|
||||
time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time())))
|
||||
|
||||
# 计算耗时
|
||||
end_time = datetime.now()
|
||||
|
||||
logger.info(f"下载器任务文件记录已同步完成。总耗时 {(end_time - start_time).seconds} 秒")
|
||||
|
||||
def __update_config(self):
|
||||
self.update_config({
|
||||
"enabled": self._enabled,
|
||||
"time": self._time,
|
||||
"history": self._history,
|
||||
"clear": self._clear,
|
||||
"onlyonce": self._onlyonce,
|
||||
"downloaders": self._downloaders,
|
||||
"dirs": self._dirs
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def __get_origin_torrents(torrents: Any, dl_tpe: str):
|
||||
# 把种子按照名称和种子大小分组,获取添加时间最早的一个,认定为是源种子,其余为辅种
|
||||
grouped_data = {}
|
||||
|
||||
# 排序种子,根据种子添加时间倒序
|
||||
if dl_tpe == "qbittorrent":
|
||||
torrents = sorted(torrents, key=lambda x: x.get("added_on"), reverse=True)
|
||||
# 遍历原始数组,按照size和name进行分组
|
||||
for torrent in torrents:
|
||||
size = torrent.get('size')
|
||||
name = torrent.get('name')
|
||||
key = (size, name) # 使用元组作为字典的键
|
||||
|
||||
# 如果分组键不存在,则将当前元素作为最小元素添加到字典中
|
||||
if key not in grouped_data:
|
||||
grouped_data[key] = torrent
|
||||
else:
|
||||
# 如果分组键已存在,则比较当前元素的time是否更小,如果更小则更新字典中的元素
|
||||
if torrent.get('added_on') < grouped_data[key].get('added_on'):
|
||||
grouped_data[key] = torrent
|
||||
else:
|
||||
torrents = sorted(torrents, key=lambda x: x.added_date, reverse=True)
|
||||
# 遍历原始数组,按照size和name进行分组
|
||||
for torrent in torrents:
|
||||
size = torrent.total_size
|
||||
name = torrent.name
|
||||
key = (size, name) # 使用元组作为字典的键
|
||||
|
||||
# 如果分组键不存在,则将当前元素作为最小元素添加到字典中
|
||||
if key not in grouped_data:
|
||||
grouped_data[key] = torrent
|
||||
else:
|
||||
# 如果分组键已存在,则比较当前元素的time是否更小,如果更小则更新字典中的元素
|
||||
if torrent.added_date < grouped_data[key].added_date:
|
||||
grouped_data[key] = torrent
|
||||
|
||||
# 新的数组
|
||||
return list(grouped_data.values())
|
||||
|
||||
@staticmethod
|
||||
def __compare_time(torrent: Any, dl_tpe: str, last_sync_time: str = None):
|
||||
if last_sync_time:
|
||||
# 获取种子时间
|
||||
if dl_tpe == "qbittorrent":
|
||||
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
|
||||
|
||||
# 之后的种子已经同步了
|
||||
if last_sync_time > str(torrent_date):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def __is_download(file: Any, dl_type: str):
|
||||
"""
|
||||
判断文件是否被下载
|
||||
"""
|
||||
try:
|
||||
if dl_type == "qbittorrent":
|
||||
return True
|
||||
else:
|
||||
return file.completed and file.completed > 0
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def __get_file_path(file: Any, dl_type: str):
|
||||
"""
|
||||
获取文件路径
|
||||
"""
|
||||
try:
|
||||
return file.get("name") if dl_type == "qbittorrent" else file.name
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def __get_torrent_files(torrent: Any, dl_type: str, downloader_obj):
|
||||
"""
|
||||
获取种子文件
|
||||
"""
|
||||
try:
|
||||
return torrent.files if dl_type == "qbittorrent" else downloader_obj.get_files(tid=torrent.id)
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def __get_torrent_name(torrent: Any, dl_type: str):
|
||||
"""
|
||||
获取种子name
|
||||
"""
|
||||
try:
|
||||
return torrent.get("name") if dl_type == "qbittorrent" else torrent.name
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def __get_download_dir(torrent: Any, dl_type: str):
|
||||
"""
|
||||
获取种子download_dir
|
||||
"""
|
||||
try:
|
||||
return torrent.get("save_path") if dl_type == "qbittorrent" else torrent.download_dir
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def __get_hash(torrent: Any, dl_type: str):
|
||||
"""
|
||||
获取种子hash
|
||||
"""
|
||||
try:
|
||||
return torrent.get("hash") if dl_type == "qbittorrent" else torrent.hashString
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return ""
|
||||
|
||||
def __get_downloader(self, dtype: str):
|
||||
"""
|
||||
根据类型返回下载器实例
|
||||
"""
|
||||
if dtype == "qbittorrent":
|
||||
return self.qb
|
||||
elif dtype == "transmission":
|
||||
return self.tr
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return True if self._enabled and self._time else False
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_service(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
注册插件公共服务
|
||||
[{
|
||||
"id": "服务ID",
|
||||
"name": "服务名称",
|
||||
"trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()",
|
||||
"func": self.xxx,
|
||||
"kwargs": {} # 定时器参数
|
||||
}]
|
||||
"""
|
||||
if self.get_state():
|
||||
return [{
|
||||
"id": "SyncDownloadFiles",
|
||||
"name": "同步下载器文件记录服务",
|
||||
"trigger": "interval",
|
||||
"func": self.sync,
|
||||
"kwargs": {"seconds": float(str(self._time).strip()) * 3600}
|
||||
}]
|
||||
return []
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enabled',
|
||||
'label': '开启插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'onlyonce',
|
||||
'label': '立即运行一次',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'history',
|
||||
'label': '补充整理历史记录',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'clear',
|
||||
'label': '清理数据',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'time',
|
||||
'label': '同步时间间隔(小时)'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSelect',
|
||||
'props': {
|
||||
'chips': True,
|
||||
'multiple': True,
|
||||
'model': 'downloaders',
|
||||
'label': '同步下载器',
|
||||
'items': [
|
||||
{'title': 'Qbittorrent', 'value': 'qbittorrent'},
|
||||
{'title': 'Transmission', 'value': 'transmission'}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextarea',
|
||||
'props': {
|
||||
'model': 'dirs',
|
||||
'label': '目录映射',
|
||||
'rows': 5,
|
||||
'placeholder': '每一行一个目录,下载器保存目录:MoviePilot映射目录'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '适用于非MoviePilot下载的任务;下载器种子数据较多时,同步时间将会较长,请耐心等候,可查看实时日志了解同步进度;时间间隔建议最少每6小时执行一次,防止上次任务没处理完。'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"onlyonce": False,
|
||||
"history": False,
|
||||
"clear": False,
|
||||
"time": 6,
|
||||
"dirs": "",
|
||||
"downloaders": []
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
退出插件
|
||||
"""
|
||||
try:
|
||||
if self._scheduler:
|
||||
self._scheduler.remove_all_jobs()
|
||||
if self._scheduler.running:
|
||||
self._scheduler.shutdown()
|
||||
self._scheduler = None
|
||||
except Exception as e:
|
||||
logger.error("退出插件失败:%s" % str(e))
|
||||
@@ -1,454 +0,0 @@
|
||||
from typing import List, Tuple, Dict, Any, Union, Optional
|
||||
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from app.log import logger
|
||||
from app.modules.qbittorrent import Qbittorrent
|
||||
from qbittorrentapi.torrents import TorrentInfoList
|
||||
from app.modules.transmission import Transmission
|
||||
from transmission_rpc.torrent import Torrent
|
||||
from app.plugins import _PluginBase
|
||||
from app.schemas import NotificationType
|
||||
|
||||
|
||||
class TrackerEditor(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "Tracker替换"
|
||||
# 插件描述
|
||||
plugin_desc = "批量替换种子tracker,支持周期性巡检(如为TR,仅支持4.0以上版本)"
|
||||
# 插件图标
|
||||
plugin_icon = "trackereditor_A.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.5"
|
||||
# 插件作者
|
||||
plugin_author = "honue"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/honue"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "trackereditor_"
|
||||
# 加载顺序
|
||||
plugin_order = 30
|
||||
# 可使用的用户级别
|
||||
auth_level = 1
|
||||
|
||||
_downloader_type: str = None
|
||||
_username: str = None
|
||||
_password: str = None
|
||||
_host: str = None
|
||||
_port: int = None
|
||||
_target_domain: str = None
|
||||
_replace_domain: str = None
|
||||
|
||||
_onlyonce: bool = False
|
||||
_downloader: Union[Qbittorrent, Transmission] = None
|
||||
|
||||
_run_con_enable: bool = False
|
||||
_run_con: Optional[str] = None
|
||||
_notify: bool = False
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
if config:
|
||||
self._onlyonce = config.get("onlyonce")
|
||||
self._downloader_type = config.get("downloader_type")
|
||||
self._host = config.get("host")
|
||||
self._port = config.get("port")
|
||||
self._username = config.get("username")
|
||||
self._password = config.get("password")
|
||||
self._target_domain = config.get("target_domain")
|
||||
self._replace_domain = config.get("replace_domain")
|
||||
self._run_con_enable = config.get("run_con_enable")
|
||||
self._run_con = config.get("run_con")
|
||||
self._notify = config.get("notify")
|
||||
|
||||
if self._onlyonce:
|
||||
# 执行替换
|
||||
self.task()
|
||||
self._onlyonce = False
|
||||
# 更新onlyonce属性
|
||||
self.__update_config()
|
||||
|
||||
def task(self):
|
||||
logger.info(f"{'*' * 30}TrackerEditor: 开始执行Tracker替换{'*' * 30}")
|
||||
torrent_total_cnt: int = 0
|
||||
torrent_update_cnt: int = 0
|
||||
if self._downloader_type == "qbittorrent":
|
||||
self._downloader = Qbittorrent(self._host, self._port, self._username, self._password)
|
||||
torrent_info_list: TorrentInfoList
|
||||
torrent_info_list, error = self._downloader.get_torrents()
|
||||
torrent_total_cnt = len(torrent_info_list)
|
||||
if error:
|
||||
return
|
||||
for torrent in torrent_info_list:
|
||||
for tracker in torrent.trackers:
|
||||
if self._target_domain in tracker.url:
|
||||
original_url = tracker.url
|
||||
new_url = tracker.url.replace(self._target_domain, self._replace_domain)
|
||||
logger.info(f"{original_url} 替换为\n {new_url}")
|
||||
torrent.edit_tracker(orig_url=original_url, new_url=new_url)
|
||||
torrent_update_cnt += 1
|
||||
|
||||
elif self._downloader_type == "transmission":
|
||||
self._downloader = Transmission(self._host, self._port, self._username, self._password)
|
||||
tr_version = self._downloader.get_session().get('version')
|
||||
# "4.0.3 (6b0e49bbb2)" "3.00 (bb6b5a062e)"
|
||||
torrent_list: List[Torrent]
|
||||
torrent_list, error = self._downloader.get_torrents()
|
||||
torrent_total_cnt = len(torrent_list)
|
||||
if error:
|
||||
return
|
||||
for torrent in torrent_list:
|
||||
new_tracker_list = []
|
||||
for tracker in torrent.tracker_list:
|
||||
if self._target_domain in tracker:
|
||||
new_url = tracker.replace(self._target_domain, self._replace_domain)
|
||||
new_tracker_list.append(new_url)
|
||||
logger.info(f"{tracker} 替换为\n {new_url}")
|
||||
torrent_update_cnt += 1
|
||||
else:
|
||||
new_tracker_list.append(tracker)
|
||||
if int(tr_version[0]) >= 4:
|
||||
# 版本大于等于4.x
|
||||
__tracker_list = [new_tracker_list]
|
||||
else:
|
||||
__tracker_list = new_tracker_list
|
||||
if torrent_update_cnt > 0:
|
||||
update_result = self._downloader.update_tracker(hash_string=torrent.hashString, tracker_list=__tracker_list)
|
||||
if not update_result:
|
||||
logger.error(f"执行tracker修改出错,中止本次执行")
|
||||
torrent_update_cnt = 0
|
||||
break
|
||||
if torrent_update_cnt == 0:
|
||||
logger.info(f"tracker修改条数为0")
|
||||
logger.info(f"{'*' * 30}TrackerEditor: Tracker替换完成{'*' * 30}")
|
||||
if (self._run_con_enable and self._notify) or (self._onlyonce and self._notify):
|
||||
title = '【Tracker替换】'
|
||||
msg = f'''扫描下载器{self._downloader_type}\n总的种子数: {torrent_total_cnt}\n已修改种子数: {torrent_update_cnt}'''
|
||||
self.send_site_message(title, msg)
|
||||
|
||||
def __update_config(self):
|
||||
self.update_config({
|
||||
"onlyonce": self._onlyonce,
|
||||
"downloader_type": self._downloader_type,
|
||||
"username": self._username,
|
||||
"password": self._password,
|
||||
"host": self._host,
|
||||
"port": self._port,
|
||||
"target_domain": self._target_domain,
|
||||
"replace_domain": self._replace_domain,
|
||||
"run_cron_enable": self._run_con_enable,
|
||||
"run_cron": self._run_con,
|
||||
"notify": self._notify
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'run_con_enable',
|
||||
'label': '启用周期性巡检 (注: 请开启时,务必填写cron表达式)',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'onlyonce',
|
||||
'label': '立即运行一次',
|
||||
}
|
||||
}
|
||||
]
|
||||
}]
|
||||
}, {
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'notify',
|
||||
'label': '发送通知',
|
||||
}
|
||||
}
|
||||
]
|
||||
}]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'run_con',
|
||||
'label': 'cron表达式',
|
||||
'placeholder': '* * * * *'
|
||||
}
|
||||
}
|
||||
]
|
||||
}, {
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSelect',
|
||||
'props': {
|
||||
'model': 'downloader_type',
|
||||
'label': '下载器类型',
|
||||
'items': [
|
||||
{'title': 'Qbittorrent', 'value': 'qbittorrent'},
|
||||
{'title': 'Transmission', 'value': 'transmission'}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}]
|
||||
}, {
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'host',
|
||||
'label': 'host主机ip',
|
||||
'placeholder': '192.168.2.100'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'port',
|
||||
'label': 'qb/tr端口',
|
||||
'placeholder': '8989'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'username',
|
||||
'label': '用户名',
|
||||
'placeholder': 'username'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'password',
|
||||
'label': '密码',
|
||||
'placeholder': 'password'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}, {
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'target_domain',
|
||||
'label': '待替换文本',
|
||||
'placeholder': 'target.com'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'replace_domain',
|
||||
'label': '替换的文本',
|
||||
'placeholder': 'replace.net'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}, {
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '对下载器中所有符合代替换文本的tacker进行字符串replace替换' + '\n' +
|
||||
'现有tracker: https://baidu.com/announce.php?passkey=xxxx' + '\n' +
|
||||
'待替换 baidu.com 或 https://baidu.com' + '\n' +
|
||||
'用于替换的文本 qq.com 或 https://qq.com' + '\n' +
|
||||
'结果为 https://qq.com/announce.php?passkey=xxxx',
|
||||
'style': 'white-space: pre-line;'
|
||||
}
|
||||
},
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '强烈建议自己先添加一个tracker测试替换是否符合预期,程序是否正常运行',
|
||||
'style': 'white-space: pre-line;'
|
||||
}
|
||||
},
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'type': 'info',
|
||||
'variant': 'tonal',
|
||||
'text': '周期性巡检时指的是允许设置间隔一段进行巡检下载器中的种子Tracker' + '\n'
|
||||
'当匹配到等待替换的tracker时,进行替换,其中cron表达式是5位,例如:* * * * * 指的是每过一分钟轮训一次',
|
||||
'style': 'white-space: pre-line;'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"onlyonce": False,
|
||||
"downloader_type": "qbittorrent",
|
||||
"host": "192.168.2.100",
|
||||
"port": 8989,
|
||||
"username": "username",
|
||||
"password": "password",
|
||||
"target_domain": "",
|
||||
"replace_domain": "",
|
||||
"run_con_enable": False,
|
||||
"run_con": "",
|
||||
"notify": True
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return True
|
||||
|
||||
def stop_service(self):
|
||||
pass
|
||||
|
||||
def get_service(self) -> List[Dict[str, Any]]:
|
||||
if self._run_con_enable and self._run_con:
|
||||
logger.info(f"{'*' * 30}TrackerEditor: 注册公共调度服务{'*' * 30}")
|
||||
return [
|
||||
{
|
||||
"id": "TrackerChangeRun",
|
||||
"name": "启用周期性Tracker替换",
|
||||
"trigger": CronTrigger.from_crontab(self._run_con),
|
||||
"func": self.task,
|
||||
"kwargs": {}
|
||||
}]
|
||||
|
||||
return []
|
||||
|
||||
def send_site_message(self, title, message):
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=title,
|
||||
text=message
|
||||
)
|
||||
@@ -1,732 +0,0 @@
|
||||
from typing import List, Tuple, Dict, Any
|
||||
|
||||
from app.log import logger
|
||||
from app.modules.transmission import Transmission
|
||||
from app.plugins import _PluginBase
|
||||
from app.schemas import NotificationType
|
||||
from app.schemas.types import EventType
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from app.core.event import eventmanager, Event
|
||||
import time
|
||||
|
||||
|
||||
class TrCommand(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "TR远程操作"
|
||||
# 插件描述
|
||||
plugin_desc = "通过定时任务或交互命令远程操作TR暂停/开始/限速等。"
|
||||
# 插件图标
|
||||
plugin_icon = "Transmission_A.png"
|
||||
# 插件版本
|
||||
plugin_version = "1.1"
|
||||
# 插件作者
|
||||
plugin_author = "Hoey"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/hoey94"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "trcommand_"
|
||||
# 加载顺序
|
||||
plugin_order = 1
|
||||
# 可使用的用户级别
|
||||
auth_level = 1
|
||||
|
||||
# 私有属性
|
||||
_tr = None
|
||||
_enabled: bool = False
|
||||
_notify: bool = False
|
||||
_pause_cron = None
|
||||
_resume_cron = None
|
||||
_only_pause_once = False
|
||||
_only_resume_once = False
|
||||
_upload_limit = 0
|
||||
_enable_upload_limit = False
|
||||
_download_limit = 0
|
||||
_enable_download_limit = False
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
# 停止现有任务
|
||||
self.stop_service()
|
||||
# 读取配置
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._notify = config.get("notify")
|
||||
self._pause_cron = config.get("pause_cron")
|
||||
self._resume_cron = config.get("resume_cron")
|
||||
self._only_pause_once = config.get("onlypauseonce")
|
||||
self._only_resume_once = config.get("onlyresumeonce")
|
||||
self._download_limit = config.get("download_limit")
|
||||
self._upload_limit = config.get("upload_limit")
|
||||
self._enable_download_limit = config.get("enable_download_limit")
|
||||
self._enable_upload_limit = config.get("enable_upload_limit")
|
||||
self._tr = Transmission()
|
||||
|
||||
if self._only_pause_once or self._only_resume_once:
|
||||
if self._only_pause_once and self._only_resume_once:
|
||||
logger.warning("只能选择一个: 立即暂停或立即开始所有任务")
|
||||
elif self._only_pause_once:
|
||||
self.pause_torrent()
|
||||
elif self._only_resume_once:
|
||||
self.resume_torrent()
|
||||
|
||||
self._only_resume_once = False
|
||||
self._only_pause_once = False
|
||||
self.update_config(
|
||||
{
|
||||
"onlypauseonce": False,
|
||||
"onlyresumeonce": False,
|
||||
"enabled": self._enabled,
|
||||
"notify": self._notify,
|
||||
"pause_cron": self._pause_cron,
|
||||
"resume_cron": self._resume_cron,
|
||||
}
|
||||
)
|
||||
|
||||
# 限速
|
||||
self.set_limit(self._upload_limit, self._download_limit)
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
定义远程控制命令
|
||||
:return: 命令关键字、事件、描述、附带数据
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"cmd": "/pause_torrents",
|
||||
"event": EventType.PluginAction,
|
||||
"desc": "暂停TR种子",
|
||||
"category": "TR",
|
||||
"data": {"action": "pause_torrents"},
|
||||
},
|
||||
{
|
||||
"cmd": "/resume_torrents",
|
||||
"event": EventType.PluginAction,
|
||||
"desc": "开始TR种子",
|
||||
"category": "TR",
|
||||
"data": {"action": "resume_torrents"},
|
||||
},
|
||||
{
|
||||
"cmd": "/toggle_upload_limit",
|
||||
"event": EventType.PluginAction,
|
||||
"desc": "TR切换上传限速状态",
|
||||
"category": "TR",
|
||||
"data": {"action": "toggle_upload_limit"},
|
||||
},
|
||||
{
|
||||
"cmd": "/toggle_download_limit",
|
||||
"event": EventType.PluginAction,
|
||||
"desc": "TR切换下载限速状态",
|
||||
"category": "TR",
|
||||
"data": {"action": "toggle_download_limit"},
|
||||
},
|
||||
]
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
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._pause_cron and self._resume_cron:
|
||||
return [
|
||||
{
|
||||
"id": "TrPause",
|
||||
"name": "暂停TR所有任务",
|
||||
"trigger": CronTrigger.from_crontab(self._pause_cron),
|
||||
"func": self.pause_torrent,
|
||||
"kwargs": {},
|
||||
},
|
||||
{
|
||||
"id": "TrResume",
|
||||
"name": "开始TR所有任务",
|
||||
"trigger": CronTrigger.from_crontab(self._resume_cron),
|
||||
"func": self.resume_torrent,
|
||||
"kwargs": {},
|
||||
},
|
||||
]
|
||||
if self._enabled and self._pause_cron:
|
||||
return [
|
||||
{
|
||||
"id": "TrPause",
|
||||
"name": "暂停TR所有任务",
|
||||
"trigger": CronTrigger.from_crontab(self._pause_cron),
|
||||
"func": self.pause_torrent,
|
||||
"kwargs": {},
|
||||
}
|
||||
]
|
||||
if self._enabled and self._resume_cron:
|
||||
return [
|
||||
{
|
||||
"id": "TrResume",
|
||||
"name": "开始TR所有任务",
|
||||
"trigger": CronTrigger.from_crontab(self._resume_cron),
|
||||
"func": self.resume_torrent,
|
||||
"kwargs": {},
|
||||
}
|
||||
]
|
||||
return []
|
||||
|
||||
def get_all_torrents(self):
|
||||
all_torrents, error = self._tr.get_torrents()
|
||||
if error:
|
||||
logger.error(f"获取TR种子失败: {error}")
|
||||
if self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【TR远程操作】",
|
||||
text=f"获取TR种子失败,请检查TR配置",
|
||||
)
|
||||
return []
|
||||
|
||||
if not all_torrents:
|
||||
logger.warning("TR没有种子")
|
||||
if self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【TR远程操作】",
|
||||
text=f"TR中没有种子",
|
||||
)
|
||||
return []
|
||||
return all_torrents
|
||||
|
||||
@staticmethod
|
||||
def get_torrents_status(torrents):
|
||||
downloading_torrents = []
|
||||
uploading_torrents = []
|
||||
paused_torrents = []
|
||||
checking_torrents = []
|
||||
error_torrents = []
|
||||
for torrent in torrents:
|
||||
match torrent.status.lower():
|
||||
case 'stopped':
|
||||
paused_torrents.append(torrent.id)
|
||||
case 'check_pending':
|
||||
checking_torrents.append(torrent.id)
|
||||
case 'checking':
|
||||
checking_torrents.append(torrent.id)
|
||||
case 'download_pending':
|
||||
downloading_torrents.append(torrent.id)
|
||||
case 'downloading':
|
||||
downloading_torrents.append(torrent.id)
|
||||
case 'seed_pending':
|
||||
uploading_torrents.append(torrent.id)
|
||||
case 'seeding':
|
||||
uploading_torrents.append(torrent.id)
|
||||
|
||||
return (
|
||||
downloading_torrents,
|
||||
uploading_torrents,
|
||||
paused_torrents,
|
||||
checking_torrents,
|
||||
error_torrents,
|
||||
)
|
||||
|
||||
@eventmanager.register(EventType.PluginAction)
|
||||
def handle_pause_torrent(self, event: Event):
|
||||
if not self._enabled:
|
||||
return
|
||||
if event:
|
||||
event_data = event.event_data
|
||||
if not event_data or event_data.get("action") != "pause_torrents":
|
||||
return
|
||||
self.pause_torrent()
|
||||
|
||||
def pause_torrent(self):
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
all_torrents = self.get_all_torrents()
|
||||
hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = (
|
||||
self.get_torrents_status(all_torrents)
|
||||
)
|
||||
to_be_paused = hash_downloading + hash_uploading + hash_checking
|
||||
logger.info(
|
||||
f"暂定任务启动 \n"
|
||||
f"种子总数: {len(all_torrents)} \n"
|
||||
f"做种数量: {len(hash_uploading)}\n"
|
||||
f"下载数量: {len(hash_downloading)}\n"
|
||||
f"检查数量: {len(hash_checking)}\n"
|
||||
f"暂停数量: {len(hash_paused)}\n"
|
||||
f"错误数量: {len(hash_error)}\n"
|
||||
f"暂停操作中请稍等...\n",
|
||||
)
|
||||
if self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【TR暂停任务启动】",
|
||||
text=f"种子总数: {len(all_torrents)} \n"
|
||||
f"做种数量: {len(hash_uploading)}\n"
|
||||
f"下载数量: {len(hash_downloading)}\n"
|
||||
f"检查数量: {len(hash_checking)}\n"
|
||||
f"暂停数量: {len(hash_paused)}\n"
|
||||
f"错误数量: {len(hash_error)}\n"
|
||||
f"暂停操作中请稍等...\n",
|
||||
)
|
||||
if len(to_be_paused) > 0:
|
||||
if self._tr.stop_torrents(ids=to_be_paused):
|
||||
logger.info(f"暂停了{len(to_be_paused)}个种子")
|
||||
else:
|
||||
logger.error(f"暂停种子失败")
|
||||
if self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【TR远程操作】",
|
||||
text=f"暂停种子失败",
|
||||
)
|
||||
# 每个种子等待1ms以让状态切换成功,至少等待1S
|
||||
wait_time = 0.001 * len(to_be_paused) + 1
|
||||
time.sleep(wait_time)
|
||||
|
||||
all_torrents = self.get_all_torrents()
|
||||
hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = (
|
||||
self.get_torrents_status(all_torrents)
|
||||
)
|
||||
logger.info(
|
||||
f"暂定任务完成 \n"
|
||||
f"种子总数: {len(all_torrents)} \n"
|
||||
f"做种数量: {len(hash_uploading)}\n"
|
||||
f"下载数量: {len(hash_downloading)}\n"
|
||||
f"检查数量: {len(hash_checking)}\n"
|
||||
f"暂停数量: {len(hash_paused)}\n"
|
||||
f"错误数量: {len(hash_error)}\n"
|
||||
)
|
||||
if self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【TR暂停任务完成】",
|
||||
text=f"种子总数: {len(all_torrents)} \n"
|
||||
f"做种数量: {len(hash_uploading)}\n"
|
||||
f"下载数量: {len(hash_downloading)}\n"
|
||||
f"检查数量: {len(hash_checking)}\n"
|
||||
f"暂停数量: {len(hash_paused)}\n"
|
||||
f"错误数量: {len(hash_error)}\n",
|
||||
)
|
||||
|
||||
@eventmanager.register(EventType.PluginAction)
|
||||
def handle_resume_torrent(self, event: Event):
|
||||
if not self._enabled:
|
||||
return
|
||||
if event:
|
||||
event_data = event.event_data
|
||||
if not event_data or event_data.get("action") != "resume_torrents":
|
||||
return
|
||||
self.resume_torrent()
|
||||
|
||||
def resume_torrent(self):
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
all_torrents = self.get_all_torrents()
|
||||
hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = (
|
||||
self.get_torrents_status(all_torrents)
|
||||
)
|
||||
logger.info(
|
||||
f"TR开始任务启动 \n"
|
||||
f"种子总数: {len(all_torrents)} \n"
|
||||
f"做种数量: {len(hash_uploading)}\n"
|
||||
f"下载数量: {len(hash_downloading)}\n"
|
||||
f"检查数量: {len(hash_checking)}\n"
|
||||
f"暂停数量: {len(hash_paused)}\n"
|
||||
f"错误数量: {len(hash_error)}\n"
|
||||
f"开始操作中请稍等...\n",
|
||||
)
|
||||
if self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【TR开始任务启动】",
|
||||
text=f"种子总数: {len(all_torrents)} \n"
|
||||
f"做种数量: {len(hash_uploading)}\n"
|
||||
f"下载数量: {len(hash_downloading)}\n"
|
||||
f"检查数量: {len(hash_checking)}\n"
|
||||
f"暂停数量: {len(hash_paused)}\n"
|
||||
f"错误数量: {len(hash_error)}\n"
|
||||
f"开始操作中请稍等...\n",
|
||||
)
|
||||
if not self._tr.start_torrents(ids=hash_paused):
|
||||
logger.error(f"开始种子失败")
|
||||
if self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【TR远程操作】",
|
||||
text=f"开始种子失败",
|
||||
)
|
||||
# 每个种子等待1ms以让状态切换成功,至少等待1S
|
||||
wait_time = 0.001 * len(hash_paused) + 1
|
||||
time.sleep(wait_time)
|
||||
|
||||
all_torrents = self.get_all_torrents()
|
||||
hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = (
|
||||
self.get_torrents_status(all_torrents)
|
||||
)
|
||||
logger.info(
|
||||
f"开始任务完成 \n"
|
||||
f"种子总数: {len(all_torrents)} \n"
|
||||
f"做种数量: {len(hash_uploading)}\n"
|
||||
f"下载数量: {len(hash_downloading)}\n"
|
||||
f"检查数量: {len(hash_checking)}\n"
|
||||
f"暂停数量: {len(hash_paused)}\n"
|
||||
f"错误数量: {len(hash_error)}\n"
|
||||
)
|
||||
if self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【TR开始任务完成】",
|
||||
text=f"种子总数: {len(all_torrents)} \n"
|
||||
f"做种数量: {len(hash_uploading)}\n"
|
||||
f"下载数量: {len(hash_downloading)}\n"
|
||||
f"检查数量: {len(hash_checking)}\n"
|
||||
f"暂停数量: {len(hash_paused)}\n"
|
||||
f"错误数量: {len(hash_error)}\n",
|
||||
)
|
||||
|
||||
@eventmanager.register(EventType.PluginAction)
|
||||
def handle_toggle_upload_limit(self, event: Event):
|
||||
if not self._enabled:
|
||||
return
|
||||
if event:
|
||||
event_data = event.event_data
|
||||
if not event_data or event_data.get("action") != "toggle_upload_limit":
|
||||
return
|
||||
|
||||
self.set_limit(self._upload_limit, self._download_limit)
|
||||
|
||||
@eventmanager.register(EventType.PluginAction)
|
||||
def handle_toggle_download_limit(self, event: Event):
|
||||
if not self._enabled:
|
||||
return
|
||||
if event:
|
||||
event_data = event.event_data
|
||||
if not event_data or event_data.get("action") != "toggle_download_limit":
|
||||
return
|
||||
self.set_limit(self._upload_limit, self._download_limit)
|
||||
|
||||
def set_both_limit(self, upload_limit, download_limit):
|
||||
if not self._enable_upload_limit or not self._enable_upload_limit:
|
||||
return True
|
||||
|
||||
if not upload_limit or not upload_limit.isdigit() or not download_limit or not download_limit.isdigit():
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【TR远程操作】",
|
||||
text=f"设置TR限速失败,download_limit或upload_limit不是一个数值",
|
||||
)
|
||||
return False
|
||||
|
||||
return self._tr.set_speed_limit(
|
||||
download_limit=int(download_limit), upload_limit=int(upload_limit)
|
||||
)
|
||||
|
||||
def set_upload_limit(self, upload_limit):
|
||||
if not self._enable_upload_limit:
|
||||
return True
|
||||
|
||||
if not upload_limit or not upload_limit.isdigit():
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【TR远程操作】",
|
||||
text=f"设置TR限速失败,upload_limit不是一个数值",
|
||||
)
|
||||
return False
|
||||
|
||||
download_limit_current_val, _ = self._tr.get_speed_limit()
|
||||
return self._tr.set_speed_limit(
|
||||
download_limit=int(download_limit_current_val), upload_limit=int(upload_limit)
|
||||
)
|
||||
|
||||
def set_download_limit(self, download_limit):
|
||||
if not self._enable_download_limit:
|
||||
return True
|
||||
|
||||
if not download_limit or not download_limit.isdigit():
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【TR远程操作】",
|
||||
text=f"设置TR限速失败,download_limit不是一个数值",
|
||||
)
|
||||
return False
|
||||
|
||||
_, upload_limit_current_val = self._tr.get_speed_limit()
|
||||
return self._tr.set_speed_limit(
|
||||
download_limit=int(download_limit), upload_limit=int(upload_limit_current_val)
|
||||
)
|
||||
|
||||
def set_limit(self, upload_limit, download_limit):
|
||||
# 限速,满足以下三种情况设置限速
|
||||
# 1. 插件启用 && download_limit启用
|
||||
# 2. 插件启用 && upload_limit启用
|
||||
# 3. 插件启用 && download_limit启用 && upload_limit启用
|
||||
|
||||
flag = None
|
||||
if self._enabled and self._enable_download_limit and self._enable_upload_limit:
|
||||
flag = self.set_both_limit(upload_limit, download_limit)
|
||||
|
||||
elif flag is None and self._enabled and self._enable_download_limit:
|
||||
flag = self.set_download_limit(download_limit)
|
||||
|
||||
elif flag is None and self._enabled and self._enable_upload_limit:
|
||||
flag = self.set_upload_limit(upload_limit)
|
||||
|
||||
if flag:
|
||||
logger.info(f"设置TR限速成功")
|
||||
if self._notify:
|
||||
if upload_limit == 0:
|
||||
text = f"上传无限速"
|
||||
else:
|
||||
text = f"上传限速:{upload_limit} KB/s"
|
||||
if download_limit == 0:
|
||||
text += f"\n下载无限速"
|
||||
else:
|
||||
text += f"\n下载限速:{download_limit} KB/s"
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【TR远程操作】",
|
||||
text=text,
|
||||
)
|
||||
elif not flag:
|
||||
logger.error(f"TR设置限速失败")
|
||||
if self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【TR远程操作】",
|
||||
text=f"设置TR限速失败",
|
||||
)
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"component": "VForm",
|
||||
"content": [
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 6},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "enabled",
|
||||
"label": "启用插件",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 6},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "notify",
|
||||
"label": "发送通知",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 6},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "onlypauseonce",
|
||||
"label": "立即暂停所有任务",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 6},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "onlyresumeonce",
|
||||
"label": "立即开始所有任务",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 6},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextField",
|
||||
"props": {
|
||||
"model": "pause_cron",
|
||||
"label": "暂停周期",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 6},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextField",
|
||||
"props": {
|
||||
"model": "resume_cron",
|
||||
"label": "开始周期",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 6},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "enable_upload_limit",
|
||||
"label": "上传限速",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 6},
|
||||
"content": [
|
||||
{
|
||||
"component": "VSwitch",
|
||||
"props": {
|
||||
"model": "enable_download_limit",
|
||||
"label": "下载限速",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 6},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextField",
|
||||
"props": {
|
||||
"model": "upload_limit",
|
||||
"label": "上传限速 KB/s",
|
||||
"placeholder": "KB/s",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {"cols": 12, "md": 6},
|
||||
"content": [
|
||||
{
|
||||
"component": "VTextField",
|
||||
"props": {
|
||||
"model": "download_limit",
|
||||
"label": "下载限速 KB/s",
|
||||
"placeholder": "KB/s",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VRow",
|
||||
"content": [
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VAlert",
|
||||
"props": {
|
||||
"type": "info",
|
||||
"variant": "tonal",
|
||||
"text": "开始周期和暂停周期使用Cron表达式,如:0 0 0 * *",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VAlert",
|
||||
"props": {
|
||||
"type": "info",
|
||||
"variant": "tonal",
|
||||
"text": "交互命令有暂停TR种子、开始TR种子、TR切换上传限速状态、TR切换下载限速状态",
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"component": "VCol",
|
||||
"props": {
|
||||
"cols": 12,
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"component": "VAlert",
|
||||
"props": {
|
||||
"type": "info",
|
||||
"variant": "tonal",
|
||||
"text": "PT精神重在分享,请勿恶意限速,因此导致账号被封禁作者概不负责",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"notify": True,
|
||||
"onlypauseonce": False,
|
||||
"onlyresumeonce": False,
|
||||
"upload_limit": 0,
|
||||
"download_limit": 0,
|
||||
"enable_upload_limit": False,
|
||||
"enable_download_limit": False,
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
def stop_service(self):
|
||||
pass
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,284 +0,0 @@
|
||||
import concurrent
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
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
|
||||
|
||||
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},
|
||||
]
|
||||
|
||||
ova_patterns = [
|
||||
re.compile(r".*?(OVA|OAD).*?", re.IGNORECASE),
|
||||
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')
|
||||
]
|
||||
|
||||
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:
|
||||
|
||||
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.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)
|
||||
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):
|
||||
# 把所有的[] 里面的内容获取出来,不需要[]本身
|
||||
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 tv_mode(self):
|
||||
logger.info("开始分离季度和集数部分")
|
||||
self.split_season_ep()
|
||||
if not self.vcb_meta.ep_title:
|
||||
return
|
||||
self.parse_season()
|
||||
self.parse_episode()
|
||||
|
||||
def parse_season(self):
|
||||
"""
|
||||
从标题中解析季度
|
||||
"""
|
||||
flag = False
|
||||
for pattern in season_patterns:
|
||||
match = pattern["pattern"].search(self.vcb_meta.season_title)
|
||||
if match:
|
||||
if isinstance(pattern["group"], int):
|
||||
self.vcb_meta.season = int(match.group(pattern["group"]))
|
||||
else:
|
||||
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
|
||||
|
||||
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 culling_blocked_words(self):
|
||||
"""
|
||||
从ep_title中剔除不相关的内容
|
||||
"""
|
||||
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"))
|
||||
Reference in New Issue
Block a user