Merge pull request #22 from thsrite/main

This commit is contained in:
jxxghp
2023-08-03 13:37:49 +08:00
committed by GitHub
3 changed files with 639 additions and 91 deletions

View File

@ -77,3 +77,27 @@ class TransferHistory(Base):
@staticmethod
def count_by_title(db: Session, title: str):
return db.query(func.count(TransferHistory.id)).filter(TransferHistory.title.like(f'%{title}%')).first()[0]
@staticmethod
def list_by(db: Session, mtype: str, title: str, year: int, season=None, episode=None):
"""
据tmdbid、season、season_episode查询转移记录
"""
# 电视剧所有季集|电影
if not season and not episode:
return db.query(TransferHistory).filter(TransferHistory.type == mtype,
TransferHistory.title == title,
TransferHistory.year == year).all()
# 电视剧某季
if season and not episode:
return db.query(TransferHistory).filter(TransferHistory.type == mtype,
TransferHistory.title == title,
TransferHistory.year == year,
TransferHistory.seasons == season).all()
# 电视剧某季某集
if season and episode:
return db.query(TransferHistory).filter(TransferHistory.type == mtype,
TransferHistory.title == title,
TransferHistory.year == year,
TransferHistory.seasons == season,
TransferHistory.episodes == episode).all()

View File

@ -36,3 +36,19 @@ class TransferHistoryOper(DbOper):
"""
return TransferHistory.statistic(self._db, days)
def get_by(self, mtype: str, title: str, year: int, season=None, episode=None) -> Any:
"""
按类型、标题、年份、季集查询转移记录
"""
return TransferHistory.list_by(db=self._db,
mtype=mtype,
title=title,
year=year,
season=season,
episode=episode)
def delete(self, historyid):
"""
删除转移记录
"""
TransferHistory.delete(self._db, historyid)

View File

@ -1,17 +1,27 @@
import datetime
import json
import os
from typing import List, Tuple, Dict, Any
import re
import time
from typing import List, Tuple, Dict, Any, Optional
from app.core.event import eventmanager
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from app.core.config import settings
from app.core.event import eventmanager, Event
from app.db.transferhistory_oper import TransferHistoryOper
from app.log import logger
from app.plugins import _PluginBase
from app.schemas.types import EventType
from app.schemas.types import NotificationType, EventType
from app.utils.http import RequestUtils
class MediaSyncDel(_PluginBase):
# 插件名称
plugin_name = "Emby同步删除"
plugin_name = "媒体库同步删除"
# 插件描述
plugin_desc = "Emby删除媒体后同步删除历史记录或源文件。"
plugin_desc = "媒体库删除媒体后同步删除历史记录或源文件。"
# 插件图标
plugin_icon = "emby.png"
# 主题色
@ -30,127 +40,625 @@ class MediaSyncDel(_PluginBase):
auth_level = 1
# 私有属性
filetransfer = None
_enable = False
_scheduler: Optional[BackgroundScheduler] = None
_enabled = False
_cron: str = ""
_notify = False
_del_source = False
_exclude_path = None
_send_notify = False
_transferhis = None
def init_plugin(self, config: dict = None):
self._transferhis = TransferHistoryOper()
# 停止现有任务
self.stop_service()
# 读取配置
if config:
self._enable = config.get("enable")
self._enabled = config.get("enabled")
self._cron = config.get("cron")
self._notify = config.get("notify")
self._del_source = config.get("del_source")
self._exclude_path = config.get("exclude_path")
self._send_notify = config.get("send_notify")
if self._enabled:
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
if self._cron:
try:
self._scheduler.add_job(func=self.sync_del,
trigger=CronTrigger.from_crontab(self._cron),
name="媒体库同步删除")
except Exception as err:
logger.error(f"定时任务配置错误:{err}")
# 推送实时消息
self.systemmessage.put(f"执行周期配置错误:{err}")
else:
self._scheduler.add_job(self.sync_del, "interval", minutes=30, name="媒体库同步删除")
# 启动任务
if self._scheduler.get_jobs():
self._scheduler.print_jobs()
self._scheduler.start()
@staticmethod
def get_command() -> List[Dict[str, Any]]:
pass
"""
定义远程控制命令
:return: 命令关键字、事件、描述、附带数据
"""
return [{
"cmd": "/sync_del",
"event": EventType.HistoryDeleted,
"desc": "媒体库同步删除",
"data": {}
}]
def get_api(self) -> List[Dict[str, Any]]:
pass
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
pass
"""
拼装插件配置页面需要返回两块数据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': 'notify',
'label': '发送通知',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VSwitch',
'props': {
'model': 'del_source',
'label': '删除源文件',
}
}
]
}
]
},
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'cron',
'label': '执行周期',
'placeholder': '5位cron表达式留空自动'
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 6
},
'content': [
{
'component': 'VTextField',
'props': {
'model': 'exclude_path',
'label': '排除路径'
}
}
]
}
]
},
]
}
], {
"enabled": False,
"notify": True,
"del_source": False,
"cron": "*/30 * * * *",
"exclude_path": "",
}
def get_page(self) -> List[dict]:
pass
"""
拼装插件详情页面,需要返回页面配置,同时附带数据
"""
# 查询同步详情
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:
type = history.get("type")
title = history.get("title")
year = history.get("year")
season = history.get("season")
episode = history.get("episode")
image = history.get("image")
del_time = history.get("del_time")
@eventmanager.register(EventType.WebhookMessage)
def sync_del(self, event):
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': [
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'类型:{type}'
},
{
'component': 'VCardSubtitle',
'props': {
'class': 'pa-2 font-bold break-words whitespace-break-spaces'
},
'content': [
{
'component': 'a',
'props': {
'class': 'pa-0 px-2'
},
'text': title
}
]
},
{
'component': 'VCardText',
'props': {
'class': 'pa-0 px-2'
},
'text': f'年份:{year}'
},
{
'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}'
}
]
}
]
}
]
}
)
return [
{
'component': 'div',
'props': {
'class': 'grid gap-3 grid-info-card',
},
'content': contents
}
]
def sync_del(self):
"""
emby删除媒体库同步删除历史记录
"""
if not self._enable:
return
event_data = event.event_data
event_type = event_data.get("event_type")
if not event_type or str(event_type) != 'media_del':
# 读取历史记录
history = self.get_data('history') or []
# 媒体服务器类型
media_server = settings.MEDIASERVER
last_time = self.get_data("last_time")
del_medias = []
if media_server == 'emby':
del_medias = self.parse_emby_log(last_time)
elif media_server == 'jellyfin':
del_medias = self.parse_jellyfin_log(last_time)
elif media_server == 'plex':
# TODO plex解析日志
return
# 是否虚拟标识
item_isvirtual = event_data.get("item_isvirtual")
if not item_isvirtual:
logger.error("item_isvirtual参数未配置为防止误删除暂停插件运行")
self.update_config({
"enable": False,
"del_source": self._del_source,
"exclude_path": self._exclude_path,
"send_notify": self._send_notify
if not del_medias:
logger.error("未解析到已删除媒体信息")
return
# 遍历删除
for del_media in del_medias:
del_time = del_media.get("time")
# 媒体类型 Movie|Series|Season|Episode
media_type = del_media.get("type")
# 媒体名称 蜀山战纪
media_name = del_media.get("name")
# 媒体年份 2015
media_year = del_media.get("year")
# 媒体路径 /data/series/国产剧/蜀山战纪 (2015)/Season 2/蜀山战纪 - S02E01 - 第1集.mp4
media_path = del_media.get("path")
# id 713083
id = del_media.get("id")
# 季数 S02
media_season = del_media.get("season")
# 集数 E02
media_episode = del_media.get("episode")
# 排除路径不处理
if self._exclude_path and media_path and any(
os.path.abspath(media_path).startswith(os.path.abspath(path)) for path in
self._exclude_path.split(",")):
logger.info(f"媒体路径 {media_path} 已被排除,暂不处理")
return
# 获取删除的记录
# 删除电影
if media_type == "Movie":
msg = f'电影 {media_name}'
transfer_history = self._transferhis.get_by(mtype="电影",
title=media_name,
year=media_year)
logger.info(f"正在同步删除{msg}")
# 删除电视剧
elif media_type == "Series":
msg = f'剧集 {media_name}'
transfer_history = self._transferhis.get_by(mtype="电视剧",
title=media_name,
year=media_year)
logger.info(f"正在同步删除{msg}")
# 删除季 S02
elif media_type == "Season":
msg = f'剧集 {media_name} {media_season}'
transfer_history = self._transferhis.get_by(mtype="电视剧",
title=media_name,
year=media_year,
season=media_season)
logger.info(f"正在同步删除{msg}")
# 删除剧集S02E02
elif media_type == "Episode":
msg = f'剧集 {media_name} {media_season}{media_episode}'
transfer_history = self._transferhis.get_by(mtype="电视剧",
title=media_name,
year=media_year,
season=media_season,
episode=media_episode)
logger.info(f"正在同步删除{msg}")
else:
continue
if not transfer_history:
logger.info(f"未获取到 {msg} 转移记录")
continue
logger.info(f"获取到删除媒体数量 {len(transfer_history)}")
# 开始删除
image = 'https://emby.media/notificationicon.png'
for transferhis in transfer_history:
image = transferhis.image
self._transferhis.delete(transferhis.id)
logger.info(f"同步删除 {msg} 完成!")
# 发送消息
if self._notify:
self.post_message(
mtype=NotificationType.MediaServer,
title="Emby同步删除任务完成",
text=f"{msg}\n"
f"数量 {len(transfer_history)}\n"
f"时间 {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}",
image=image)
history.append({
"type": "电影" if media_type == "Movie" else "电视剧",
"title": media_name,
"year": media_year,
"path": media_path,
"season": media_season,
"episode": media_episode,
"image": image,
"del_time": del_time
})
return
# 如果是虚拟item则直接return不进行删除
if item_isvirtual == 'True':
return
# 保存历史
self.save_data("history", history)
# 媒体类型
media_type = event_data.get("media_type")
# 媒体名称
media_name = event_data.get("media_name")
# 媒体路径
media_path = event_data.get("media_path")
# tmdb_id
tmdb_id = event_data.get("tmdb_id")
# 季数
season_num = event_data.get("season_num")
if season_num and str(season_num).isdigit() and int(season_num) < 10:
season_num = f'0{season_num}'
# 集数
episode_num = event_data.get("episode_num")
if episode_num and str(episode_num).isdigit() and int(episode_num) < 10:
episode_num = f'0{episode_num}'
self.save_data("last_time", datetime.datetime.now())
if not media_type:
logger.error(f"{media_name} 同步删除失败,未获取到媒体类型")
return
if not tmdb_id or not str(tmdb_id).isdigit():
logger.error(f"{media_name} 同步删除失败未获取到TMDB ID")
return
@staticmethod
def parse_emby_log(last_time):
# emby host
emby_host = settings.EMBY_HOST
if emby_host:
if not emby_host.endswith("/"):
emby_host += "/"
if not emby_host.startswith("http"):
emby_host = "http://" + emby_host
if self._exclude_path and media_path and any(
os.path.abspath(media_path).startswith(os.path.abspath(path)) for path in
self._exclude_path.split(",")):
logger.info(f"媒体路径 {media_path} 已被排除,暂不处理")
return
# emby 日志url
log_url = "%sSystem/Logs/embyserver.txt?api_key=%s" % (emby_host, settings.EMBY_API_KEY)
log_res = RequestUtils().get_res(url=log_url)
# TODO 删除电影
if media_type == "Movie":
msg = f'电影 {media_name} {tmdb_id}'
logger.info(f"正在同步删除{msg}")
# TODO 删除电视剧
elif media_type == "Series":
msg = f'剧集 {media_name} {tmdb_id}'
logger.info(f"正在同步删除{msg}")
# TODO 删除季 S02
elif media_type == "Season":
if not season_num or not str(season_num).isdigit():
logger.error(f"{media_name} 季同步删除失败,未获取到具体季")
return
msg = f'剧集 {media_name} S{season_num} {tmdb_id}'
logger.info(f"正在同步删除{msg}")
# 删除剧集S02E02
elif media_type == "Episode":
if not season_num or not str(season_num).isdigit() or not episode_num or not str(episode_num).isdigit():
logger.error(f"{media_name} 集同步删除失败,未获取到具体集")
return
msg = f'剧集 {media_name} S{season_num}E{episode_num} {tmdb_id}'
logger.info(f"正在同步删除{msg}")
else:
return
if not log_res or log_res.status_code != 200:
logger.error("获取emby日志失败请检查服务器配置")
return []
# TODO 开始删除
# TODO 发送消息
if self._send_notify:
pass
logger.info(f"同步删除 {msg} 完成!")
# 正则解析删除的媒体信息
pattern = r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}.\d{3}) Info App: Removing item from database, Type: (\w+), Name: (.*), Path: (.*), Id: (\d+)'
matches = re.findall(pattern, log_res.text)
del_medias = []
# 循环获取媒体信息
for match in matches:
time = match[0]
# 排除已处理的媒体信息
if last_time and time < last_time:
continue
type = match[1]
name = match[2]
path = match[3]
id = match[4]
year = None
year_pattern = r'\(\d+\)'
year_match = re.search(year_pattern, path)
if year_match:
year = year_match.group()[1:-1]
season = None
episode = None
if type == 'Episode' or type == 'Season':
name_pattern = r"\/([\u4e00-\u9fa5]+)(?= \()"
season_pattern = r"Season\s*(\d+)"
episode_pattern = r"S\d+E(\d+)"
name_match = re.search(name_pattern, path)
season_match = re.search(season_pattern, path)
episode_match = re.search(episode_pattern, path)
if name_match:
name = name_match.group(1)
if season_match:
season = season_match.group(1)
if int(season) < 10:
season = f'S0{season}'
else:
season = f'S{season}'
else:
season = None
if episode_match:
episode = episode_match.group(1)
episode = f'E{episode}'
else:
episode = None
media = {
"time": time,
"type": type,
"name": name,
"year": year,
"path": path,
"id": id,
"season": season,
"episode": episode,
}
logger.debug(f"解析到删除媒体:{json.dumps(media)}")
del_medias.append(media)
return del_medias
@staticmethod
def parse_jellyfin_log(last_time):
# jellyfin host
jellyfin_host = settings.JELLYFIN_HOST
if jellyfin_host:
if not jellyfin_host.endswith("/"):
jellyfin_host += "/"
if not jellyfin_host.startswith("http"):
jellyfin_host = "http://" + jellyfin_host
# jellyfin 日志url
log_url = "%sSystem/Logs/jellyfinserver.txt?api_key=%s" % (jellyfin_host, settings.JELLYFIN_API_KEY)
log_res = RequestUtils().get_res(url=log_url)
if not log_res or log_res.status_code != 200:
logger.error("获取jellyfin日志失败请检查服务器配置")
return []
# 正则解析删除的媒体信息
pattern = r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}.\d{3}) Info App: Removing item from database, Type: (\w+), Name: (.*), Path: (.*), Id: (\d+)'
matches = re.findall(pattern, log_res.text)
del_medias = []
# 循环获取媒体信息
for match in matches:
time = match[0]
# 排除已处理的媒体信息
if time < last_time:
continue
type = match[1]
name = match[2]
path = match[3]
id = match[4]
year = None
year_pattern = r'\(\d+\)'
year_match = re.search(year_pattern, path)
if year_match:
year = year_match.group()[1:-1]
season = None
episode = None
if type == 'Episode' or type == 'Season':
name_pattern = r"\/([\u4e00-\u9fa5]+)(?= \()"
season_pattern = r"Season\s*(\d+)"
episode_pattern = r"S\d+E(\d+)"
name_match = re.search(name_pattern, path)
season_match = re.search(season_pattern, path)
episode_match = re.search(episode_pattern, path)
if name_match:
name = name_match.group(1)
if season_match:
season = season_match.group(1)
if int(season) < 10:
season = f'S0{season}'
else:
season = f'S{season}'
else:
season = None
if episode_match:
episode = episode_match.group(1)
episode = f'E{episode}'
else:
episode = None
media = {
"time": time,
"type": type,
"name": name,
"year": year,
"path": path,
"id": id,
"season": season,
"episode": episode,
}
logger.debug(f"解析到删除媒体:{json.dumps(media)}")
del_medias.append(media)
return del_medias
def get_state(self):
return self._enable
return self._enabled
def stop_service(self):
"""
退出插件
"""
pass
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))
@eventmanager.register(EventType.HistoryDeleted)
def remote_sync_del(self, event: Event):
"""
媒体库同步删除
"""
if event:
logger.info("收到命令,开始执行媒体库同步删除 ...")
self.post_message(channel=event.event_data.get("channel"),
title="开始媒体库同步删除 ...",
userid=event.event_data.get("user"))
self.sync_del()
if event:
self.post_message(channel=event.event_data.get("channel"),
title="媒体库同步删除完成!", userid=event.event_data.get("user"))