Compare commits

...

15 Commits

Author SHA1 Message Date
jxxghp
a2096e8e0f v2.0.2 2024-11-08 13:26:05 +08:00
jxxghp
75e80158e5 Merge pull request #3030 from Akimio521/fix(tmdb/douban)-cache 2024-11-08 10:48:23 +08:00
Akimio521
d42bd14288 fix: 优先使用id作为cache key避免key冲突 2024-11-08 10:35:29 +08:00
jxxghp
28f6e7f9bb fix https://github.com/jxxghp/MoviePilot-Plugins/issues/540 2024-11-07 18:58:32 +08:00
jxxghp
2aadbeaed7 Merge pull request #3025 from amtoaer/feat_jellyfin_item_path 2024-11-07 18:48:15 +08:00
jxxghp
3f6b4bf3f2 Merge pull request #3022 from MMZOX/v2 2024-11-07 18:46:59 +08:00
amtoaer
f73750fcf7 feat: 为 jellyfin 的 webhook 事件填充 item_path 字段 2024-11-07 15:01:19 +08:00
MMZOX
59df673eb5 try to fix #2965 2024-11-07 13:45:06 +08:00
jxxghp
e29ab92cd1 fix #3008 2024-11-07 08:27:05 +08:00
jxxghp
3777045a17 fix #3012 2024-11-07 08:24:22 +08:00
jxxghp
16165c0fcc fix #3018 2024-11-07 08:20:11 +08:00
jxxghp
4d377d5e04 Merge pull request #3016 from InfinityPacer/feature/scheduler 2024-11-06 20:01:14 +08:00
InfinityPacer
76c84f9bac fix(scheduler): optimize job registration and removal logic 2024-11-06 19:37:22 +08:00
jxxghp
88f91152d6 Merge pull request #3009 from lybtt/fix_local_storage 2024-11-06 10:52:15 +08:00
lvyb
dfdb88c5ac fix softlink 2024-11-06 09:30:53 +08:00
12 changed files with 110 additions and 55 deletions

View File

@@ -335,6 +335,8 @@ class MediaChain(ChainBase, metaclass=Singleton):
:param _path: 元数据文件路径
:param _content: 文件内容
"""
if not _fileitem or not _content or not _path:
return
tmp_file = settings.TEMP_PATH / _path.name
tmp_file.write_bytes(_content)
logger.info(f"保存文件:【{_fileitem.storage}{_path}")
@@ -356,6 +358,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
logger.info(f"{_url} 图片下载失败,请检查网络连通性!")
except Exception as err:
logger.error(f"{_url} 图片下载失败:{str(err)}")
return None
# 当前文件路径
filepath = Path(fileitem.path)
@@ -410,7 +413,8 @@ class MediaChain(ChainBase, metaclass=Singleton):
# 下载图片
content = __download_image(_url=attr_value)
# 写入图片到当前目录
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
if content:
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
else:
# 电视剧
if fileitem.type == "file":
@@ -447,7 +451,8 @@ class MediaChain(ChainBase, metaclass=Singleton):
# 下载图片
content = __download_image(image_url)
# 保存图片文件到当前目录
__save_file(_fileitem=parent, _path=image_path, _content=content)
if content:
__save_file(_fileitem=parent, _path=image_path, _content=content)
else:
# 当前为目录,处理目录内的文件
@@ -485,7 +490,8 @@ class MediaChain(ChainBase, metaclass=Singleton):
# 下载图片
content = __download_image(image_url)
# 保存图片文件到当前目录
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
if content:
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
# 判断当前目录是不是剧集根目录
if season_meta.name:
# 当前目录有名称生成tvshow nfo 和 tv图片
@@ -511,6 +517,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
# 下载图片
content = __download_image(image_url)
# 保存图片文件到当前目录
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
if content:
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
logger.info(f"{filepath.name} 刮削完成")

View File

@@ -30,8 +30,8 @@ class MetaVideo(MetaBase):
_episode_re = r"EP?(\d{2,4})$|^EP?(\d{1,4})$|^S\d{1,2}EP?(\d{1,4})$|S\d{2}EP?(\d{2,4})"
_part_re = r"(^PART[0-9ABI]{0,2}$|^CD[0-9]{0,2}$|^DVD[0-9]{0,2}$|^DISK[0-9]{0,2}$|^DISC[0-9]{0,2}$)"
_roman_numerals = r"^(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$"
_source_re = r"^BLURAY$|^HDTV$|^UHDTV$|^HDDVD$|^WEBRIP$|^DVDRIP$|^BDRIP$|^BLU$|^WEB$|^BD$|^HDRip$"
_effect_re = r"^REMUX$|^UHD$|^SDR$|^HDR\d*$|^DOLBY$|^DOVI$|^DV$|^3D$|^REPACK$"
_source_re = r"^BLURAY$|^HDTV$|^UHDTV$|^HDDVD$|^WEBRIP$|^DVDRIP$|^BDRIP$|^BLU$|^WEB$|^BD$|^HDRip$|^REMUX$|^UHD$|^REPACK$"
_effect_re = r"^SDR$|^HDR\d*$|^DOLBY$|^DOVI$|^DV$|^3D$"
_resources_type_re = r"%s|%s" % (_source_re, _effect_re)
_name_no_begin_re = r"^[\[【].+?[\]】]"
_name_no_chinese_re = r".*版|.*字幕"

View File

@@ -9,17 +9,19 @@ class FormatParser(object):
_split_chars = r"\.|\s+|\(|\)|\[|]|-|\+|【|】|/||;|&|\||#|_|「|」|~"
def __init__(self, eformat: str, details: str = None, part: str = None,
offset: int = None, key: str = "ep"):
offset: str = None, key: str = "ep"):
"""
:params eformat: 格式化字符串
:params details: 格式化详情
:params part: 分集
:params offset: 偏移量
:params offset: 偏移量 -10/EP*2
:prams key: EP关键字
"""
self._format = eformat
self._start_ep = None
self._end_ep = None
self.__offset = offset or "EP"
self._key = key
self._part = None
if part:
self._part = part
@@ -34,8 +36,6 @@ class FormatParser(object):
self._end_ep = int(tmp[0]) if int(tmp[0]) > int(tmp[1]) else int(tmp[1])
else:
self._start_ep = self._end_ep = int(tmp[0])
self.__offset = int(offset) if offset else 0
self._key = key
@property
def format(self):
@@ -77,15 +77,21 @@ class FormatParser(object):
if self._start_ep is not None and self._start_ep == self._end_ep:
if isinstance(self._start_ep, str):
s, e = self._start_ep.split("-")
start_ep = self.__offset.replace("EP", s)
end_ep = self.__offset.replace("EP", e)
if int(s) == int(e):
return int(s) + self.__offset, None, self.part
return int(s) + self.__offset, int(e) + self.__offset, self.part
return self._start_ep + self.__offset, None, self.part
return int(eval(start_ep)), None, self.part
return int(eval(start_ep)), int(eval(end_ep)), self.part
else:
start_ep = self.__offset.replace("EP", str(self._start_ep))
return int(eval(start_ep)), None, self.part
if not self._format:
return self._start_ep, self._end_ep, self.part
s, e = self.__handle_single(file_name)
return s + self.__offset if s is not None else None, \
e + self.__offset if e is not None else None, self.part
else:
s, e = self.__handle_single(file_name)
start_ep = self.__offset.replace("EP", str(s)) if s else None
end_ep = self.__offset.replace("EP", str(e)) if e else None
return int(eval(start_ep)) if start_ep else None, int(eval(end_ep)) if end_ep else None, self.part
def __handle_single(self, file: str) -> Tuple[Optional[int], Optional[int]]:
"""

View File

@@ -225,12 +225,13 @@ class RssHelper:
}
@staticmethod
def parse(url, proxy: bool = False, timeout: int = 15) -> Union[List[dict], None]:
def parse(url, proxy: bool = False, timeout: int = 15, headers: dict = None) -> Union[List[dict], None]:
"""
解析RSS订阅URL获取RSS中的种子信息
:param url: RSS地址
:param proxy: 是否使用代理
:param timeout: 请求超时
:param headers: 自定义请求头
:return: 种子信息列表如为None代表Rss过期
"""
# 开始处理
@@ -238,7 +239,8 @@ class RssHelper:
if not url:
return []
try:
ret = RequestUtils(proxies=settings.PROXY if proxy else None, timeout=timeout).get_res(url)
ret = RequestUtils(proxies=settings.PROXY if proxy else None,
timeout=timeout, headers=headers).get_res(url)
if not ret:
return []
except Exception as err:

View File

@@ -52,7 +52,7 @@ class DoubanCache(metaclass=Singleton):
获取缓存KEY
"""
return f"[{meta.type.value if meta.type else '未知'}]" \
f"{meta.name or meta.doubanid}-{meta.year}-{meta.begin_season}"
f"{meta.doubanid or meta.name}-{meta.year}-{meta.begin_season}"
def get(self, meta: MetaBase):
"""

View File

@@ -462,6 +462,11 @@ class FileManagerModule(_ModuleBase):
# 上传文件
new_item = target_oper.upload(target_fileitem, filepath)
if new_item:
# 重命名为目标文件名
if new_item.name != target_file.name:
if target_oper.rename(new_item, target_file.name):
new_item.name = target_file.name
new_item.path = str(Path(new_item.path).parent / target_file.name)
return new_item, ""
else:
return None, f"{fileitem.path} 上传 {target_storage} 失败"
@@ -475,6 +480,11 @@ class FileManagerModule(_ModuleBase):
# 上传文件
new_item = target_oper.upload(target_fileitem, filepath)
if new_item:
# 重命名为目标文件名
if new_item.name != target_file.name:
if target_oper.rename(new_item, target_file.name):
new_item.name = target_file.name
new_item.path = str(Path(new_item.path).parent / target_file.name)
# 删除源文件
source_oper.delete(fileitem)
return new_item, ""

View File

@@ -222,7 +222,7 @@ class LocalStorage(StorageBase):
软链接文件
"""
file_path = Path(fileitem.path)
code, message = SystemUtils.copy(file_path, target_file)
code, message = SystemUtils.softlink(file_path, target_file)
if code != 0:
logger.error(f"软链接文件失败:{message}")
return False

View File

@@ -468,6 +468,30 @@ class Jellyfin:
return None
return None
def get_item_path_by_id(self, item_id: str) -> Optional[str]:
"""
根据ItemId查询所在的Path
:param item_id: 在Jellyfin中的ID
:return: Path
"""
if not self._host or not self._apikey:
return None
url = f"{self._host}Items/{item_id}/PlaybackInfo"
params = {"api_key": self._apikey}
try:
res = RequestUtils(timeout=10).get_res(url, params)
if res:
media_sources = res.json().get("MediaSources")
if media_sources:
return media_sources[0].get("Path")
else:
logger.error("Items/Id/PlaybackInfo 未获取到返回数据,不设置 Path")
return None
except Exception as e:
logger.error("连接Items/Id/PlaybackInfo出错" + str(e))
return None
return None
def generate_image_link(self, item_id: str, image_type: str, host_type: bool) -> Optional[str]:
"""
根据ItemId和imageType查询本地对应图片
@@ -662,6 +686,8 @@ class Jellyfin:
item_id=eventItem.item_id,
image_type="Backdrop"
)
# jellyfin 的 webhook 不含 item_path需要单独获取
eventItem.item_path = self.get_item_path_by_id(eventItem.item_id)
return eventItem

View File

@@ -50,7 +50,7 @@ class TmdbCache(metaclass=Singleton):
"""
获取缓存KEY
"""
return f"[{meta.type.value if meta.type else '未知'}]{meta.name or meta.tmdbid}-{meta.year}-{meta.begin_season}"
return f"[{meta.type.value if meta.type else '未知'}]{meta.tmdbid or meta.name}-{meta.year}-{meta.begin_season}"
def get(self, meta: MetaBase):
"""

View File

@@ -1,4 +1,3 @@
import logging
import threading
import traceback
from datetime import datetime, timedelta
@@ -27,12 +26,6 @@ from app.schemas.types import EventType
from app.utils.singleton import Singleton
from app.utils.timer import TimerUtils
# 获取 apscheduler 的日志记录器
scheduler_logger = logging.getLogger('apscheduler')
# 设置日志级别为 WARNING
scheduler_logger.setLevel(logging.WARNING)
class SchedulerChain(ChainBase):
pass
@@ -436,23 +429,23 @@ class Scheduler(metaclass=Singleton):
try:
sid = f"{service['id']}"
job_id = sid.split("|")[0]
if job_id not in self._jobs:
self._jobs[job_id] = {
"func": service["func"],
"name": service["name"],
"pid": pid,
"plugin_name": plugin_name,
"running": False,
}
self._scheduler.add_job(
self.start,
service["trigger"],
id=sid,
name=service["name"],
**service["kwargs"],
kwargs={"job_id": job_id}
)
logger.info(f"注册插件{plugin_name}服务:{service['name']} - {service['trigger']}")
self._jobs[job_id] = {
"func": service["func"],
"name": service["name"],
"pid": pid,
"plugin_name": plugin_name,
"running": False,
}
self._scheduler.add_job(
self.start,
service["trigger"],
id=sid,
name=service["name"],
**service["kwargs"],
kwargs={"job_id": job_id},
replace_existing=True
)
logger.info(f"注册插件{plugin_name}服务:{service['name']} - {service['trigger']}")
except Exception as e:
logger.error(f"注册插件{plugin_name}服务失败:{str(e)} - {service}")
SchedulerChain().messagehelper.put(title=f"插件 {plugin_name} 服务注册失败",
@@ -468,14 +461,25 @@ class Scheduler(metaclass=Singleton):
with self._lock:
# 获取插件名称
plugin_name = PluginManager().get_plugin_attr(pid, "plugin_name")
for job_id, service in self._jobs.copy().items():
# 先从 _jobs 中查找匹配的服务
jobs_to_remove = [(job_id, service) for job_id, service in self._jobs.items() if service.get("pid") == pid]
if not jobs_to_remove:
return
for job_id, service in jobs_to_remove:
try:
if service.get("pid") == pid:
self._jobs.pop(job_id, None)
try:
self._scheduler.remove_job(job_id)
except JobLookupError:
pass
# 移除服务
self._jobs.pop(job_id, None)
# 在调度器中查找并移除对应的 job
job_removed = False
for job in list(self._scheduler.get_jobs()):
job_id_from_service = job.id.split("|")[0]
if job_id == job_id_from_service:
try:
self._scheduler.remove_job(job.id)
job_removed = True
except JobLookupError:
pass
if job_removed:
logger.info(f"移除插件服务({plugin_name}){service.get('name')}")
except Exception as e:
logger.error(f"移除插件服务失败:{str(e)} - {job_id}: {service}")

View File

@@ -86,4 +86,4 @@ class EpisodeFormat(BaseModel):
format: Optional[str] = None
detail: Optional[str] = None
part: Optional[str] = None
offset: Optional[int] = None
offset: Optional[str] = None

View File

@@ -1,2 +1,2 @@
APP_VERSION = 'v2.0.1'
FRONTEND_VERSION = 'v2.0.1'
APP_VERSION = 'v2.0.2'
FRONTEND_VERSION = 'v2.0.2'