Merge pull request #45 from thsrite/main

This commit is contained in:
jxxghp 2023-08-08 22:09:26 +08:00 committed by GitHub
commit 9df5fabf92
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 546 additions and 121 deletions

View File

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

View File

@ -43,8 +43,8 @@ class TransferHistoryOper(DbOper):
""" """
return TransferHistory.statistic(self._db, days) return TransferHistory.statistic(self._db, days)
def get_by(self, mtype: str, title: str, year: int, def get_by(self, mtype: str = None, title: str = None, year: int = None,
season: str = None, episode: str = None) -> Any: season: str = None, episode: str = None, tmdbid: str = None) -> Any:
""" """
按类型标题年份季集查询转移记录 按类型标题年份季集查询转移记录
""" """
@ -53,7 +53,8 @@ class TransferHistoryOper(DbOper):
title=title, title=title,
year=year, year=year,
season=season, season=season,
episode=episode) episode=episode,
tmdbid=tmdbid)
def delete(self, historyid): def delete(self, historyid):
""" """

View File

@ -2,6 +2,7 @@ import datetime
import json import json
import os import os
import re import re
import shutil
import time import time
from typing import List, Tuple, Dict, Any, Optional from typing import List, Tuple, Dict, Any, Optional
@ -15,8 +16,10 @@ from app.db.transferhistory_oper import TransferHistoryOper
from app.log import logger from app.log import logger
from app.modules.emby import Emby from app.modules.emby import Emby
from app.modules.jellyfin import Jellyfin from app.modules.jellyfin import Jellyfin
from app.modules.themoviedb.tmdbv3api import Episode
from app.plugins import _PluginBase from app.plugins import _PluginBase
from app.schemas.types import NotificationType, EventType from app.schemas.types import NotificationType, EventType
from app.utils.path_utils import PathUtils
class MediaSyncDel(_PluginBase): class MediaSyncDel(_PluginBase):
@ -44,15 +47,17 @@ class MediaSyncDel(_PluginBase):
# 私有属性 # 私有属性
_scheduler: Optional[BackgroundScheduler] = None _scheduler: Optional[BackgroundScheduler] = None
_enabled = False _enabled = False
_sync_type: str = ""
_cron: str = "" _cron: str = ""
_notify = False _notify = False
_del_source = False _del_source = False
_exclude_path = None _exclude_path = None
_episode = None
_transferhis = None _transferhis = None
def init_plugin(self, config: dict = None): def init_plugin(self, config: dict = None):
self._transferhis = TransferHistoryOper() self._transferhis = TransferHistoryOper()
self.episode = Episode()
# 停止现有任务 # 停止现有任务
self.stop_service() self.stop_service()
@ -60,16 +65,17 @@ class MediaSyncDel(_PluginBase):
# 读取配置 # 读取配置
if config: if config:
self._enabled = config.get("enabled") self._enabled = config.get("enabled")
self._sync_type = config.get("sync_type")
self._cron = config.get("cron") self._cron = config.get("cron")
self._notify = config.get("notify") self._notify = config.get("notify")
self._del_source = config.get("del_source") self._del_source = config.get("del_source")
self._exclude_path = config.get("exclude_path") self._exclude_path = config.get("exclude_path")
if self._enabled: if self._enabled and str(self._sync_type) == "log":
self._scheduler = BackgroundScheduler(timezone=settings.TZ) self._scheduler = BackgroundScheduler(timezone=settings.TZ)
if self._cron: if self._cron:
try: try:
self._scheduler.add_job(func=self.sync_del, self._scheduler.add_job(func=self.sync_del_by_log,
trigger=CronTrigger.from_crontab(self._cron), trigger=CronTrigger.from_crontab(self._cron),
name="媒体库同步删除") name="媒体库同步删除")
except Exception as err: except Exception as err:
@ -77,7 +83,7 @@ class MediaSyncDel(_PluginBase):
# 推送实时消息 # 推送实时消息
self.systemmessage.put(f"执行周期配置错误:{err}") self.systemmessage.put(f"执行周期配置错误:{err}")
else: else:
self._scheduler.add_job(self.sync_del, "interval", minutes=30, name="媒体库同步删除") self._scheduler.add_job(self.sync_del_by_log, "interval", minutes=30, name="媒体库同步删除")
# 启动任务 # 启动任务
if self._scheduler.get_jobs(): if self._scheduler.get_jobs():
@ -115,7 +121,7 @@ class MediaSyncDel(_PluginBase):
'component': 'VCol', 'component': 'VCol',
'props': { 'props': {
'cols': 12, 'cols': 12,
'md': 6 'md': 4
}, },
'content': [ 'content': [
{ {
@ -131,7 +137,7 @@ class MediaSyncDel(_PluginBase):
'component': 'VCol', 'component': 'VCol',
'props': { 'props': {
'cols': 12, 'cols': 12,
'md': 6 'md': 4
}, },
'content': [ 'content': [
{ {
@ -142,17 +148,12 @@ class MediaSyncDel(_PluginBase):
} }
} }
] ]
}
]
}, },
{
'component': 'VRow',
'content': [
{ {
'component': 'VCol', 'component': 'VCol',
'props': { 'props': {
'cols': 12, 'cols': 12,
'md': 6 'md': 4
}, },
'content': [ 'content': [
{ {
@ -173,7 +174,27 @@ class MediaSyncDel(_PluginBase):
'component': 'VCol', 'component': 'VCol',
'props': { 'props': {
'cols': 12, 'cols': 12,
'md': 6 'md': 4
},
'content': [
{
'component': 'VSelect',
'props': {
'model': 'sync_type',
'label': '同步方式',
'items': [
{'title': '日志', 'value': 'log'},
{'title': 'Scripter X', 'value': 'plugin'}
]
}
}
]
},
{
'component': 'VCol',
'props': {
'cols': 12,
'md': 4
}, },
'content': [ 'content': [
{ {
@ -190,7 +211,7 @@ class MediaSyncDel(_PluginBase):
'component': 'VCol', 'component': 'VCol',
'props': { 'props': {
'cols': 12, 'cols': 12,
'md': 6 'md': 4
}, },
'content': [ 'content': [
{ {
@ -204,13 +225,33 @@ class MediaSyncDel(_PluginBase):
} }
] ]
}, },
{
'component': 'VRow',
'content': [
{
'component': 'VCol',
'props': {
'cols': 12,
},
'content': [
{
'component': 'VAlert',
'props': {
'text': '同步方式分为日志同步和Scripter X。日志同步需要配置执行周期默认30分钟执行一次。'
'Scripter X方式需要emby安装并配置Scripter X插件无需配置执行周期。'
}
}
]
}
]
}
] ]
} }
], { ], {
"enabled": False, "enabled": False,
"notify": True, "notify": True,
"del_source": False, "del_source": False,
"sync_type": "log",
"cron": "*/30 * * * *", "cron": "*/30 * * * *",
"exclude_path": "", "exclude_path": "",
} }
@ -341,9 +382,178 @@ class MediaSyncDel(_PluginBase):
} }
] ]
def sync_del(self): @eventmanager.register(EventType.WebhookMessage)
def sync_del_by_plugin(self, event):
""" """
emby删除媒体库同步删除历史记录 emby删除媒体库同步删除历史记录
Scripter X插件
"""
if not self._enabled:
return
event_data = event.event_data
event_type = event_data.get("event_type")
if not event_type or str(event_type) != 'media_del':
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,
"notify": self._notify,
"cron": self._cron,
"sync_type": self._sync_type,
})
return
# 如果是虚拟item则直接return不进行删除
if item_isvirtual == 'True':
return
# 读取历史记录
history = self.get_data('history') or []
# 媒体类型
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'S0{season_num}'
else:
season_num = f'S{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'E0{episode_num}'
else:
episode_num = f'E{episode_num}'
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
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} {tmdb_id}'
transfer_history: List[TransferHistory] = self._transferhis.get_by(tmdbid=tmdb_id)
# 删除电视剧
elif media_type == "Series":
msg = f'剧集 {media_name} {tmdb_id}'
transfer_history: List[TransferHistory] = self._transferhis.get_by(tmdbid=tmdb_id)
# 删除季 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}'
transfer_history: List[TransferHistory] = self._transferhis.get_by(tmdbid=tmdb_id,
season=season_num)
# 删除剧集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}'
transfer_history: List[TransferHistory] = self._transferhis.get_by(tmdbid=tmdb_id,
season=season_num,
episode=episode_num)
else:
return
logger.info(f"正在同步删除{msg}")
if not transfer_history:
logger.warn(f"{media_type} {media_name} 未获取到可删除数据")
return
# 开始删除
del_cnt = 0
image = 'https://emby.media/notificationicon.png'
year = None
for transferhis in transfer_history:
image = transferhis.image
year = transferhis.year
if media_type == "Episode" or media_type == "Movie":
# 如果有剧集或者电影有多个版本的话,需要根据名称筛选下要删除的版本
if os.path.basename(transferhis.dest) != os.path.basename(media_path):
continue
self._transferhis.delete(transferhis.id)
del_cnt += 1
# 删除种子任务
if self._del_source:
del_source = False
if transferhis.download_hash:
try:
self.chain.remove_torrents(transferhis.download_hash)
except Exception as e:
logger.error("删除种子失败,尝试删除源文件:%s" % str(e))
del_source = True
# 直接删除源文件
if del_source:
source_name = os.path.basename(transferhis.src)
source_path = str(transferhis.src).replace(source_name, "")
self.delete_media_file(filedir=source_path,
filename=source_name)
logger.info(f"同步删除 {msg} 完成!")
# 发送消息
if self._notify:
if media_type == "Episode":
# 根据tmdbid获取图片
image = self._episode().images(tv_id=tmdb_id,
season_id=season_num,
episode_id=episode_num,
orginal=True)
# 发送通知
self.post_message(
mtype=NotificationType.MediaServer,
title="媒体库同步删除任务完成",
image=image,
text=f"{msg}\n"
f"数量 {del_cnt}\n"
f"时间 {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}"
)
history.append({
"type": "电影" if media_type == "Movie" else "电视剧",
"title": media_name,
"year": year,
"path": media_path,
"season": season_num,
"episode": episode_num,
"image": image,
"del_time": str(datetime.datetime.now())
})
# 保存历史
self.save_data("history", history)
self.save_data("last_time", datetime.datetime.now())
def sync_del_by_log(self):
"""
emby删除媒体库同步删除历史记录
日志方式
""" """
# 读取历史记录 # 读取历史记录
history = self.get_data('history') or [] history = self.get_data('history') or []
@ -437,8 +647,21 @@ class MediaSyncDel(_PluginBase):
image = transferhis.image image = transferhis.image
self._transferhis.delete(transferhis.id) self._transferhis.delete(transferhis.id)
# 删除种子任务 # 删除种子任务
if self._del_source and transferhis.download_hash: if self._del_source:
del_source = False
if transferhis.download_hash:
try:
self.chain.remove_torrents(transferhis.download_hash) self.chain.remove_torrents(transferhis.download_hash)
except Exception as e:
logger.error("删除种子失败,尝试删除源文件:%s" % str(e))
del_source = True
# 直接删除源文件
if del_source:
source_name = os.path.basename(transferhis.src)
source_path = str(transferhis.src).replace(source_name, "")
self.delete_media_file(filedir=source_path,
filename=source_name)
logger.info(f"同步删除 {msg} 完成!") logger.info(f"同步删除 {msg} 完成!")
@ -613,6 +836,42 @@ class MediaSyncDel(_PluginBase):
return del_medias return del_medias
@staticmethod
def delete_media_file(filedir, filename):
"""
删除媒体文件空目录也会被删除
"""
filedir = os.path.normpath(filedir).replace("\\", "/")
file = os.path.join(filedir, filename)
try:
if not os.path.exists(file):
return False, f"{file} 不存在"
os.remove(file)
nfoname = f"{os.path.splitext(filename)[0]}.nfo"
nfofile = os.path.join(filedir, nfoname)
if os.path.exists(nfofile):
os.remove(nfofile)
# 检查空目录并删除
if re.findall(r"^S\d{2}|^Season", os.path.basename(filedir), re.I):
# 当前是季文件夹,判断并删除
seaon_dir = filedir
if seaon_dir.count('/') > 1 and not PathUtils.get_dir_files(seaon_dir, exts=settings.RMT_MEDIAEXT):
shutil.rmtree(seaon_dir)
# 媒体文件夹
media_dir = os.path.dirname(seaon_dir)
else:
media_dir = filedir
# 检查并删除媒体文件夹,非根目录且目录大于二级,且没有媒体文件时才会删除
if media_dir != '/' \
and media_dir.count('/') > 1 \
and not re.search(r'[a-zA-Z]:/$', media_dir) \
and not PathUtils.get_dir_files(media_dir, exts=settings.RMT_MEDIAEXT):
shutil.rmtree(media_dir)
return True, f"{file} 删除成功"
except Exception as e:
logger.error("删除源文件失败:%s" % str(e))
return True, f"{file} 删除失败"
def get_state(self): def get_state(self):
return self._enabled return self._enabled
@ -639,7 +898,7 @@ class MediaSyncDel(_PluginBase):
self.post_message(channel=event.event_data.get("channel"), self.post_message(channel=event.event_data.get("channel"),
title="开始媒体库同步删除 ...", title="开始媒体库同步删除 ...",
userid=event.event_data.get("user")) userid=event.event_data.get("user"))
self.sync_del() self.sync_del_by_log()
if event: if event:
self.post_message(channel=event.event_data.get("channel"), self.post_message(channel=event.event_data.get("channel"),

155
app/utils/path_utils.py Normal file
View File

@ -0,0 +1,155 @@
import os
class PathUtils:
@staticmethod
def get_dir_files(in_path, exts="", filesize=0, episode_format=None):
"""
获得目录下的媒体文件列表List 按后缀大小格式过滤
"""
if not in_path:
return []
if not os.path.exists(in_path):
return []
ret_list = []
if os.path.isdir(in_path):
for root, dirs, files in os.walk(in_path):
for file in files:
cur_path = os.path.join(root, file)
# 检查路径是否合法
if PathUtils.is_invalid_path(cur_path):
continue
# 检查格式匹配
if episode_format and not episode_format.match(file):
continue
# 检查后缀
if exts and os.path.splitext(file)[-1].lower() not in exts:
continue
# 检查文件大小
if filesize and os.path.getsize(cur_path) < filesize:
continue
# 命中
if cur_path not in ret_list:
ret_list.append(cur_path)
else:
# 检查路径是否合法
if PathUtils.is_invalid_path(in_path):
return []
# 检查后缀
if exts and os.path.splitext(in_path)[-1].lower() not in exts:
return []
# 检查格式
if episode_format and not episode_format.match(os.path.basename(in_path)):
return []
# 检查文件大小
if filesize and os.path.getsize(in_path) < filesize:
return []
ret_list.append(in_path)
return ret_list
@staticmethod
def get_dir_level1_files(in_path, exts=""):
"""
查询目录下的文件只查询一级
"""
ret_list = []
if not os.path.exists(in_path):
return []
for file in os.listdir(in_path):
path = os.path.join(in_path, file)
if os.path.isfile(path):
if not exts or os.path.splitext(file)[-1].lower() in exts:
ret_list.append(path)
return ret_list
@staticmethod
def get_dir_level1_medias(in_path, exts=""):
"""
根据后缀返回目录下所有的文件及文件夹列表只查询一级
"""
ret_list = []
if not os.path.exists(in_path):
return []
if os.path.isdir(in_path):
for file in os.listdir(in_path):
path = os.path.join(in_path, file)
if os.path.isfile(path):
if not exts or os.path.splitext(file)[-1].lower() in exts:
ret_list.append(path)
else:
ret_list.append(path)
else:
ret_list.append(in_path)
return ret_list
@staticmethod
def is_invalid_path(path):
"""
判断是否不能处理的路径
"""
if not path:
return True
if path.find('/@Recycle/') != -1 or path.find('/#recycle/') != -1 or path.find('/.') != -1 or path.find(
'/@eaDir') != -1:
return True
return False
@staticmethod
def is_path_in_path(path1, path2):
"""
判断两个路径是否包含关系 path1 in path2
"""
if not path1 or not path2:
return False
path1 = os.path.normpath(path1).replace("\\", "/")
path2 = os.path.normpath(path2).replace("\\", "/")
if path1 == path2:
return True
path = os.path.dirname(path2)
while True:
if path == path1:
return True
path = os.path.dirname(path)
if path == os.path.dirname(path):
break
return False
@staticmethod
def get_bluray_dir(path):
"""
判断是否蓝光原盘目录是则返回原盘的根目录否则返回空
"""
if not path or not os.path.exists(path):
return None
if os.path.isdir(path):
if os.path.exists(os.path.join(path, "BDMV", "index.bdmv")):
return path
elif os.path.normpath(path).endswith("BDMV") \
and os.path.exists(os.path.join(path, "index.bdmv")):
return os.path.dirname(path)
elif os.path.normpath(path).endswith("STREAM") \
and os.path.exists(os.path.join(os.path.dirname(path), "index.bdmv")):
return PathUtils.get_parent_paths(path, 2)
else:
# 电视剧原盘下会存在多个目录形如Spider Man 2021/DIsc1, Spider Man 2021/Disc2
for level1 in PathUtils.get_dir_level1_medias(path):
if os.path.exists(os.path.join(level1, "BDMV", "index.bdmv")):
return path
return None
else:
if str(os.path.splitext(path)[-1]).lower() in [".m2ts", ".ts"] \
and os.path.normpath(os.path.dirname(path)).endswith("STREAM") \
and os.path.exists(os.path.join(PathUtils.get_parent_paths(path, 2), "index.bdmv")):
return PathUtils.get_parent_paths(path, 3)
else:
return None
@staticmethod
def get_parent_paths(path, level: int = 1):
"""
获取父目录路径level为向上查找的层数
"""
for lv in range(0, level):
path = os.path.dirname(path)
return path