Compare commits

...

42 Commits

Author SHA1 Message Date
jxxghp
b1db95a925 v2.4.4 2025-05-07 08:26:06 +08:00
jxxghp
9dac9850b6 fix plugin file api 2025-05-06 23:56:35 +08:00
jxxghp
abe091254a fix plugin file api 2025-05-06 23:30:26 +08:00
jxxghp
d2e5367dc6 fix plugins 2025-05-06 11:44:23 +08:00
jxxghp
8ccd1f5fe4 Merge pull request #4229 from wikrin/v2 2025-05-06 06:34:16 +08:00
Attente
50bc865dd2 fix(database): improve message template
- Fix syntax error in downloadAdded message template
2025-05-05 23:14:58 +08:00
jxxghp
74a6ee7066 fix 2025-05-05 19:50:15 +08:00
jxxghp
89e76bcb48 fix 2025-05-05 19:49:30 +08:00
jxxghp
c55f6baf67 Merge pull request #4228 from wikrin/format_notification
Format notification
2025-05-05 19:28:44 +08:00
Attente
ae154489e1 上下文构建并非复杂任务, 移除缓存 2025-05-05 14:08:41 +08:00
Attente
fdc79033ce Merge https://github.com/jxxghp/MoviePilot into format_notification 2025-05-05 13:21:58 +08:00
jxxghp
9a8aa5e632 更新 subscribe.py 2025-05-05 13:16:14 +08:00
Attente
6b81f3ce5f feat(template):实现缓存机制以提升性能
- 在 `TemplateHelper` 和 `TemplateContextBuilder` 中集成 TTLCache(带过期时间的缓存),提升数据复用能力
- 引入 `build_context_cache` 装饰器,统一管理上下文构建的缓存逻辑
对媒体信息、剧集详情、种子信息、传输信息及原始对象启用缓存,减少重复计算
- 新增上下文缓存支持,为异步广播事件 NoticeMessage 提供所需上下文(可通过消息 title 与 text 内容重新获取上下文)
- 支持插件通过自定义模板灵活重构消息体,提升扩展性与灵活性
2025-05-05 13:14:45 +08:00
Attente
aeaddfe36b feat(database): add notification templates for version 2.1.4
- Add new Alembic migration script for version 2.1.4
- Implement notification templates for various events:
  - Organize success
  - Download added
  - Subscribe added
  - Subscribe complete
- Store notification templates in system configuration
2025-05-05 05:27:59 +08:00
Attente
20c1f30877 feat(message): 实现自定义消息模板功能
- 新增 MessageTemplateHelper 类用于渲染消息模板
- 在 ChainBase 中集成消息模板渲染功能
- 修改 DownloadChain、SubscribeChain 和 TransferChain 以使用新消息模板
- 新增 TemplateHelper 类用于处理模板格式
- 在 SystemConfigKey 中添加 NotificationTemplates 配置项
- 更新 Notification 模型以支持 ctype 字段
2025-05-05 05:27:48 +08:00
jxxghp
52ce6ff38e fix plugin file api 2025-05-03 22:14:39 +08:00
jxxghp
c692a3c80e feat:支持vue原生插件页面 2025-05-03 10:03:44 +08:00
jxxghp
491009636a fix bug 2025-05-02 22:57:29 +08:00
jxxghp
ed16ee14ea fix bug 2025-05-02 21:57:19 +08:00
jxxghp
7f2ed09267 fix storage 2025-05-02 20:49:38 +08:00
jxxghp
c0976897ef fix bug 2025-05-02 13:30:39 +08:00
jxxghp
85b55aa924 fix bug 2025-05-02 08:31:38 +08:00
jxxghp
91d0f76783 feat:支持新增存储类型 2025-05-02 08:11:48 +08:00
jxxghp
741badf9e6 feat:支持文件整理存储操作事件 2025-05-01 21:16:21 +08:00
jxxghp
ca1f3ac377 feat:文件整理支持操作类入参 2025-05-01 20:56:17 +08:00
jxxghp
e13e1c9ca3 fix run_module 2025-05-01 11:36:43 +08:00
jxxghp
06ad042443 fix typo 2025-05-01 11:20:56 +08:00
jxxghp
9d333b855c feat:支持插件协持系统模块实现 2025-05-01 11:03:28 +08:00
jxxghp
f46e2acd56 v2.4.3
- 用户界面支持多语言
- 支持设定TheMovieDb元数据语言
- 订阅成功消息增加了演员和简介
- 修复问题

提醒:如升级后页面空白,请强制刷新或者清理浏览器缓存
2025-04-29 17:32:40 +08:00
jxxghp
5ac4d3f4ae fix wallpaper api 2025-04-29 15:26:10 +08:00
jxxghp
1614eebc47 fix 2025-04-29 14:53:04 +08:00
jxxghp
b50599b71f fix:增加安全性 2025-04-29 14:30:34 +08:00
jxxghp
0459025bf8 Merge pull request #4207 from monster-fire/v2 2025-04-28 19:37:52 +08:00
monster-fire
0bd37da8c7 Update __init__.py 添加空值检查 2025-04-28 18:46:48 +08:00
jxxghp
da969dde53 fix:TMDB支持设置语种 2025-04-28 12:11:48 +08:00
jxxghp
33fdd6cafa feat:TMDB支持设置语种 2025-04-28 09:10:38 +08:00
jxxghp
2fe68766eb Merge remote-tracking branch 'origin/v2' into v2 2025-04-28 09:07:42 +08:00
jxxghp
205348697c fix #4188 2025-04-27 12:26:49 +08:00
jxxghp
9b3533c1da Merge pull request #4199 from cddjr/fix_bing 2025-04-27 06:53:00 +08:00
景大侠
c3584e838e fix: 开启全局图片缓存后无法显示来自Bing的壁纸 2025-04-27 00:17:29 +08:00
jxxghp
16d8b3fb58 Merge pull request #4187 from thsrite/v2 2025-04-23 11:53:29 +08:00
thsrite
686bbdc16b fix 添加订阅成功消息增加演员名称、简介 2025-04-23 11:44:44 +08:00
23 changed files with 1214 additions and 386 deletions

View File

@@ -77,5 +77,7 @@ def wallpapers() -> Any:
return WebUtils.get_bing_wallpapers()
elif settings.WALLPAPER == "mediaserver":
return MediaServerChain().get_latest_wallpapers()
else:
elif settings.WALLPAPER == "tmdb":
return TmdbChain().get_trending_wallpapers()
else:
return []

View File

@@ -198,7 +198,7 @@ def seasons(mediaid: Optional[str] = None,
@router.get("/{mediaid}", summary="查询媒体详情", response_model=schemas.MediaInfo)
def detail(mediaid: str, type_name: str, title: Optional[str] = None, year: int = None,
def detail(mediaid: str, type_name: str, title: Optional[str] = None, year: str = None,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据媒体ID查询themoviedb或豆瓣媒体信息type_name: 电影/电视剧

View File

@@ -1,12 +1,15 @@
import mimetypes
from typing import Annotated, Any, List, Optional
from fastapi import APIRouter, Depends, Header
from fastapi import APIRouter, Depends, Header, HTTPException
from starlette import status
from starlette.responses import FileResponse
from app import schemas
from app.command import Command
from app.core.config import settings
from app.core.plugin import PluginManager
from app.core.security import verify_apikey, verify_token
from app.core.security import verify_apikey, verify_token, verify_apitoken
from app.db.systemconfig_oper import SystemConfigOper
from app.db.user_oper import get_current_active_superuser
from app.factory import app
@@ -16,7 +19,6 @@ from app.scheduler import Scheduler
from app.schemas.types import SystemConfigKey
PROTECTED_ROUTES = {"/api/v1/openapi.json", "/docs", "/docs/oauth2-redirect", "/redoc"}
PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin"
router = APIRouter()
@@ -218,25 +220,60 @@ def install(plugin_id: str,
return schemas.Response(success=True)
@router.get("/remotes", summary="获取插件联邦组件列表", response_model=List[dict])
def remotes(token: str) -> Any:
"""
获取插件联邦组件列表
"""
if token != "moviepilot":
raise HTTPException(status_code=403, detail="Forbidden")
return PluginManager().get_plugin_remotes()
@router.get("/form/{plugin_id}", summary="获取插件表单页面")
def plugin_form(plugin_id: str,
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict:
"""
根据插件ID获取插件配置表单
根据插件ID获取插件配置表单或Vue组件URL
"""
conf, model = PluginManager().get_plugin_form(plugin_id)
return {
"conf": conf,
"model": model
}
plugin_instance = PluginManager().running_plugins.get(plugin_id)
if not plugin_instance:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"插件 {plugin_id} 不存在或未加载")
# 渲染模式
render_mode, _ = plugin_instance.get_render_mode()
try:
conf, model = plugin_instance.get_form()
return {
"render_mode": render_mode,
"conf": conf,
"model": PluginManager().get_plugin_config(plugin_id) or model
}
except Exception as e:
logger.error(f"插件 {plugin_id} 调用方法 get_form 出错: {str(e)}")
return {}
@router.get("/page/{plugin_id}", summary="获取插件数据页面")
def plugin_page(plugin_id: str, _: schemas.TokenPayload = Depends(get_current_active_superuser)) -> List[dict]:
def plugin_page(plugin_id: str, _: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict:
"""
根据插件ID获取插件数据页面
"""
return PluginManager().get_plugin_page(plugin_id)
plugin_instance = PluginManager().running_plugins.get(plugin_id)
if not plugin_instance:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"插件 {plugin_id} 不存在或未加载")
# 渲染模式
render_mode, _ = plugin_instance.get_render_mode()
try:
page = plugin_instance.get_page()
return {
"render_mode": render_mode,
"page": page or []
}
except Exception as e:
logger.error(f"插件 {plugin_id} 调用方法 get_page 出错: {str(e)}")
return {}
@router.get("/dashboard/meta", summary="获取所有插件仪表板元信息")
@@ -247,22 +284,22 @@ def plugin_dashboard_meta(_: schemas.TokenPayload = Depends(verify_token)) -> Li
return PluginManager().get_plugin_dashboard_meta()
@router.get("/dashboard/{plugin_id}/{key}", summary="获取插件仪表板配置")
def plugin_dashboard_by_key(plugin_id: str, key: str, user_agent: Annotated[str | None, Header()] = None,
_: schemas.TokenPayload = Depends(verify_token)) -> Optional[schemas.PluginDashboard]:
"""
根据插件ID获取插件仪表板
"""
return PluginManager().get_plugin_dashboard(plugin_id, key, user_agent)
@router.get("/dashboard/{plugin_id}", summary="获取插件仪表板配置")
def plugin_dashboard(plugin_id: str, user_agent: Annotated[str | None, Header()] = None,
_: schemas.TokenPayload = Depends(verify_token)) -> schemas.PluginDashboard:
"""
根据插件ID获取插件仪表板
"""
return PluginManager().get_plugin_dashboard(plugin_id, user_agent=user_agent)
@router.get("/dashboard/{plugin_id}/{key}", summary="获取插件仪表板配置")
def plugin_dashboard(plugin_id: str, key: str, user_agent: Annotated[str | None, Header()] = None,
_: schemas.TokenPayload = Depends(verify_token)) -> schemas.PluginDashboard:
"""
根据插件ID获取插件仪表板
"""
return PluginManager().get_plugin_dashboard(plugin_id, key=key, user_agent=user_agent)
return plugin_dashboard_by_key(plugin_id, "", user_agent)
@router.get("/reset/{plugin_id}", summary="重置插件配置及数据", response_model=schemas.Response)
@@ -286,6 +323,41 @@ def reset_plugin(plugin_id: str,
return schemas.Response(success=True)
@router.get("/file/{plugin_id}/{filepath:path}", summary="获取插件静态文件")
def plugin_static_file(plugin_id: str, filepath: str):
"""
获取插件静态文件
"""
# 基础安全检查
if ".." in filepath or ".." in filepath:
logger.warning(f"Static File API: Path traversal attempt detected: {plugin_id}/{filepath}")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
plugin_base_dir = settings.ROOT_PATH / "app" / "plugins" / plugin_id.lower()
plugin_file_path = plugin_base_dir / filepath
if not plugin_file_path.exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"{plugin_file_path} 不存在")
if not plugin_file_path.is_file():
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"{plugin_file_path} 不是文件")
# 判断 MIME 类型
response_type, _ = mimetypes.guess_type(str(plugin_file_path))
suffix = plugin_file_path.suffix.lower()
# 强制修正 .mjs 和 .js 的 MIME 类型
if suffix in ['.js', '.mjs']:
response_type = 'application/javascript'
elif suffix == '.css' and not response_type: # 如果 guess_type 没猜对 css也修正
response_type = 'text/css'
elif not response_type: # 对于其他猜不出的类型
response_type = 'application/octet-stream'
try:
return FileResponse(plugin_file_path, media_type=response_type)
except Exception as e:
logger.error(f"Error creating/sending FileResponse for {plugin_file_path}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Internal Server Error")
@router.get("/{plugin_id}", summary="获取插件配置")
def plugin_config(plugin_id: str,
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict:

View File

@@ -171,10 +171,13 @@ def cache_img(
@router.get("/global", summary="查询非敏感系统设置", response_model=schemas.Response)
def get_global_setting():
def get_global_setting(token: str):
"""
查询非敏感系统设置(无需鉴权)
查询非敏感系统设置(默认鉴权)
"""
if token != "moviepilot":
raise HTTPException(status_code=403, detail="Forbidden")
# FIXME: 新增敏感配置项时要在此处添加排除项
info = settings.dict(
exclude={"SECRET_KEY", "RESOURCE_SECRET_KEY", "API_TOKEN", "TMDB_API_KEY", "TVDB_API_KEY", "FANART_API_KEY",

View File

@@ -518,32 +518,33 @@ def arr_series_lookup(term: str, _: Annotated[str, Depends(verify_apikey)], db:
"""
查询Sonarr剧集 term: `tvdb:${id}` title
"""
# 季信息
seas: List[int] = []
# 获取TVDBID
if not term.startswith("tvdb:"):
mediainfo = MediaChain().recognize_media(meta=MetaInfo(term),
mtype=MediaType.TV)
if not mediainfo:
return [SonarrSeries()]
tvdbid = mediainfo.tvdb_id
if not tvdbid:
return [SonarrSeries()]
# 季信息
if mediainfo.seasons:
seas = list(mediainfo.seasons)
else:
mediainfo = None
tvdbid = int(term.replace("tvdb:", ""))
# 查询TVDB信息
tvdbinfo = MediaChain().tvdb_info(tvdbid=tvdbid)
if not tvdbinfo:
return [SonarrSeries()]
# 查询TVDB信息
tvdbinfo = MediaChain().tvdb_info(tvdbid=tvdbid)
if not tvdbinfo:
return [SonarrSeries()]
# 季信息
seas: List[int] = []
sea_num = tvdbinfo.get('season')
if sea_num:
seas = list(range(1, int(sea_num) + 1))
# 季信息
sea_num = tvdbinfo.get('season')
if sea_num:
seas = list(range(1, int(sea_num) + 1))
# 根据TVDB查询媒体信息
if not mediainfo:
# 根据TVDB查询媒体信息
mediainfo = MediaChain().recognize_media(meta=MetaInfo(tvdbinfo.get('seriesName')),
mtype=MediaType.TV)

View File

@@ -3,6 +3,7 @@ import gc
import pickle
import traceback
from abc import ABCMeta
from collections.abc import Callable
from pathlib import Path
from typing import Optional, Any, Tuple, List, Set, Union, Dict
@@ -14,9 +15,10 @@ from app.core.context import Context, MediaInfo, TorrentInfo
from app.core.event import EventManager
from app.core.meta import MetaBase
from app.core.module import ModuleManager
from app.core.plugin import PluginManager
from app.db.message_oper import MessageOper
from app.db.user_oper import UserOper
from app.helper.message import MessageHelper, MessageQueueManager
from app.helper.message import MessageHelper, MessageQueueManager, MessageTemplateHelper
from app.helper.service import ServiceConfigHelper
from app.log import logger
from app.schemas import TransferInfo, TransferTorrent, ExistMediaInfo, DownloadingTorrent, CommingMessage, Notification, \
@@ -42,6 +44,7 @@ class ChainBase(metaclass=ABCMeta):
send_callback=self.run_module
)
self.useroper = UserOper()
self.pluginmanager = PluginManager()
@staticmethod
def load_cache(filename: str) -> Any:
@@ -97,7 +100,50 @@ class ChainBase(metaclass=ABCMeta):
return ret is None
result = None
logger.debug(f"请求模块执行:{method} ...")
plugin_modules = self.pluginmanager.get_plugin_modules()
# 插件模块
for plugin, module_dict in plugin_modules.items():
plugin_id, plugin_name = plugin
if method in module_dict:
func = module_dict[method]
if func:
try:
logger.info(f"请求插件 {plugin_name} 执行:{method} ...")
if is_result_empty(result):
# 返回None第一次执行或者需继续执行下一模块
result = func(*args, **kwargs)
elif isinstance(result, list):
# 返回为列表,有多个模块运行结果时进行合并
temp = func(*args, **kwargs)
if isinstance(temp, list):
result.extend(temp)
else:
break
except Exception as err:
if kwargs.get("raise_exception"):
raise
logger.error(
f"运行插件 {plugin_id} 模块 {method} 出错:{str(err)}\n{traceback.format_exc()}")
self.messagehelper.put(title=f"{plugin_name} 发生了错误",
message=str(err),
role="plugin")
self.eventmanager.send_event(
EventType.SystemError,
{
"type": "plugin",
"plugin_id": plugin_id,
"plugin_name": plugin_name,
"plugin_method": method,
"error": str(err),
"traceback": traceback.format_exc()
}
)
if not is_result_empty(result) and not isinstance(result, list):
# 插件模块返回结果不为空且不是列表,直接返回
return result
# 系统模块
logger.debug(f"请求系统模块执行:{method} ...")
modules = self.modulemanager.get_running_modules(method)
# 按优先级排序
modules = sorted(modules, key=lambda x: x.get_priority())
@@ -114,10 +160,10 @@ class ChainBase(metaclass=ABCMeta):
# 返回None第一次执行或者需继续执行下一模块
result = func(*args, **kwargs)
elif ObjectUtils.check_signature(func, result):
# 返回结果与方法签名一致,将结果传入(不能多个模块同时运行的需要通过开关控制)
# 返回结果与方法签名一致,将结果传入
result = func(result)
elif isinstance(result, list):
# 返回为列表,有多个模块运行结果时进行合并(不能多个模块同时运行的需要通过开关控制)
# 返回为列表,有多个模块运行结果时进行合并
temp = func(*args, **kwargs)
if isinstance(temp, list):
result.extend(temp)
@@ -401,7 +447,8 @@ class ChainBase(metaclass=ABCMeta):
target_storage: Optional[str] = None, target_path: Path = None,
transfer_type: Optional[str] = None, scrape: bool = None,
library_type_folder: bool = None, library_category_folder: bool = None,
episodes_info: List[TmdbEpisode] = None) -> Optional[TransferInfo]:
episodes_info: List[TmdbEpisode] = None,
source_oper: Callable = None, target_oper: Callable = None) -> Optional[TransferInfo]:
"""
文件转移
:param fileitem: 文件信息
@@ -415,6 +462,8 @@ class ChainBase(metaclass=ABCMeta):
:param library_type_folder: 是否按类型创建目录
:param library_category_folder: 是否按类别创建目录
:param episodes_info: 当前季的全部集信息
:param source_oper: 源存储操作类
:param target_oper: 目标存储操作类
:return: {path, target_path, message}
"""
return self.run_module("transfer",
@@ -424,7 +473,8 @@ class ChainBase(metaclass=ABCMeta):
transfer_type=transfer_type, scrape=scrape,
library_type_folder=library_type_folder,
library_category_folder=library_category_folder,
episodes_info=episodes_info)
episodes_info=episodes_info,
source_oper=source_oper, target_oper=target_oper)
def transfer_completed(self, hashs: str, downloader: Optional[str] = None) -> None:
"""
@@ -492,13 +542,27 @@ class ChainBase(metaclass=ABCMeta):
"""
return self.run_module("media_files", mediainfo=mediainfo)
def post_message(self, message: Notification) -> None:
def post_message(self,
message: Optional[Notification] = None,
meta: Optional[MetaBase] = None,
mediainfo: Optional[MediaInfo] = None,
torrentinfo: Optional[TorrentInfo] = None,
transferinfo: Optional[TransferInfo] = None,
**kwargs) -> None:
"""
发送消息
:param message: 消息体
:param message: Notification实例
:param meta: 元数据
:param mediainfo: 媒体信息
:param torrentinfo: 种子信息
:param transferinfo: 文件整理信息
:param kwargs: 其他参数(覆盖业务对象属性值)
:return: 成功或失败
"""
# 保存原消息
# 渲染消息
message = MessageTemplateHelper.render(message=message, meta=meta, mediainfo=mediainfo,
torrentinfo=torrentinfo, transferinfo=transferinfo, **kwargs)
# 保存消息
self.messagehelper.put(message, role="user", title=message.title)
self.messageoper.add(**message.dict())
# 发送消息按设置隔离

View File

@@ -20,7 +20,7 @@ from app.helper.message import MessageHelper
from app.helper.torrent import TorrentHelper
from app.log import logger
from app.schemas import ExistMediaInfo, NotExistMediaInfo, DownloadingTorrent, Notification, ResourceSelectionEventData, ResourceDownloadEventData
from app.schemas.types import MediaType, TorrentStatus, EventType, MessageChannel, NotificationType, ChainEventType
from app.schemas.types import MediaType, TorrentStatus, EventType, MessageChannel, NotificationType, ContentType, ChainEventType
from app.utils.http import RequestUtils
from app.utils.string import StringUtils
@@ -38,63 +38,6 @@ class DownloadChain(ChainBase):
self.directoryhelper = DirectoryHelper()
self.messagehelper = MessageHelper()
def post_download_message(self, meta: MetaBase, mediainfo: MediaInfo, torrent: TorrentInfo,
channel: MessageChannel = None, username: Optional[str] = None,
download_episodes: Optional[str] = None):
"""
发送添加下载的消息,根据消息场景开关决定发给谁
:param meta: 元数据
:param mediainfo: 媒体信息
:param torrent: 种子信息
:param channel: 通知渠道
:param username: 通知显示的下载用户信息
:param download_episodes: 下载的集数
"""
# 拼装消息内容
msg_text = ""
if username:
msg_text = f"用户:{username}"
if torrent.site_name:
msg_text = f"{msg_text}\n站点:{torrent.site_name}"
if meta.resource_term:
msg_text = f"{msg_text}\n质量:{meta.resource_term}"
if torrent.size:
if str(torrent.size).replace(".", "").isdigit():
size = StringUtils.str_filesize(torrent.size)
else:
size = torrent.size
msg_text = f"{msg_text}\n大小:{size}"
if torrent.title:
msg_text = f"{msg_text}\n种子:{torrent.title}"
if torrent.pubdate:
msg_text = f"{msg_text}\n发布时间:{torrent.pubdate}"
if torrent.freedate:
msg_text = f"{msg_text}\n免费时间:{StringUtils.diff_time_str(torrent.freedate)}"
if torrent.seeders:
msg_text = f"{msg_text}\n做种数:{torrent.seeders}"
if torrent.uploadvolumefactor and torrent.downloadvolumefactor:
msg_text = f"{msg_text}\n促销:{torrent.volume_factor}"
if torrent.hit_and_run:
msg_text = f"{msg_text}\nHit&Run"
if torrent.labels:
msg_text = f"{msg_text}\n标签:{' '.join(torrent.labels)}"
if torrent.description:
html_re = re.compile(r'<[^>]+>', re.S)
description = html_re.sub('', torrent.description)
torrent.description = re.sub(r'<[^>]+>', '', description)
msg_text = f"{msg_text}\n描述:{torrent.description}"
# 下载成功按规则发送消息
self.post_message(Notification(
channel=channel,
mtype=NotificationType.Download,
title=f"{mediainfo.title_year} "
f"{'%s %s' % (meta.season, download_episodes) if download_episodes else meta.season_episode} 开始下载",
text=msg_text,
image=mediainfo.get_message_image(),
link=settings.MP_DOMAIN('/#/downloading'),
username=username))
def download_torrent(self, torrent: TorrentInfo,
channel: MessageChannel = None,
source: Optional[str] = None,
@@ -384,8 +327,20 @@ class DownloadChain(ChainBase):
self.downloadhis.add_files(files_to_add)
# 下载成功发送消息
self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent,
username=username, download_episodes=download_episodes)
self.post_message(
Notification(
channel=channel,
mtype=NotificationType.Download,
ctype=ContentType.DownloadAdded,
image=_media.get_message_image(),
link=settings.MP_DOMAIN('/#/downloading'),
username=username
),
meta=_meta,
mediainfo=_media,
torrentinfo=_torrent,
download_episodes=download_episodes
)
# 下载成功后处理
self.download_added(context=context, download_dir=download_dir, torrent_path=torrent_file)
# 广播事件

View File

@@ -29,7 +29,7 @@ from app.helper.subscribe import SubscribeHelper
from app.helper.torrent import TorrentHelper
from app.log import logger
from app.schemas import MediaRecognizeConvertEventData
from app.schemas.types import MediaType, SystemConfigKey, MessageChannel, NotificationType, EventType, ChainEventType
from app.schemas.types import MediaType, SystemConfigKey, MessageChannel, NotificationType, EventType, ChainEventType, ContentType
from app.utils.singleton import Singleton
@@ -228,22 +228,22 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
userid=userid))
return None, err_msg
elif message:
logger.info(f'{mediainfo.title_year} {metainfo.season} 添加订阅成功')
if username:
text = f"评分:{mediainfo.vote_average},来自用户:{username}"
else:
text = f"评分:{mediainfo.vote_average}"
if mediainfo.type == MediaType.TV:
link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub')
else:
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
# 订阅成功按规则发送消息
self.post_message(schemas.Notification(mtype=NotificationType.Subscribe,
title=f"{mediainfo.title_year} {metainfo.season} 已添加订阅",
text=text,
image=mediainfo.get_message_image(),
link=link,
username=username))
self.post_message(
schemas.Notification(
mtype=NotificationType.Subscribe,
ctype=ContentType.SubscribeAdded,
image=mediainfo.get_message_image(),
link=link,
username=username
),
mediainfo=mediainfo,
username=username
)
# 发送事件
EventManager().send_event(EventType.SubscribeAdded, {
"subscribe_id": sid,
@@ -1013,11 +1013,18 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
else:
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
# 完成订阅按规则发送消息
self.post_message(schemas.Notification(mtype=NotificationType.Subscribe,
title=f'{mediainfo.title_year} {meta.season} 已完成{msgstr}',
image=mediainfo.get_message_image(),
link=link,
username=subscribe.username))
self.post_message(
schemas.Notification(
mtype=NotificationType.Subscribe,
ctype=ContentType.SubscribeComplete,
image=mediainfo.get_message_image(),
link=link,
username=subscribe.username
),
meta=meta,
mediainfo=mediainfo,
msgstr=msgstr
)
# 发送事件
EventManager().send_event(EventType.SubscribeComplete, {
"subscribe_id": subscribe.id,

View File

@@ -17,6 +17,7 @@ from app.core.config import settings, global_vars
from app.core.context import MediaInfo
from app.core.meta import MetaBase
from app.core.metainfo import MetaInfoPath
from app.core.event import eventmanager
from app.db.downloadhistory_oper import DownloadHistoryOper
from app.db.models.downloadhistory import DownloadHistory
from app.db.models.transferhistory import TransferHistory
@@ -29,7 +30,8 @@ from app.log import logger
from app.schemas import TransferInfo, TransferTorrent, Notification, EpisodeFormat, FileItem, TransferDirectoryConf, \
TransferTask, TransferQueue, TransferJob, TransferJobTask
from app.schemas.types import TorrentStatus, EventType, MediaType, ProgressKey, NotificationType, MessageChannel, \
SystemConfigKey
SystemConfigKey, ChainEventType, ContentType
from app.schemas import StorageOperSelectionEventData
from app.utils.singleton import Singleton
from app.utils.string import StringUtils
@@ -699,10 +701,36 @@ class TransferChain(ChainBase, metaclass=Singleton):
storage=task.fileitem.storage,
src_path=Path(task.fileitem.path),
target_storage=task.target_storage)
if not task.target_storage and task.target_directory:
task.target_storage = task.target_directory.library_storage
# 正在处理
self.jobview.running_task(task)
# 广播事件,请示额外的源存储支持
source_oper = None
source_event_data = StorageOperSelectionEventData(
storage=task.fileitem.storage,
)
source_event = eventmanager.send_event(ChainEventType.StorageOperSelection, source_event_data)
# 使用事件返回的上下文数据
if source_event and source_event.event_data:
source_event_data: StorageOperSelectionEventData = source_event.event_data
if source_event_data.storage_oper:
source_oper = source_event_data.storage_oper
# 广播事件,请示额外的目标存储支持
target_oper = None
target_event_data = StorageOperSelectionEventData(
storage=task.target_storage,
)
target_event = eventmanager.send_event(ChainEventType.StorageOperSelection, target_event_data)
# 使用事件返回的上下文数据
if target_event and target_event.event_data:
target_event_data: StorageOperSelectionEventData = target_event.event_data
if target_event_data.storage_oper:
target_oper = target_event_data.storage_oper
# 执行整理
transferinfo: TransferInfo = self.transfer(fileitem=task.fileitem,
meta=task.meta,
@@ -714,7 +742,9 @@ class TransferChain(ChainBase, metaclass=Singleton):
episodes_info=task.episodes_info,
scrape=task.scrape,
library_type_folder=task.library_type_folder,
library_category_folder=task.library_category_folder)
library_category_folder=task.library_category_folder,
source_oper=source_oper,
target_oper=target_oper)
if not transferinfo:
logger.error("文件整理模块运行失败")
return False, "文件整理模块运行失败"
@@ -1344,22 +1374,16 @@ class TransferChain(ChainBase, metaclass=Singleton):
"""
发送入库成功的消息
"""
msg_title = f"{mediainfo.title_year} {meta.season_episode if not season_episode else season_episode} 已入库"
if mediainfo.vote_average:
msg_str = f"评分:{mediainfo.vote_average},类型:{mediainfo.type.value}"
else:
msg_str = f"类型:{mediainfo.type.value}"
if mediainfo.category:
msg_str = f"{msg_str},类别:{mediainfo.category}"
if meta.resource_term:
msg_str = f"{msg_str},质量:{meta.resource_term}"
msg_str = f"{msg_str},共{transferinfo.file_count}个文件," \
f"大小:{StringUtils.str_filesize(transferinfo.total_size)}"
if transferinfo.message:
msg_str = f"{msg_str},以下文件处理失败:\n{transferinfo.message}"
# 发送
self.post_message(Notification(
mtype=NotificationType.Organize,
title=msg_title, text=msg_str, image=mediainfo.get_message_image(),
username=username,
link=settings.MP_DOMAIN('#/history')))
self.post_message(
Notification(
mtype=NotificationType.Organize,
ctype=ContentType.OrganizeSuccess,
image=mediainfo.get_message_image(),
username=username,
link=settings.MP_DOMAIN('#/history')
),
meta=meta,
mediainfo=mediainfo,
transferinfo=transferinfo,
season_episode=season_episode
)

View File

@@ -101,6 +101,8 @@ class ConfigModel(BaseModel):
TMDB_IMAGE_DOMAIN: str = "image.tmdb.org"
# TMDB API地址
TMDB_API_DOMAIN: str = "api.themoviedb.org"
# TMDB元数据语言
TMDB_LOCALE: str = "zh"
# TMDB API Key
TMDB_API_KEY: str = "db55323b8d3e4154498498a75642b381"
# TVDB API Key
@@ -238,6 +240,7 @@ class ConfigModel(BaseModel):
SECURITY_IMAGE_DOMAINS: List[str] = Field(
default_factory=lambda: ["image.tmdb.org",
"static-mdb.v.geilijiasu.com",
"bing.com",
"doubanio.com",
"lain.bgm.tv",
"raw.githubusercontent.com",

View File

@@ -7,8 +7,10 @@ import time
import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union
from typing import Any, Dict, List, Optional, Type, Union, Callable, Tuple
from fastapi import HTTPException
from starlette import status
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
@@ -220,6 +222,14 @@ class PluginManager(metaclass=Singleton):
self._running_plugins = {}
logger.info("插件停止完成")
@property
def running_plugins(self):
"""
获取运行态插件列表
:return: 运行态插件列表
"""
return self._running_plugins
def reload_monitor(self):
"""
重新加载插件文件修改监测
@@ -407,68 +417,6 @@ class PluginManager(metaclass=Singleton):
self.plugindata.del_data(pid)
return True
def get_plugin_form(self, pid: str) -> Tuple[List[dict], Dict[str, Any]]:
"""
获取插件表单
:param pid: 插件ID
"""
plugin = self._running_plugins.get(pid)
if not plugin:
return [], {}
if hasattr(plugin, "get_form"):
return plugin.get_form() or ([], {})
return [], {}
def get_plugin_page(self, pid: str) -> List[dict]:
"""
获取插件页面
:param pid: 插件ID
"""
plugin = self._running_plugins.get(pid)
if not plugin:
return []
if hasattr(plugin, "get_page"):
return plugin.get_page() or []
return []
def get_plugin_dashboard(self, pid: str, key: Optional[str] = None, **kwargs) -> Optional[schemas.PluginDashboard]:
"""
获取插件仪表盘
:param pid: 插件ID
:param key: 仪表盘key
"""
def __get_params_count(func: Callable):
"""
获取函数的参数信息
"""
signature = inspect.signature(func)
return len(signature.parameters)
plugin = self._running_plugins.get(pid)
if not plugin:
return None
if hasattr(plugin, "get_dashboard"):
# 检查方法的参数个数
params_count = __get_params_count(plugin.get_dashboard)
if params_count > 1:
dashboard: Tuple = plugin.get_dashboard(key=key, **kwargs)
elif params_count > 0:
dashboard: Tuple = plugin.get_dashboard(**kwargs)
else:
dashboard: Tuple = plugin.get_dashboard()
if dashboard:
cols, attrs, elements = dashboard
return schemas.PluginDashboard(
id=pid,
name=plugin.plugin_name,
key=key or "",
cols=cols or {},
elements=elements,
attrs=attrs or {}
)
return None
def get_plugin_state(self, pid: str) -> bool:
"""
获取插件状态
@@ -558,7 +506,63 @@ class PluginManager(metaclass=Singleton):
logger.error(f"获取插件 {plugin_id} 服务出错:{str(e)}")
return ret_services
def get_plugin_dashboard_meta(self):
def get_plugin_modules(self, pid: Optional[str] = None) -> Dict[tuple, Dict[str, Any]]:
"""
获取插件模块
{
plugin_id: {
method: function
}
}
"""
ret_modules = {}
for plugin_id, plugin in self._running_plugins.items():
if pid and pid != plugin_id:
continue
if hasattr(plugin, "get_module") and ObjectUtils.check_method(plugin.get_module):
try:
if not plugin.get_state():
continue
plugin_module = plugin.get_module() or []
ret_modules[(plugin_id, plugin.get_name())] = plugin_module
except Exception as e:
logger.error(f"获取插件 {plugin_id} 模块出错:{str(e)}")
return ret_modules
@staticmethod
def get_plugin_remote_entry(plugin_id: str, dist_path: str) -> str:
"""
获取插件的远程入口地址
:param plugin_id: 插件 ID
:param dist_path: 插件的分发路径
:return: 远程入口地址
"""
if dist_path.startswith("/"):
dist_path = dist_path[1:]
if dist_path.endswith("/"):
dist_path = dist_path[:-1]
return f"/plugin/file/{plugin_id.lower()}/{dist_path}/remoteEntry.js"
def get_plugin_remotes(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:
"""
获取插件联邦组件列表
"""
remotes = []
for plugin_id, plugin in self._running_plugins.items():
if pid and pid != plugin_id:
continue
if hasattr(plugin, "get_render_mode"):
render_mode, dist_path = plugin.get_render_mode()
if render_mode != "vue":
continue
remotes.append({
"id": plugin_id,
"url": self.get_plugin_remote_entry(plugin_id, dist_path),
"name": plugin.plugin_name,
})
return remotes
def get_plugin_dashboard_meta(self) -> List[Dict[str, str]]:
"""
获取所有插件仪表盘元信息
"""
@@ -588,6 +592,49 @@ class PluginManager(metaclass=Singleton):
logger.error(f"获取插件[{plugin_id}]仪表盘元数据出错:{str(e)}")
return dashboard_meta
def get_plugin_dashboard(self, pid: str, key: str, user_agent: str = None) -> schemas.PluginDashboard:
"""
获取插件仪表盘
"""
def __get_params_count(func: Callable):
"""
获取函数的参数信息
"""
signature = inspect.signature(func)
return len(signature.parameters)
# 获取插件实例
plugin_instance = self.running_plugins.get(pid)
if not plugin_instance:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"插件 {pid} 不存在或未加载")
# 渲染模式
render_mode, _ = plugin_instance.get_render_mode()
# 获取插件仪表板
try:
# 检查方法的参数个数
params_count = __get_params_count(plugin_instance.get_dashboard)
if params_count > 1:
dashboard: Tuple = plugin_instance.get_dashboard(key=key, user_agent=user_agent)
elif params_count > 0:
dashboard: Tuple = plugin_instance.get_dashboard(user_agent=user_agent)
else:
dashboard: Tuple = plugin_instance.get_dashboard()
except Exception as e:
logger.error(f"插件 {pid} 调用方法 get_dashboard 出错: {str(e)}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"插件 {pid} 调用方法 get_dashboard 出错: {str(e)}")
cols, attrs, elements = dashboard
return schemas.PluginDashboard(
id=pid,
name=plugin_instance.plugin_name,
key=key,
render_mode=render_mode,
cols=cols or {},
attrs=attrs or {},
)
def get_plugin_attr(self, pid: str, attr: str) -> Any:
"""
获取插件属性

View File

@@ -1,18 +1,525 @@
from __future__ import annotations
import ast
import json
import queue
import re
import threading
import time
from datetime import datetime
from typing import Any, Union
from typing import List, Optional, Callable
from typing import Any, Literal, Optional, List, Dict, Union
from typing import Callable
from cachetools import TTLCache
from jinja2 import Template
from app.core.config import global_vars
from app.core.context import MediaInfo, TorrentInfo
from app.core.meta import MetaBase
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas.message import Notification
from app.schemas.tmdb import TmdbEpisode
from app.schemas.transfer import TransferInfo
from app.schemas.types import SystemConfigKey
from app.utils.singleton import Singleton, SingletonClass
from app.log import logger
from app.utils.string import StringUtils
class TemplateContextBuilder:
"""
模板上下文构建器
"""
def __init__(self):
self._context = {}
def build(
self,
meta: Optional[MetaBase] = None,
mediainfo: Optional[MediaInfo] = None,
torrentinfo: Optional[TorrentInfo] = None,
transferinfo: Optional[TransferInfo] = None,
file_extension: Optional[str] = None,
episodes_info: Optional[List[TmdbEpisode]] = None,
include_raw_objects: bool = False,
**kwargs
) -> Dict[str, Any]:
"""
:param meta: 媒体信息
:param mediainfo: 媒体信息
:param torrentinfo: 种子信息
:param transferinfo: 传输信息
:param file_extension: 文件扩展名
:param episodes_info: 剧集信息
:param include_raw_objects: 是否包含原始对象
:return: 渲染上下文字典
"""
self._context.clear()
self._add_episode_details(meta, episodes_info)
self._add_media_info(mediainfo)
self._add_transfer_info(transferinfo)
self._add_torrent_info(torrentinfo)
self._add_file_info(file_extension)
if kwargs: self._context.update(kwargs)
if include_raw_objects:
self._add_raw_objects(meta, mediainfo, torrentinfo, transferinfo, episodes_info)
return self._context
def _add_media_info(self, mediainfo: MediaInfo):
"""
增加媒体信息
"""
if not mediainfo: return
base_info = {
# 标题
"title": self.__convert_invalid_characters(mediainfo.title),
# 英文标题
"en_title": self.__convert_invalid_characters(mediainfo.en_title),
# 原语种标题
"original_title": self.__convert_invalid_characters(mediainfo.original_title),
# 年份
"year": mediainfo.year or self._context.get("year"),
"title_year": mediainfo.title_year or self._context.get("title_year"),
}
_meta_season = self._context.get("season")
media_info = {
# 类型
"type": mediainfo.type.value,
# 类别
"category": mediainfo.category,
# 评分
"vote_average": mediainfo.vote_average,
# 海报
"poster": mediainfo.get_poster_image(),
# 背景图
"backdrop": mediainfo.get_backdrop_image(),
# 季年份根据season值获取
"season_year": mediainfo.season_years.get(
int(_meta_season),
None) if (mediainfo.season_years and _meta_season) else None,
# 演员
"actors": ''.join([actor['name'] for actor in mediainfo.actors[:5]]),
# 简介
"overview": mediainfo.overview,
# TMDBID
"tmdbid": mediainfo.tmdb_id,
# IMDBID
"imdbid": mediainfo.imdb_id,
# 豆瓣ID
"doubanid": mediainfo.douban_id,
}
self._context.update({**base_info, **media_info})
def _add_episode_details(self, meta: Optional[MetaBase], episodes: Optional[List[TmdbEpisode]]):
"""
添加剧集详细信息
"""
if not meta:
return
episode_data = {"episode_title": None, "episode_date": None}
if meta.begin_episode and episodes:
for episode in episodes:
if episode.episode_number == meta.begin_episode:
episode_data.update({
"episode_title": self.__convert_invalid_characters(episode.name),
"episode_date": episode.air_date if episode.air_date else None
})
break
meta_info = {
# 原文件名
"original_name": meta.title,
# 识别名称(优先使用中文)
"name": meta.name,
# 识别的英文名称(可能为空)
"en_name": meta.en_name,
# 年份
"year": meta.year,
# 名字 + 年份
"title_year": self._context.get("title_year") or "%s (%s)" % (
meta.name, meta.year) if meta.year else meta.name,
# 季号
"season": meta.season_seq,
# 集号
"episode": meta.episode_seqs,
# 季集 SxxExx
"season_episode": "%s%s" % (meta.season, meta.episode),
# 段/节
"part": meta.part,
# 自定义占位符
"customization": meta.customization,
}
tech_metadata = {
# 资源类型
"resourceType": meta.resource_type,
# 特效
"effect": meta.resource_effect,
# 版本
"edition": meta.edition,
# 分辨率
"videoFormat": meta.resource_pix,
# 质量
"resource_term": meta.resource_term,
# 制作组/字幕组
"releaseGroup": meta.resource_team,
# 视频编码
"videoCodec": meta.video_encode,
# 音频编码
"audioCodec": meta.audio_encode,
}
self._context.update({**meta_info, **tech_metadata, **episode_data})
def _add_torrent_info(self, torrentinfo: Optional[TorrentInfo]):
"""
添加种子信息
"""
if not torrentinfo:
return
if torrentinfo.size:
if str(torrentinfo.size).replace(".", "").isdigit():
size = StringUtils.str_filesize(torrentinfo.size)
else:
size = torrentinfo.size
else:
size = 0
if torrentinfo.description:
html_re = re.compile(r'<[^>]+>', re.S)
description = html_re.sub('', torrentinfo.description)
torrentinfo.description = re.sub(r'<[^>]+>', '', description)
torrent_info = {
# 种子标题
"torrent_title": torrentinfo.title,
# 发布时间
"pubdate": torrentinfo.pubdate,
# 免费剩余时间
"freedate": torrentinfo.freedate_diff,
# 做种数
"seeders": torrentinfo.seeders,
# 促销信息
"volume_factor": torrentinfo.volume_factor,
# Hit&Run
"hit_and_run": "" if torrentinfo.hit_and_run else "",
# 种子标签
"labels": ' '.join(torrentinfo.labels),
# 描述
"description": torrentinfo.description,
# 站点名称
"site_name": torrentinfo.site_name,
# 种子大小
"size": size,
}
self._context.update(torrent_info)
def _add_transfer_info(self, transferinfo: Optional[TransferInfo]) -> Optional[Dict]:
"""
添加文件转移上下文
"""
if not transferinfo:
return None
ctx = {
"transfer_type": transferinfo.transfer_type,
"file_count": transferinfo.file_count,
"total_size": StringUtils.str_filesize(transferinfo.total_size),
"err_msg": transferinfo.message,
}
self._context.update(ctx)
def _add_file_info(self, file_extension: Optional[str]):
"""
添加文件信息
"""
if not file_extension: return
file_info = {
# 文件后缀
"fileExt": file_extension,
}
self._context.update(file_info)
def _add_raw_objects(
self,
meta: Optional[MetaBase],
mediainfo: Optional[MediaInfo],
torrentinfo: Optional[TorrentInfo],
transferinfo: Optional[TransferInfo],
episodes_info: Optional[List[TmdbEpisode]],
):
"""
添加原始对象引用
"""
raw_objects = {
# 文件元数据
"__meta__": meta,
# 识别的媒体信息
"__mediainfo__": mediainfo,
# 种子信息
"__torrentinfo__": torrentinfo,
# 文件转移信息
"__transferinfo__": transferinfo,
# 当前季的全部集信息
"__episodes_info__": episodes_info,
}
self._context.update({k: v for k, v in raw_objects.items() if v is not None})
@staticmethod
def __convert_invalid_characters(filename: str):
"""
将不支持的字符转换为全角字符
"""
if not filename:
return filename
invalid_characters = r'\/:*?"<>|'
# 创建半角到全角字符的转换表
halfwidth_chars = "".join([chr(i) for i in range(33, 127)])
fullwidth_chars = "".join([chr(i + 0xFEE0) for i in range(33, 127)])
translation_table = str.maketrans(halfwidth_chars, fullwidth_chars)
# 将不支持的字符替换为对应的全角字符
for char in invalid_characters:
filename = filename.replace(char, char.translate(translation_table))
return filename
class TemplateHelper(metaclass=SingletonClass):
"""
模板格式渲染帮助类
"""
def __init__(self):
self.builder = TemplateContextBuilder()
self.cache = TTLCache(maxsize=100, ttl=600)
@staticmethod
def _generate_cache_key(cuntent: Union[str, dict]) -> str:
"""
生成缓存键
"""
if isinstance(cuntent, dict):
base_str = cuntent.get("title", '') + cuntent.get("text", '')
return StringUtils.md5_hash(json.dumps(base_str, sort_keys=True, ensure_ascii=False))
return StringUtils.md5_hash(cuntent)
def get_cache_context(self, cuntent: Union[str, dict]) -> Optional[dict]:
"""
获取缓存上下文
"""
cache_key = self._generate_cache_key(cuntent)
return self.cache.get(cache_key)
def set_cache_context(self, cuntent: Union[str, dict], context: dict) -> None:
"""
设置缓存上下文
"""
cache_key = self._generate_cache_key(cuntent)
self.cache[cache_key] = context
def render(self,
template_content: str,
template_type: Literal['string', 'dict', 'literal'] = "literal",
**kwargs) -> Optional[Union[str, dict]]:
"""
根据模板格式渲染内容
:param template_content: 模板字符串
:param template_type: 模板字符串类型(消息通知`literal`, 路径`string`)
:param kwargs: 补传业务对象
:raises ValueError: 当模板处理过程中出现错误
:return: 渲染后的结果
"""
try:
# 解析模板字符
parsed = self.parse_template_content(template_content, template_type)
if not parsed:
raise ValueError("模板解析失败")
context = self.builder.build(**kwargs)
if not context:
raise ValueError("上下文构建失败")
rendered = self.render_with_context(parsed, context)
if not rendered:
raise ValueError("模板渲染失败")
if rendered := rendered if template_type == 'string' else self.__process_formatted_string(rendered):
# 缓存上下文
self.set_cache_context(rendered, context)
# 返回渲染结果
return rendered
except Exception as e:
logger.error(f"模板处理失败: {str(e)}")
raise ValueError(f"模板处理失败: {str(e)}") from e
@staticmethod
def render_with_context(template_content: str, context: dict) -> str:
"""
使用指定上下文渲染 Jinja2 模板字符串
template_content: Jinja2 模板字符串
context: 渲染用的上下文数据
"""
# 渲染模板
template = Template(template_content)
return template.render(context)
@staticmethod
def parse_template_content(template_content: Union[str, dict],
template_type: Literal['string', 'dict', 'literal'] = None) -> Optional[str]:
"""
解析模板字符
:param template_content 模板格式字符
:param template_type 模板字符类型
"""
def parse_literal(_template_content: str) -> str:
"""
解析Python字面量
"""
try:
template_dict = ast.literal_eval(_template_content) if isinstance(_template_content,
str) else _template_content
if not isinstance(template_dict, dict):
raise ValueError("解析结果必须是一个字典")
return json.dumps(template_dict, ensure_ascii=False)
except (ValueError, SyntaxError) as err:
raise ValueError(f"无效的Python字面量格式: {str(err)}")
try:
if template_type:
parse_map = {
'string': lambda x: str(x),
'dict': lambda x: json.dumps(x, ensure_ascii=False),
'literal': parse_literal
}
return parse_map[template_type](template_content)
# 自动判断模板类型
if isinstance(template_content, dict):
return json.dumps(template_content, ensure_ascii=False)
elif isinstance(template_content, str):
try:
json.loads(template_content)
return template_content
except json.JSONDecodeError:
try:
return parse_literal(template_content)
except (ValueError, SyntaxError):
return template_content
else:
raise ValueError(f"不支持的模板类型: {type(template_content)}")
except Exception as e:
logger.error(f"模板解析失败: {str(e)}")
return None
@staticmethod
def __process_formatted_string(rendered: str) -> Optional[Union[dict, str]]:
"""
处理格式化字符串
保留转义字符
"""
def restore_chars(obj: Any) -> Any:
"""恢复特殊字符"""
if isinstance(obj, str):
return obj.replace('\\n', '\n').replace('\\r', '\r').replace('\\t', '\t').replace('\\b', '\b').replace(
'\\f', '\f')
elif isinstance(obj, dict):
return {k: restore_chars(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [restore_chars(item) for item in obj]
return obj
# 定义特殊字符映射
special_chars = {
'\n': '\\n', # 换行符
'\r': '\\r', # 回车符
'\t': '\\t', # 制表符
'\b': '\\b', # 退格符
'\f': '\\f', # 换页符
}
# 处理特殊字符
processed = rendered
for char, escape in special_chars.items():
processed = processed.replace(char, escape)
# 尝试解析为JSON
try:
rendered_dict = json.loads(processed)
return restore_chars(rendered_dict)
except json.JSONDecodeError:
return rendered
class MessageTemplateHelper:
"""
消息模板渲染器
"""
@staticmethod
def render(message: Notification, *args, **kwargs) -> Optional[Notification]:
"""
渲染消息模板
"""
if not MessageTemplateHelper.is_instance_valid(message):
if MessageTemplateHelper.meets_update_conditions(message, *args, **kwargs):
logger.info("将使用模板渲染消息内容")
return MessageTemplateHelper._apply_template_data(message, *args, **kwargs)
return message
@staticmethod
def is_instance_valid(message: Notification) -> bool:
"""
检查消息是否有效
"""
if isinstance(message, Notification):
return bool(message.title or message.text)
return False
@staticmethod
def meets_update_conditions(message: Notification, *args, **kwargs) -> bool:
"""
判断是否满足消息实例更新条件
满足条件需同时具备:
1. 消息为有效Notification实例
2. 消息指定了模板类型(ctype)
3. 存在待渲染的模板变量数据
"""
if isinstance(message, Notification):
return True if message.ctype and (args or kwargs) else False
return False
@staticmethod
def _apply_template_data(message: Notification, *args, **kwargs) -> Optional[Notification]:
"""
更新消息实例
"""
try:
if template := MessageTemplateHelper._get_template(message):
rendered = TemplateHelper().render(template_content=template, *args, **kwargs)
for key, value in rendered.items():
if hasattr(message, key):
setattr(message, key, value)
return message
except Exception as e:
logger.error(f"更新Notification时出现错误{str(e)}")
return message
@staticmethod
def _get_template(message: Notification) -> Optional[str]:
"""
获取消息模板
"""
template_dict: dict[str, str] = SystemConfigOper().get(SystemConfigKey.NotificationTemplates)
return template_dict.get(f"{message.ctype.value}")
class MessageQueueManager(metaclass=SingletonClass):

View File

@@ -50,3 +50,24 @@ class StorageHelper:
s.config = conf
break
self.systemconfig.set(SystemConfigKey.Storages, [s.dict() for s in storagies])
def add_storage(self, storage: str, name: str, conf: dict):
"""
添加存储配置
"""
storagies = self.get_storagies()
if not storagies:
storagies = [
schemas.StorageConf(
type=storage,
name=name,
config=conf
)
]
else:
storagies.append(schemas.StorageConf(
type=storage,
name=name,
config=conf
))
self.systemconfig.set(SystemConfigKey.Storages, [s.dict() for s in storagies])

View File

@@ -1,7 +1,7 @@
import re
from pathlib import Path
from threading import Lock
from typing import Optional, List, Tuple, Union, Dict
from typing import Optional, List, Tuple, Union, Dict, Callable
from jinja2 import Template
@@ -11,7 +11,7 @@ from app.core.event import eventmanager
from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo, MetaInfoPath
from app.helper.directory import DirectoryHelper
from app.helper.message import MessageHelper
from app.helper.message import MessageHelper, TemplateHelper
from app.helper.module import ModuleHelper
from app.log import logger
from app.modules import _ModuleBase
@@ -30,6 +30,7 @@ class FileManagerModule(_ModuleBase):
"""
_storage_schemas = []
_support_storages = []
def __init__(self):
super().__init__()
@@ -40,6 +41,8 @@ class FileManagerModule(_ModuleBase):
# 加载模块
self._storage_schemas = ModuleHelper.load('app.modules.filemanager.storages',
filter_func=lambda _, obj: hasattr(obj, 'schema') and obj.schema)
# 获取存储类型
self._support_storages = [storage.schema.value for storage in self._storage_schemas]
@staticmethod
def get_name() -> str:
@@ -114,6 +117,8 @@ class FileManagerModule(_ModuleBase):
"""
支持的整理方式
"""
if storage not in self._support_storages:
return None
storage_oper = self.__get_storage_oper(storage)
if not storage_oper:
logger.error(f"不支持 {storage} 的整理方式获取")
@@ -176,6 +181,8 @@ class FileManagerModule(_ModuleBase):
:param recursion: 是否递归,此时只浏览文件
:return: 文件项列表
"""
if fileitem.storage not in self._support_storages:
return None
storage_oper = self.__get_storage_oper(fileitem.storage)
if not storage_oper:
logger.error(f"不支持 {fileitem.storage} 的文件浏览")
@@ -206,6 +213,8 @@ class FileManagerModule(_ModuleBase):
"""
查询当前目录下是否存在指定扩展名任意文件
"""
if fileitem.storage not in self._support_storages:
return None
storage_oper = self.__get_storage_oper(fileitem.storage)
if not storage_oper:
logger.error(f"不支持 {fileitem.storage} 的文件浏览")
@@ -239,26 +248,32 @@ class FileManagerModule(_ModuleBase):
:param name: 目录名
:return: 创建的目录
"""
if fileitem.storage not in self._support_storages:
return None
storage_oper = self.__get_storage_oper(fileitem.storage)
if not storage_oper:
logger.error(f"不支持 {fileitem.storage} 的目录创建")
return None
return storage_oper.create_folder(fileitem, name)
def delete_file(self, fileitem: FileItem) -> bool:
def delete_file(self, fileitem: FileItem) -> Optional[bool]:
"""
删除文件或目录
"""
if fileitem.storage not in self._support_storages:
return None
storage_oper = self.__get_storage_oper(fileitem.storage)
if not storage_oper:
logger.error(f"不支持 {fileitem.storage} 的删除处理")
return False
return storage_oper.delete(fileitem)
def rename_file(self, fileitem: FileItem, name: str) -> bool:
def rename_file(self, fileitem: FileItem, name: str) -> Optional[bool]:
"""
重命名文件或目录
"""
if fileitem.storage not in self._support_storages:
return None
storage_oper = self.__get_storage_oper(fileitem.storage)
if not storage_oper:
logger.error(f"不支持 {fileitem.storage} 的重命名处理")
@@ -269,6 +284,8 @@ class FileManagerModule(_ModuleBase):
"""
下载文件
"""
if fileitem.storage not in self._support_storages:
return None
storage_oper = self.__get_storage_oper(fileitem.storage)
if not storage_oper:
logger.error(f"不支持 {fileitem.storage} 的下载处理")
@@ -279,6 +296,8 @@ class FileManagerModule(_ModuleBase):
"""
上传文件
"""
if fileitem.storage not in self._support_storages:
return None
storage_oper = self.__get_storage_oper(fileitem.storage)
if not storage_oper:
logger.error(f"不支持 {fileitem.storage} 的上传处理")
@@ -289,6 +308,8 @@ class FileManagerModule(_ModuleBase):
"""
根据路径获取文件项
"""
if storage not in self._support_storages:
return None
storage_oper = self.__get_storage_oper(storage)
if not storage_oper:
logger.error(f"不支持 {storage} 的文件获取")
@@ -299,6 +320,8 @@ class FileManagerModule(_ModuleBase):
"""
获取上级目录项
"""
if fileitem.storage not in self._support_storages:
return None
storage_oper = self.__get_storage_oper(fileitem.storage)
if not storage_oper:
logger.error(f"不支持 {fileitem.storage} 的文件获取")
@@ -309,6 +332,8 @@ class FileManagerModule(_ModuleBase):
"""
快照存储
"""
if storage not in self._support_storages:
return None
storage_oper = self.__get_storage_oper(storage)
if not storage_oper:
logger.error(f"不支持 {storage} 的快照处理")
@@ -319,6 +344,8 @@ class FileManagerModule(_ModuleBase):
"""
存储使用情况
"""
if storage not in self._support_storages:
return None
storage_oper = self.__get_storage_oper(storage)
if not storage_oper:
logger.error(f"不支持 {storage} 的存储使用情况")
@@ -330,7 +357,8 @@ class FileManagerModule(_ModuleBase):
target_storage: Optional[str] = None, target_path: Path = None,
transfer_type: Optional[str] = None, scrape: Optional[bool] = None,
library_type_folder: Optional[bool] = None, library_category_folder: Optional[bool] = None,
episodes_info: List[TmdbEpisode] = None) -> TransferInfo:
episodes_info: List[TmdbEpisode] = None,
source_oper: Callable = None, target_oper: Callable = None) -> TransferInfo:
"""
文件整理
:param fileitem: 文件信息
@@ -344,6 +372,8 @@ class FileManagerModule(_ModuleBase):
:param library_type_folder: 是否按媒体类型创建目录
:param library_category_folder: 是否按媒体类别创建目录
:param episodes_info: 当前季的全部集信息
:param source_oper: 源存储操作对象
:param target_oper: 目标存储操作对象
:return: {path, target_path, message}
"""
# 检查目录路径
@@ -370,9 +400,6 @@ class FileManagerModule(_ModuleBase):
overwrite_mode = target_directory.overwrite_mode
# 是否需要刮削
need_scrape = target_directory.scraping if scrape is None else scrape
# 目标存储类型
if not target_storage:
target_storage = target_directory.library_storage
# 拼装媒体库一、二级子目录
target_path = self.__get_dest_dir(mediainfo=mediainfo, target_dir=target_directory,
need_type_folder=library_type_folder,
@@ -399,6 +426,29 @@ class FileManagerModule(_ModuleBase):
return TransferInfo(success=False,
fileitem=fileitem,
message=f"{target_directory.name} 未设置整理方式")
# 源操作对象
if not source_oper:
source_oper = self.__get_storage_oper(fileitem.storage)
if not source_oper:
return TransferInfo(success=False,
message=f"不支持的存储类型:{fileitem.storage}",
fileitem=fileitem,
fail_list=[fileitem.path],
transfer_type=transfer_type,
need_notify=need_notify
)
# 目的操作对象
if not target_oper:
target_oper = self.__get_storage_oper(target_storage)
if not target_oper:
return TransferInfo(success=False,
message=f"不支持的存储类型:{target_storage}",
fileitem=fileitem,
fail_list=[fileitem.path],
transfer_type=transfer_type,
need_notify=need_notify)
# 整理
logger.info(f"获取整理目标路径:【{target_storage}{target_path}")
return self.transfer_media(fileitem=fileitem,
@@ -411,7 +461,9 @@ class FileManagerModule(_ModuleBase):
need_rename=need_rename,
need_notify=need_notify,
overwrite_mode=overwrite_mode,
episodes_info=episodes_info)
episodes_info=episodes_info,
source_oper=source_oper,
target_oper=target_oper)
def __get_storage_oper(self, _storage: str, _func: Optional[str] = None) -> Optional[StorageBase]:
"""
@@ -430,12 +482,17 @@ class FileManagerModule(_ModuleBase):
"""
pass
def __transfer_command(self, fileitem: FileItem, target_storage: str,
target_file: Path, transfer_type: str) -> Tuple[Optional[FileItem], str]:
@staticmethod
def __transfer_command(fileitem: FileItem, target_storage: str,
source_oper: StorageBase, target_oper: StorageBase,
target_file: Path, transfer_type: str,
) -> Tuple[Optional[FileItem], str]:
"""
处理单个文件
:param fileitem: 源文件
:param target_storage: 目标存储
:param source_oper: 源存储操作对象
:param target_oper: 目标存储操作对象
:param target_file: 目标文件路径
:param transfer_type: 整理方式
"""
@@ -459,13 +516,6 @@ class FileManagerModule(_ModuleBase):
and fileitem.storage != "local" and target_storage != "local"):
return None, f"不支持 {fileitem.storage}{target_storage} 的文件整理"
# 源操作对象
source_oper: StorageBase = self.__get_storage_oper(fileitem.storage)
# 目的操作对象
target_oper: StorageBase = self.__get_storage_oper(target_storage)
if not source_oper or not target_oper:
return None, f"不支持的存储类型:{fileitem.storage}{target_storage}"
# 加锁
with lock:
if fileitem.storage == "local" and target_storage == "local":
@@ -568,18 +618,23 @@ class FileManagerModule(_ModuleBase):
return None, "未知错误"
def __transfer_other_files(self, fileitem: FileItem, target_storage: str, target_file: Path,
transfer_type: str) -> Tuple[bool, str]:
def __transfer_other_files(self, fileitem: FileItem, target_storage: str,
source_oper: StorageBase, target_oper: StorageBase,
target_file: Path, transfer_type: str) -> Tuple[bool, str]:
"""
根据文件名整理其他相关文件
:param fileitem: 源文件
:param target_storage: 目标存储
:param source_oper: 源存储操作对象
:param target_oper: 目标存储操作对象
:param target_file: 目标路径
:param transfer_type: 整理方式
"""
# 整理字幕
state, errmsg = self.__transfer_subtitles(fileitem=fileitem,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
target_file=target_file,
transfer_type=transfer_type)
if not state:
@@ -587,17 +642,22 @@ class FileManagerModule(_ModuleBase):
# 整理音轨文件
state, errmsg = self.__transfer_audio_track_files(fileitem=fileitem,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
target_file=target_file,
transfer_type=transfer_type)
return state, errmsg
def __transfer_subtitles(self, fileitem: FileItem, target_storage: str, target_file: Path,
transfer_type: str) -> Tuple[bool, str]:
def __transfer_subtitles(self, fileitem: FileItem, target_storage: str,
source_oper: StorageBase, target_oper: StorageBase,
target_file: Path, transfer_type: str) -> Tuple[bool, str]:
"""
根据文件名整理对应字幕文件
:param fileitem: 源文件
:param target_storage: 目标存储
:param source_oper: 源存储操作对象
:param target_oper: 目标存储操作对象
:param target_file: 目标路径
:param transfer_type: 整理方式
"""
@@ -617,17 +677,12 @@ class FileManagerModule(_ModuleBase):
# 比对文件名并整理字幕
org_path = Path(fileitem.path)
# 列出所有字幕文件
storage_oper = self.__get_storage_oper(fileitem.storage)
if not storage_oper:
logger.error(f"不支持 {fileitem.storage} 的文件整理")
return False, f"不支持的文件存储:{fileitem.storage}"
# 查找上级文件项
parent_item: FileItem = storage_oper.get_parent(fileitem)
parent_item: FileItem = source_oper.get_parent(fileitem)
if not parent_item:
return False, f"{org_path} 上级目录获取失败"
# 字幕文件列表
file_list: List[FileItem] = storage_oper.list(parent_item) or []
file_list: List[FileItem] = source_oper.list(parent_item) or []
file_list = [f for f in file_list if f.type == "file" and f.extension
and f".{f.extension.lower()}" in settings.RMT_SUBEXT]
if len(file_list) == 0:
@@ -677,9 +732,9 @@ class FileManagerModule(_ModuleBase):
}
new_sub_tag_list = [
(".default" + new_file_type if (
(settings.DEFAULT_SUB == "zh-cn" and new_file_type == ".chi.zh-cn") or
(settings.DEFAULT_SUB == "zh-tw" and new_file_type == ".zh-tw") or
(settings.DEFAULT_SUB == "eng" and new_file_type == ".eng")
(settings.DEFAULT_SUB == "zh-cn" and new_file_type == ".chi.zh-cn") or
(settings.DEFAULT_SUB == "zh-tw" and new_file_type == ".zh-tw") or
(settings.DEFAULT_SUB == "eng" and new_file_type == ".eng")
) else new_file_type) if t == 0 else "%s%s(%s)" % (new_file_type,
new_sub_tag_dict.get(
new_file_type, ""
@@ -693,6 +748,8 @@ class FileManagerModule(_ModuleBase):
logger.debug(f"正在处理字幕:{sub_item.name}")
new_item, errmsg = self.__transfer_command(fileitem=sub_item,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
target_file=new_file,
transfer_type=transfer_type)
if new_item:
@@ -705,26 +762,24 @@ class FileManagerModule(_ModuleBase):
logger.info(f"字幕 {new_file} 出错了,原因: {str(error)}")
return True, ""
def __transfer_audio_track_files(self, fileitem: FileItem, target_storage: str, target_file: Path,
transfer_type: str) -> Tuple[bool, str]:
def __transfer_audio_track_files(self, fileitem: FileItem, target_storage: str,
source_oper: StorageBase, target_oper: StorageBase,
target_file: Path, transfer_type: str) -> Tuple[bool, str]:
"""
根据文件名整理对应音轨文件
:param fileitem: 源文件
:param target_storage: 目标存储
:param source_oper: 源存储操作对象
:param target_oper: 目标存储操作对象
:param target_file: 目标路径
:param transfer_type: 整理方式
"""
org_path = Path(fileitem.path)
# 列出所有音轨文件
storage_oper = self.__get_storage_oper(fileitem.storage)
if not storage_oper:
logger.error(f"不支持 {fileitem.storage} 的文件整理")
return False, f"不支持的文件存储:{fileitem.storage}"
# 查找上级文件项
parent_item: FileItem = storage_oper.get_parent(fileitem)
parent_item: FileItem = source_oper.get_parent(fileitem)
if not parent_item:
return False, f"{org_path} 上级目录获取失败"
file_list: List[FileItem] = storage_oper.list(parent_item)
file_list: List[FileItem] = source_oper.list(parent_item)
# 匹配音轨文件
pending_file_list: List[FileItem] = [file for file in file_list
if Path(file.name).stem == org_path.stem
@@ -740,6 +795,8 @@ class FileManagerModule(_ModuleBase):
logger.info(f"正在整理音轨文件:{track_file}{new_track_file}")
new_item, errmsg = self.__transfer_command(fileitem=track_file,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
target_file=new_track_file,
transfer_type=transfer_type)
if new_item:
@@ -750,21 +807,19 @@ class FileManagerModule(_ModuleBase):
logger.error(f"音轨文件 {org_path.name} 整理失败:{str(error)}")
return True, ""
def __transfer_dir(self, fileitem: FileItem, mediainfo: MediaInfo, transfer_type: str,
target_storage: str, target_path: Path) -> Tuple[Optional[FileItem], str]:
def __transfer_dir(self, fileitem: FileItem, mediainfo: MediaInfo,
source_oper: StorageBase, target_oper: StorageBase,
transfer_type: str, target_storage: str, target_path: Path) -> Tuple[Optional[FileItem], str]:
"""
整理整个文件夹
:param fileitem: 源文件
:param mediainfo: 媒体信息
:param source_oper: 源存储操作对象
:param target_oper: 目标存储操作对象
:param transfer_type: 整理方式
:param target_storage: 目标存储
:param target_path: 目标路径
"""
# 获取目标目录
target_oper: StorageBase = self.__get_storage_oper(target_storage)
if not target_oper:
return None, f"不支持的文件存储:{target_storage}"
logger.info(f"正在整理目录:{fileitem.path}{target_path}")
target_item = target_oper.get_folder(target_path)
if not target_item:
@@ -788,6 +843,8 @@ class FileManagerModule(_ModuleBase):
# 处理所有文件
state, errmsg = self.__transfer_dir_files(fileitem=fileitem,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
target_path=target_path,
transfer_type=transfer_type)
if state:
@@ -795,29 +852,29 @@ class FileManagerModule(_ModuleBase):
else:
return None, errmsg
def __transfer_dir_files(self, fileitem: FileItem, transfer_type: str,
target_storage: str, target_path: Path) -> Tuple[bool, str]:
def __transfer_dir_files(self, fileitem: FileItem, target_storage: str,
source_oper: StorageBase, target_oper: StorageBase,
transfer_type: str, target_path: Path) -> Tuple[bool, str]:
"""
按目录结构整理目录下所有文件
:param fileitem: 源文件
:param target_storage: 目标存储
:param source_oper: 源存储操作对象
:param target_oper: 目标存储操作对象
:param target_path: 目标路径
:param transfer_type: 整理方式
"""
# 列出所有文件
storage_oper = self.__get_storage_oper(fileitem.storage)
if not storage_oper:
logger.error(f"不支持 {fileitem.storage} 的文件整理")
return False, f"不支持的文件存储:{fileitem.storage}"
file_list: List[FileItem] = storage_oper.list(fileitem)
file_list: List[FileItem] = source_oper.list(fileitem)
# 整理文件
for item in file_list:
if item.type == "dir":
# 递归整理目录
new_path = target_path / item.name
state, errmsg = self.__transfer_dir_files(fileitem=item,
transfer_type=transfer_type,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
transfer_type=transfer_type,
target_path=new_path)
if not state:
return False, errmsg
@@ -826,6 +883,8 @@ class FileManagerModule(_ModuleBase):
new_file = target_path / item.name
new_item, errmsg = self.__transfer_command(fileitem=item,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
target_file=new_file,
transfer_type=transfer_type)
if not new_item:
@@ -833,16 +892,23 @@ class FileManagerModule(_ModuleBase):
# 返回成功
return True, ""
def __transfer_file(self, fileitem: FileItem, mediainfo: MediaInfo, target_storage: str, target_file: Path,
transfer_type: str, over_flag: Optional[bool] = False) -> Tuple[Optional[FileItem], str]:
def __transfer_file(self, fileitem: FileItem, mediainfo: MediaInfo,
source_oper: StorageBase, target_oper: StorageBase,
target_storage: str, target_file: Path,
transfer_type: str, over_flag: Optional[bool] = False) -> Tuple[
Optional[FileItem], str]:
"""
整理一个文件,同时处理其他相关文件
:param fileitem: 原文件
:param mediainfo: 媒体信息
:param source_oper: 源存储操作对象
:param target_oper: 目标存储操作对象
:param target_storage: 目标存储
:param target_file: 新文件
:param transfer_type: 整理方式
:param over_flag: 是否覆盖为True时会先删除再整理
:param source_oper: 源存储操作对象
:param target_oper: 目标存储操作对象
"""
logger.info(f"正在整理文件:【{fileitem.storage}{fileitem.path} 到 【{target_storage}{target_file}"
f"操作类型:{transfer_type}")
@@ -874,12 +940,16 @@ class FileManagerModule(_ModuleBase):
target_file.unlink()
new_item, errmsg = self.__transfer_command(fileitem=fileitem,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
target_file=target_file,
transfer_type=transfer_type)
if new_item:
# 处理其他相关文件
self.__transfer_other_files(fileitem=fileitem,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
target_file=target_file,
transfer_type=transfer_type)
return new_item, errmsg
@@ -936,11 +1006,13 @@ class FileManagerModule(_ModuleBase):
target_storage: str,
target_path: Path,
transfer_type: str,
source_oper: StorageBase,
target_oper: StorageBase,
need_scrape: Optional[bool] = False,
need_rename: Optional[bool] = True,
need_notify: Optional[bool] = True,
overwrite_mode: Optional[str] = None,
episodes_info: List[TmdbEpisode] = None,
episodes_info: List[TmdbEpisode] = None
) -> TransferInfo:
"""
识别并整理一个文件或者一个目录下的所有文件
@@ -950,6 +1022,8 @@ class FileManagerModule(_ModuleBase):
:param target_storage: 目标存储
:param target_path: 目标路径
:param transfer_type: 文件整理方式
:param source_oper: 源存储操作对象
:param target_oper: 目标存储操作对象
:param need_scrape: 是否需要刮削
:param need_rename: 是否需要重命名
:param need_notify: 是否需要通知
@@ -977,6 +1051,8 @@ class FileManagerModule(_ModuleBase):
# 整理目录
new_diritem, errmsg = self.__transfer_dir(fileitem=fileitem,
mediainfo=mediainfo,
source_oper=source_oper,
target_oper=target_oper,
target_storage=target_storage,
target_path=new_path,
transfer_type=transfer_type)
@@ -1040,8 +1116,6 @@ class FileManagerModule(_ModuleBase):
# 判断是否要覆盖
overflag = False
# 目的操作对象
target_oper: StorageBase = self.__get_storage_oper(target_storage)
# 计算重命名中的文件夹层级
rename_format_level = len(rename_format.split("/")) - 1
folder_path = new_file.parents[rename_format_level - 1]
@@ -1102,14 +1176,16 @@ class FileManagerModule(_ModuleBase):
if overwrite_mode == 'latest':
# 文件不存在,但仅保留最新版本
logger.info(f"当前整理覆盖模式设置为 {overwrite_mode},仅保留最新版本,正在删除已有版本文件 ...")
self.__delete_version_files(target_storage, new_file)
self.__delete_version_files(target_oper, new_file)
# 整理文件
new_item, err_msg = self.__transfer_file(fileitem=fileitem,
mediainfo=mediainfo,
target_storage=target_storage,
target_file=new_file,
transfer_type=transfer_type,
over_flag=overflag)
over_flag=overflag,
source_oper=source_oper,
target_oper=target_oper)
if not new_item:
logger.error(f"文件 {fileitem.path} 整理失败:{err_msg}")
return TransferInfo(success=False,
@@ -1142,97 +1218,8 @@ class FileManagerModule(_ModuleBase):
:param file_ext: 文件扩展名
:param episodes_info: 当前季的全部集信息
"""
def __convert_invalid_characters(filename: str):
if not filename:
return filename
invalid_characters = r'\/:*?"<>|'
# 创建半角到全角字符的转换表
halfwidth_chars = "".join([chr(i) for i in range(33, 127)])
fullwidth_chars = "".join([chr(i + 0xFEE0) for i in range(33, 127)])
translation_table = str.maketrans(halfwidth_chars, fullwidth_chars)
# 将不支持的字符替换为对应的全角字符
for char in invalid_characters:
filename = filename.replace(char, char.translate(translation_table))
return filename
# 获取集标题
episode_title = None
if meta.begin_episode and episodes_info:
for episode in episodes_info:
if episode.episode_number == meta.begin_episode:
episode_title = episode.name
break
# 获取集播出日期
episode_date = None
if meta.begin_episode and episodes_info:
for episode in episodes_info:
if episode.episode_number == meta.begin_episode:
episode_date = episode.air_date
break
return {
# 标题
"title": __convert_invalid_characters(mediainfo.title),
# 英文标题
"en_title": __convert_invalid_characters(mediainfo.en_title),
# 原语种标题
"original_title": __convert_invalid_characters(mediainfo.original_title),
# 原文件名
"original_name": meta.title,
# 识别名称(优先使用中文)
"name": meta.name,
# 识别的英文名称(可能为空)
"en_name": meta.en_name,
# 年份
"year": mediainfo.year or meta.year,
# 季年份根据season值获取
"season_year": mediainfo.season_years.get(
int(meta.season_seq),
None) if (mediainfo.season_years and meta.season_seq) else None,
# 资源类型
"resourceType": meta.resource_type,
# 特效
"effect": meta.resource_effect,
# 版本
"edition": meta.edition,
# 分辨率
"videoFormat": meta.resource_pix,
# 制作组/字幕组
"releaseGroup": meta.resource_team,
# 视频编码
"videoCodec": meta.video_encode,
# 音频编码
"audioCodec": meta.audio_encode,
# TMDBID
"tmdbid": mediainfo.tmdb_id,
# IMDBID
"imdbid": mediainfo.imdb_id,
# 豆瓣ID
"doubanid": mediainfo.douban_id,
# 季号
"season": meta.season_seq,
# 集号
"episode": meta.episode_seqs,
# 季集 SxxExx
"season_episode": "%s%s" % (meta.season, meta.episode),
# 段/节
"part": meta.part,
# 剧集标题
"episode_title": __convert_invalid_characters(episode_title),
# 剧集日期根据episodes_info值获取
"episode_date": episode_date,
# 文件后缀
"fileExt": file_ext,
# 自定义占位符
"customization": meta.customization,
# 文件元数据
"__meta__": meta,
# 识别的媒体信息
"__mediainfo__": mediainfo,
# 当前季的全部集信息
"__episodes_info__": episodes_info,
}
return TemplateHelper().builder.build(meta=meta, mediainfo=mediainfo,
file_extension=file_ext, episodes_info=episodes_info)
@staticmethod
def get_rename_path(template_string: str, rename_dict: dict, path: Path = None) -> Path:
@@ -1351,14 +1338,14 @@ class FileManagerModule(_ModuleBase):
logger.info(f"{mediainfo.title_year} 在本地文件系统中找到了这些季集:{seasons}")
return ExistMediaInfo(type=MediaType.TV, seasons=seasons)
def __delete_version_files(self, target_storage: str, path: Path) -> bool:
@staticmethod
def __delete_version_files(storage_oper: StorageBase, path: Path) -> bool:
"""
删除目录下的所有版本文件
:param target_storage: 存储类型
:param storage_oper: 存储操作对象
:param path: 目录路径
"""
# 存储
storage_oper = self.__get_storage_oper(target_storage)
if not storage_oper:
return False
# 识别文件中的季集信息

View File

@@ -268,7 +268,7 @@ class TheMovieDbModule(_ModuleBase):
# 当前季第一季时间
first_date = episodes[0].get("air_date")
# 判断是不是日期格式
if re.match(r"^\d{4}-\d{2}-\d{2}$", first_date):
if first_date and re.match(r"^\d{4}-\d{2}-\d{2}$", first_date):
season_years[season] = str(first_date).split("-")[0]
if season_years:
mediainfo.season_years = season_years

View File

@@ -33,7 +33,7 @@ class TmdbApi:
# APIKEY
self.tmdb.api_key = settings.TMDB_API_KEY
# 语种
self.tmdb.language = 'zh'
self.tmdb.language = settings.TMDB_LOCALE
# 代理
self.tmdb.proxies = settings.PROXY
# 调试模式
@@ -632,7 +632,8 @@ class TmdbApi:
# 转换多语种标题
self.__update_tmdbinfo_extra_title(tmdb_info)
# 转换中文标题
self.__update_tmdbinfo_cn_title(tmdb_info)
if settings.TMDB_LOCALE == "zh":
self.__update_tmdbinfo_cn_title(tmdb_info)
return tmdb_info

View File

@@ -55,6 +55,13 @@ class _PluginBase(metaclass=ABCMeta):
"""
pass
def get_name(self) -> str:
"""
获取插件名称
:return: 插件名称
"""
return self.plugin_name
@abstractmethod
def get_state(self) -> bool:
"""
@@ -76,6 +83,14 @@ class _PluginBase(metaclass=ABCMeta):
"""
pass
@staticmethod
def get_render_mode() -> Tuple[str, Optional[str]]:
"""
获取插件渲染模式
:return: 1、渲染模式支持vue/vuetify默认vuetify2、vue模式下编译后文件的相对路径默认为`dist/asserts`vuetify模式下为None
"""
return "vuetify", None
@abstractmethod
def get_api(self) -> List[Dict[str, Any]]:
"""
@@ -91,18 +106,19 @@ class _PluginBase(metaclass=ABCMeta):
pass
@abstractmethod
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
def get_form(self) -> Tuple[Optional[List[dict]], Dict[str, Any]]:
"""
拼装插件配置页面,需要返回两块数据1、页面配置2、数据结构
插件配置页面使用Vuetify组件拼装参考https://vuetifyjs.com/
拼装插件配置页面,插件配置页面使用Vuetify组件拼装参考https://vuetifyjs.com/
:return: 1、页面配置vuetify模式或 Nonevue模式2、默认数据结构
"""
pass
@abstractmethod
def get_page(self) -> List[dict]:
def get_page(self) -> Optional[List[dict]]:
"""
拼装插件详情页面,需要返回页面配置,同时附带数据
插件详情页面使用Vuetify组件拼装参考https://vuetifyjs.com/
:return: 页面配置vuetify模式或 Nonevue模式
"""
pass
@@ -119,9 +135,9 @@ class _PluginBase(metaclass=ABCMeta):
"""
pass
def get_dashboard(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], List[dict]]]:
def get_dashboard(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], Optional[List[dict]]]]:
"""
获取插件仪表盘页面需要返回1、仪表板col配置字典2、全局配置自动刷新等3、仪表板页面元素配置json含数据
获取插件仪表盘页面需要返回1、仪表板col配置字典2、全局配置布局、自动刷新等3、仪表板页面元素配置含数据jsonvuetify或 Nonevue模式
1、col配置参考
{
"cols": 12, "md": 6
@@ -133,7 +149,7 @@ class _PluginBase(metaclass=ABCMeta):
"title": "组件标题", // 组件标题,如有将显示该标题,否则显示插件名称
"subtitle": "组件子标题", // 组件子标题,缺省时不展示子标题
}
3、页面配置使用Vuetify组件拼装参考https://vuetifyjs.com/
3、vuetify模式页面配置使用Vuetify组件拼装参考https://vuetifyjs.com/vue模式为None
kwargs参数可获取的值1、user_agent浏览器UA
@@ -155,6 +171,16 @@ class _PluginBase(metaclass=ABCMeta):
"""
pass
def get_module(self) -> Dict[str, Any]:
"""
获取插件模块声明,用于胁持系统模块实现(方法名:方法实现)
{
"id1": self.xxx1,
"id2": self.xxx2,
}
"""
pass
@abstractmethod
def stop_service(self):
"""

View File

@@ -1,5 +1,5 @@
from pathlib import Path
from typing import Optional, Dict, Any, List, Set
from typing import Optional, Dict, Any, List, Set, Callable
from pydantic import BaseModel, Field, root_validator
@@ -307,3 +307,21 @@ class MediaRecognizeConvertEventData(ChainEventData):
# 输出参数
media_dict: dict = Field(default=dict, description="转换后的媒体信息TheMovieDb/豆瓣)")
class StorageOperSelectionEventData(ChainEventData):
"""
StorageOperSelect 事件的数据模型
Attributes:
# 输入参数
storage (str): 存储类型
# 输出参数
storage_oper (Callable): 存储操作对象
"""
# 输入参数
storage: Optional[str] = Field(default=None, description="存储类型")
# 输出参数
storage_oper: Optional[Callable] = Field(default=None, description="存储操作对象")

View File

@@ -2,7 +2,7 @@ from typing import Optional, Union
from pydantic import BaseModel, Field
from app.schemas.types import NotificationType, MessageChannel
from app.schemas.types import ContentType, NotificationType, MessageChannel
class CommingMessage(BaseModel):
@@ -45,6 +45,8 @@ class Notification(BaseModel):
source: Optional[str] = None
# 消息类型
mtype: Optional[NotificationType] = None
# 内容类型
ctype: Optional[ContentType] = None
# 标题
title: Optional[str] = None
# 文本内容

View File

@@ -59,6 +59,8 @@ class PluginDashboard(Plugin):
name: Optional[str] = None
# 仪表板key
key: Optional[str] = None
# 演染模式
render_mode: Optional[str] = Field(default="vuetify")
# 全局配置
attrs: Optional[dict] = Field(default_factory=dict)
# col列数

View File

@@ -89,6 +89,8 @@ class ChainEventType(Enum):
RecommendSource = "recommend.source"
# 工作流执行
WorkflowExecution = "workflow.execution"
# 存储操作选择
StorageOperSelection = "storage.operation"
# 系统配置Key字典
@@ -149,6 +151,8 @@ class SystemConfigKey(Enum):
FollowSubscribers = "FollowSubscribers"
# 通知发送时间
NotificationSendTime = "NotificationSendTime"
# 通知消息格式模板
NotificationTemplates = "NotificationTemplates"
# 处理进度Key字典
@@ -187,6 +191,21 @@ class NotificationType(Enum):
Other = "其它"
class ContentType(str, Enum):
"""
消息内容类型
操作状态的通知消息类型标识
"""
# 订阅添加成功
SubscribeAdded: str = "subscribeAdded"
# 订阅完成
SubscribeComplete: str = "subscribeComplete"
# 入库成功
OrganizeSuccess: str = "organizeSuccess"
# 下载开始(添加下载任务成功)
DownloadAdded: str = "downloadAdded"
# 消息渠道
class MessageChannel(Enum):
"""

View File

@@ -0,0 +1,67 @@
"""2.1.4
Revision ID: 89d24811e894
Revises: 4b544f5d3b07
Create Date: 2025-05-03 17:29:07.635618
"""
from app.db.systemconfig_oper import SystemConfigOper
from app.schemas.types import SystemConfigKey
# revision identifiers, used by Alembic.
revision = '89d24811e894'
down_revision = '4b544f5d3b07'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
value = {
"organizeSuccess": """
{
'title': '{{ title_year }}'
'{% if season_episode %} {{ season_episode }}{% endif %} 已入库',
'text': '{% if vote_average %}评分:{{ vote_average }}{% endif %}'
'类型:{{ type }}'
'{% if category %},类别:{{ category }}{% endif %}'
'{% if resource_term %},质量:{{ resource_term }}{% endif %}'
'{{ file_count }}个文件,大小:{{ total_size }}'
'{% if err_msg %},以下文件处理失败:{{ err_msg }}{% endif %}'
}""",
"downloadAdded": """
{
'title': '{{ title_year }}'
'{% if download_episodes %} {{ season }} {{ download_episodes }}{% else %}{{ season_episode }}{% endif %} 开始下载',
'text': '{% if site_name %}站点:{{ site_name }}{% endif %}'
'{% if resource_term %}\\n质量{{ resource_term }}{% endif %}'
'{% if size %}\\n大小{{ size }}{% endif %}'
'{% if title %}\\n种子{{ title }}{% endif %}'
'{% if pubdate %}\\n发布时间{{ pubdate }}{% endif %}'
'{% if freedate %}\\n免费时间{{ freedate }}{% endif %}'
'{% if seeders %}\\n做种数{{ seeders }}{% endif %}'
'{% if volume_factor %}\\n促销{{ volume_factor }}{% endif %}'
'{% if hit_and_run %}\\nHit&Run{{ hit_and_run }}{% endif %}'
'{% if labels %}\\n标签{{ labels }}{% endif %}'
'{% if description %}\\n描述{{ description }}{% endif %}'
}""",
"subscribeAdded": "{'title': '{{ title_year }} {{season}} 已添加订阅'}",
"subscribeComplete": """
{
'title': '{{ title_year }} {{season}} 已完成{{msgstr}}',
'text': '{% if vote_average %}评分:{{ vote_average }}{% endif %}'
'{% if username %},来自用户:{{ username }}{% endif %}'
'{% if actors %}\\n演员{{ actors }}{% endif %}'
'{% if overview %}\\n简介{{ overview }}{% endif %}'
}"""
}
_systemconfig = SystemConfigOper()
if not _systemconfig.get(SystemConfigKey.NotificationTemplates):
_systemconfig.set(SystemConfigKey.NotificationTemplates, value)
# ### end Alembic commands ###
def downgrade() -> None:
pass

View File

@@ -1,2 +1,2 @@
APP_VERSION = 'v2.4.2'
FRONTEND_VERSION = 'v2.4.2'
APP_VERSION = 'v2.4.4'
FRONTEND_VERSION = 'v2.4.4'