Compare commits

...

12 Commits

Author SHA1 Message Date
jxxghp
1ae826cf14 Merge pull request #991 from xiaoQQya/develop 2026-02-24 19:57:39 +08:00
xiaoQQya
f438490ca5 perf(AutoSignIn): 优化站点 Rousi Pro 签到失败提示信息 2026-02-23 21:22:13 +08:00
jxxghp
b938ca5bf3 Merge pull request #988 from YuHoYe/feat/dailysummary 2026-02-10 06:56:14 +08:00
YuHoYe
028103b900 fix(DailySummary): 简化配置界面 + 修复通知标题重复
- 移除高级设置 tab(signin_plugin_id / brush_plugin_ids / storage_paths)
  这些内部实现细节不该暴露给用户,改为代码内硬编码默认值
  存储路径改为纯自动检测 MP 的 LIBRARY_PATH / DOWNLOAD_PATH
- 去掉 VTabs,报告模块选择器直接平铺
- Cron 字段和开关移到 VTabs 外面,避免弹出菜单被裁剪
- 修复通知标题重复:text 中不再拼接 header,由 post_message 的 title 参数单独传递
2026-02-09 23:25:51 +08:00
YuHoYe
bb1f159198 feat(DailySummary): Cron 字段改用 VCronField GUI 选择器 2026-02-09 23:23:10 +08:00
YuHoYe
6fa42abc17 fix(DailySummary): 旧配置升级时回写默认模块选择 2026-02-09 23:23:10 +08:00
YuHoYe
95b952c27f feat(DailySummary): 详情页展示模块配置和发送统计 2026-02-09 23:23:10 +08:00
jxxghp
6631d06a04 Merge pull request #987 from YuHoYe/feat/dailysummary 2026-02-09 06:24:53 +08:00
jxxghp
1afce8c607 Merge pull request #986 from BlueflameLi/main 2026-02-09 06:23:50 +08:00
YuHoYe
82c825e349 fix(DailySummary): 修复详情页面不显示问题
- 参考 BrushFlow 的 get_page 结构,将所有内容放在单个 VRow 内
- 无数据时返回「暂无数据」提示
- 表格 height 改为 '30rem'(字符串)
- 统计卡片对齐官方组件风格
2026-02-09 00:41:43 +08:00
YuHoYe
ff7d7b1fa4 feat(DailySummary): 新增活动总结插件
定时发送每日/每周/每月活动总结通知,支持自定义报告模块和历史记录查看。
2026-02-08 23:06:18 +08:00
BlueflameLi
328ed9884a 🐞 fix(MoviePilotUpdateNotify): 修复版本号比较逻辑 2026-02-08 22:00:03 +08:00
7 changed files with 799 additions and 14 deletions

View File

@@ -499,11 +499,12 @@
"name": "MoviePilot更新推送",
"description": "MoviePilot推送release更新通知、自动重启。",
"labels": "消息通知,自动更新",
"version": "1.5",
"version": "1.5.1",
"icon": "Moviepilot_A.png",
"author": "thsrite",
"level": 1,
"history": {
"v1.5.1": "修复版本号比较逻辑",
"v1.5": "修复版本描述为空时的报错",
"v1.4": "兼容更新内容带版本号的情况",
"v1.3": "增加前端版本更新检查需要主程序升级至v1.8.4+版本"

View File

@@ -44,12 +44,13 @@
"name": "站点自动签到",
"description": "自动模拟登录、签到站点。",
"labels": "站点",
"version": "2.8.1",
"version": "2.8.2",
"icon": "signin.png",
"author": "thsrite",
"level": 2,
"release": true,
"history": {
"v2.8.2": "优化站点 Rousi Pro 签到失败提示信息",
"v2.8.1": "更新站点 Rousi Pro 签到接口",
"v2.8": "适配站点 Rousi Pro",
"v2.7": "站点请求使用站点设置的超时时间",
@@ -385,11 +386,12 @@
"name": "MoviePilot更新推送",
"description": "MoviePilot推送release更新通知、自动重启。",
"labels": "消息通知,自动更新",
"version": "2.3",
"version": "2.3.1",
"icon": "Moviepilot_A.png",
"author": "thsrite",
"level": 1,
"history": {
"v2.3.1": "修复版本号比较逻辑",
"v2.3": "修复版本描述为空时的报错",
"v2.2": "支持 MoviePilot v2.5.0+",
"v2.1": "优化执行周期输入需要MoviePilot v2.2.1+",
@@ -613,5 +615,17 @@
"v1.4.2": "适配MoviePilot v2.8.8+",
"v1.4.1": "MoviePilot V2 版本登录壁纸本地化插件"
}
},
"DailySummary": {
"name": "活动总结",
"description": "定时发送每日/每周/每月活动总结通知,支持自定义报告模块、历史记录查看",
"labels": "通知",
"version": "2.0.0",
"icon": "Bark_A.png",
"author": "yuhoye",
"level": 1,
"history": {
"v2.0.0": "首次发布:可配置报告模块、历史记录页面、下载器通用化、存储路径可配置"
}
}
}

View File

@@ -35,7 +35,7 @@ class AutoSignIn(_PluginBase):
# 插件图标
plugin_icon = "signin.png"
# 插件版本
plugin_version = "2.8.1"
plugin_version = "2.8.2"
# 插件作者
plugin_author = "thsrite"
# 作者主页

View File

@@ -64,8 +64,8 @@ class RousiPro(_ISiteSigninHandler):
logger.info(f"{site} 今日已签到")
return True, "今日已签到"
elif res is not None and res.status_code == 401:
logger.error(f"{site} 签到失败,登录状态无")
return False, "签到失败,登录状态无"
logger.error(f"{site} 签到失败,Authorization 已失")
return False, "签到失败,Authorization 已失"
elif res is not None:
logger.error(f"{site} 签到失败,状态码:{res.status_code}")
return False, f"签到失败,状态码:{res.status_code}"
@@ -104,8 +104,8 @@ class RousiPro(_ISiteSigninHandler):
logger.info(f"{site} 模拟登录成功")
return True, "模拟登录成功"
elif res is not None and res.status_code == 401:
logger.error(f"{site} 模拟登录失败,登录状态无")
return False, "模拟登录失败,登录状态无"
logger.error(f"{site} 模拟登录失败,Authorization 已失")
return False, "模拟登录失败,Authorization 已失"
elif res is not None:
logger.error(f"{site} 模拟登录失败,状态码:{res.status_code}")
return False, f"模拟登录失败,状态码:{res.status_code}"

View File

@@ -0,0 +1,769 @@
"""MoviePilot 活动总结插件 — 定时发送每日/每周/每月活动总结通知"""
import os
from collections import OrderedDict
from dataclasses import dataclass
from typing import Any, List, Dict, Tuple, Optional
from datetime import datetime, timedelta
import pytz
from apscheduler.triggers.cron import CronTrigger
from app.log import logger
from app.plugins import _PluginBase
from app.schemas import NotificationType
from app.core.event import eventmanager, Event
from app.schemas.types import EventType
from app.core.config import settings
from app.db.transferhistory_oper import TransferHistoryOper
from app.db.subscribe_oper import SubscribeOper
from app.db.plugindata_oper import PluginDataOper
from app.db.models.siteuserdata import SiteUserData
from app.db import ScopedSession
# ─── 模块注册表key → 中文名,各报告按需选取 ───
MODULES = OrderedDict([
("download", "下载记录"),
("transfer", "入库记录"),
("signin", "签到状态"),
("brush", "刷流统计"),
("downloader", "下载器概览"),
("site_delta", "站点增量"),
("site_current", "站点快照"),
("subscribe", "订阅进度"),
("storage", "存储空间"),
])
MODULE_OPTIONS = [{"title": name, "value": key} for key, name in MODULES.items()]
# 各报告类型的默认模块
DEFAULT_DAILY_MODULES = ["download", "transfer", "signin", "brush", "downloader", "site_delta"]
DEFAULT_WEEKLY_MODULES = ["download", "transfer", "subscribe", "site_delta", "brush"]
DEFAULT_MONTHLY_MODULES = [
"download", "transfer", "subscribe", "site_current",
"site_delta", "storage", "brush", "downloader",
]
WEEKDAY_NAMES = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
# 历史记录上限
MAX_HISTORY = 100
@dataclass
class TimeRange:
"""报告的时间范围"""
start: datetime
end: datetime
start_str: str # "YYYY-MM-DD HH:MM:SS" — 用于数据库查询
start_date: str # "YYYY-MM-DD"
end_date: str # "YYYY-MM-DD"
report_type: str # "daily" / "weekly" / "monthly"
prefix: str # "今日" / "本周" / "本月"
class DailySummary(_PluginBase):
plugin_name = "活动总结"
plugin_desc = "定时发送每日/每周/每月活动总结通知,支持自定义报告模块、历史记录查看"
plugin_icon = "Bark_A.png"
plugin_version = "2.0.0"
plugin_author = "yuhoye"
author_url = "https://github.com/yuhoye"
plugin_config_prefix = "dailysummary_"
plugin_order = 30
auth_level = 1
# ─── 配置字段 ───
_enabled: bool = False
_notify: bool = True
_daily_cron: str = "0 23 * * *"
_weekly_cron: str = "0 23 * * 1"
_monthly_cron: str = "0 23 1 * *"
_onlyonce: bool = False
_test_type: str = "daily"
_daily_modules: list = None
_weekly_modules: list = None
_monthly_modules: list = None
def init_plugin(self, config: dict = None):
if config:
self._enabled = config.get("enabled", False)
self._notify = config.get("notify", True)
self._daily_cron = config.get("daily_cron", "0 23 * * *")
self._weekly_cron = config.get("weekly_cron", "0 23 * * 1")
self._monthly_cron = config.get("monthly_cron", "0 23 1 * *")
self._onlyonce = config.get("onlyonce", False)
self._test_type = config.get("test_type", "daily")
self._daily_modules = config.get("daily_modules") or DEFAULT_DAILY_MODULES
self._weekly_modules = config.get("weekly_modules") or DEFAULT_WEEKLY_MODULES
self._monthly_modules = config.get("monthly_modules") or DEFAULT_MONTHLY_MODULES
else:
self._daily_modules = DEFAULT_DAILY_MODULES
self._weekly_modules = DEFAULT_WEEKLY_MODULES
self._monthly_modules = DEFAULT_MONTHLY_MODULES
if self._onlyonce:
self._onlyonce = False
self._save_config()
from apscheduler.schedulers.background import BackgroundScheduler
scheduler = BackgroundScheduler(timezone=settings.TZ)
test_func = {
"daily": self.send_daily,
"weekly": self.send_weekly,
"monthly": self.send_monthly,
}.get(self._test_type, self.send_daily)
scheduler.add_job(
func=test_func,
trigger="date",
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
name="立即测试",
)
scheduler.start()
def _save_config(self):
self.update_config({
"enabled": self._enabled,
"notify": self._notify,
"daily_cron": self._daily_cron,
"weekly_cron": self._weekly_cron,
"monthly_cron": self._monthly_cron,
"onlyonce": False,
"test_type": self._test_type,
"daily_modules": self._daily_modules,
"weekly_modules": self._weekly_modules,
"monthly_modules": self._monthly_modules,
})
def get_state(self) -> bool:
return self._enabled
@staticmethod
def get_command() -> List[Dict[str, Any]]:
return [
{"cmd": "/daily_summary", "event": EventType.PluginAction, "desc": "发送每日总结", "category": "工具", "data": {"action": "daily_summary"}},
{"cmd": "/weekly_summary", "event": EventType.PluginAction, "desc": "发送每周总结", "category": "工具", "data": {"action": "weekly_summary"}},
{"cmd": "/monthly_summary", "event": EventType.PluginAction, "desc": "发送每月总结", "category": "工具", "data": {"action": "monthly_summary"}},
]
def get_api(self) -> List[Dict[str, Any]]:
return [{
"path": "/clear_history",
"endpoint": self._api_clear_history,
"methods": ["POST"],
"summary": "清除历史记录",
}]
def _api_clear_history(self) -> dict:
self.save_data("history", [])
logger.info("[DailySummary] 历史记录已清除")
return {"success": True}
def get_service(self) -> List[Dict[str, Any]]:
if not self._enabled:
return []
services = []
if self._daily_cron:
services.append({
"id": "DailySummary_daily",
"name": "每日总结",
"trigger": CronTrigger.from_crontab(self._daily_cron),
"func": self.send_daily,
"kwargs": {},
})
if self._weekly_cron:
services.append({
"id": "DailySummary_weekly",
"name": "每周总结",
"trigger": CronTrigger.from_crontab(self._weekly_cron),
"func": self.send_weekly,
"kwargs": {},
})
if self._monthly_cron:
services.append({
"id": "DailySummary_monthly",
"name": "每月总结",
"trigger": CronTrigger.from_crontab(self._monthly_cron),
"func": self.send_monthly,
"kwargs": {},
})
return services
# ─── 配置表单:三 Tab 布局 ───
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
test_options = [
{"title": "每日总结", "value": "daily"},
{"title": "每周总结", "value": "weekly"},
{"title": "每月总结", "value": "monthly"},
]
return [
{
"component": "VForm",
"content": [
# ── 基本设置VTabs 外面,避免弹出菜单被裁剪) ──
{
'component': 'VRow',
'content': [
{'component': 'VCol', 'props': {'cols': 12, 'md': 3},
'content': [{'component': 'VSwitch', 'props': {'model': 'enabled', 'label': '启用插件'}}]},
{'component': 'VCol', 'props': {'cols': 12, 'md': 3},
'content': [{'component': 'VSwitch', 'props': {'model': 'notify', 'label': '发送通知'}}]},
{'component': 'VCol', 'props': {'cols': 12, 'md': 3},
'content': [{'component': 'VSwitch', 'props': {'model': 'onlyonce', 'label': '立即测试一次'}}]},
{'component': 'VCol', 'props': {'cols': 12, 'md': 3},
'content': [{'component': 'VSelect', 'props': {'model': 'test_type', 'label': '测试类型', 'items': test_options}}]},
],
},
{
'component': 'VRow',
'content': [
{'component': 'VCol', 'props': {'cols': 12, 'md': 4},
'content': [{'component': 'VCronField', 'props': {'model': 'daily_cron', 'label': '每日周期', 'placeholder': '5位cron表达式'}}]},
{'component': 'VCol', 'props': {'cols': 12, 'md': 4},
'content': [{'component': 'VCronField', 'props': {'model': 'weekly_cron', 'label': '每周周期', 'placeholder': '5位cron表达式'}}]},
{'component': 'VCol', 'props': {'cols': 12, 'md': 4},
'content': [{'component': 'VCronField', 'props': {'model': 'monthly_cron', 'label': '每月周期', 'placeholder': '5位cron表达式'}}]},
],
},
# ── 报告内容 ──
{
"component": "VRow",
"props": {"style": "margin-top: 8px;"},
"content": [
{"component": "VCol", "props": {"cols": 12},
"content": [{"component": "VAlert", "props": {"type": "info", "variant": "tonal", "text": "选择各报告中包含的信息模块,模块按选择顺序显示在报告中"}}]},
],
},
{
"component": "VRow",
"content": [
{"component": "VCol", "props": {"cols": 12, "md": 4},
"content": [{"component": "VSelect", "props": {
"model": "daily_modules", "label": "日报模块",
"items": MODULE_OPTIONS, "multiple": True, "chips": True, "closable-chips": True,
}}]},
{"component": "VCol", "props": {"cols": 12, "md": 4},
"content": [{"component": "VSelect", "props": {
"model": "weekly_modules", "label": "周报模块",
"items": MODULE_OPTIONS, "multiple": True, "chips": True, "closable-chips": True,
}}]},
{"component": "VCol", "props": {"cols": 12, "md": 4},
"content": [{"component": "VSelect", "props": {
"model": "monthly_modules", "label": "月报模块",
"items": MODULE_OPTIONS, "multiple": True, "chips": True, "closable-chips": True,
}}]},
],
},
],
}
], {
"enabled": False,
"notify": True,
"daily_cron": "0 23 * * *",
"weekly_cron": "0 23 * * 1",
"monthly_cron": "0 23 1 * *",
"onlyonce": False,
"test_type": "daily",
"daily_modules": DEFAULT_DAILY_MODULES,
"weekly_modules": DEFAULT_WEEKLY_MODULES,
"monthly_modules": DEFAULT_MONTHLY_MODULES,
}
# ─── 历史记录页面 ───
def get_page(self) -> List[dict]:
history = self.get_data("history") or []
# 模块配置摘要
def _module_names(modules):
return "".join(MODULES.get(m, m) for m in (modules or []))
config_cards = [
self._config_card('📊 日报模块', _module_names(self._daily_modules), self._daily_cron),
self._config_card('📈 周报模块', _module_names(self._weekly_modules), self._weekly_cron),
self._config_card('📅 月报模块', _module_names(self._monthly_modules), self._monthly_cron),
]
if not history:
return [
{
'component': 'VRow',
'content': config_cards,
},
{
'component': 'div',
'text': '暂无发送记录',
'props': {'class': 'text-center mt-4'},
},
]
daily_count = sum(1 for r in history if r.get("type") == "daily")
weekly_count = sum(1 for r in history if r.get("type") == "weekly")
monthly_count = sum(1 for r in history if r.get("type") == "monthly")
items = [
{
'time': r.get('time', ''),
'type_label': {'daily': '日报', 'weekly': '周报', 'monthly': '月报'}.get(r.get('type'), ''),
'title': r.get('title', ''),
'preview': (r.get('text', '')[:80] + '...') if len(r.get('text', '')) > 80 else r.get('text', ''),
}
for r in reversed(history)
]
return [
{
'component': 'VRow',
'content': config_cards + [
# 发送统计
self._stat_card('日报', f'{daily_count}'),
self._stat_card('周报', f'{weekly_count}'),
self._stat_card('月报', f'{monthly_count}'),
# 历史记录表格
{
'component': 'VCol',
'props': {'cols': 12, 'class': 'd-none d-sm-block'},
'content': [
{
'component': 'VDataTableVirtual',
'props': {
'class': 'text-sm',
'headers': [
{'title': '时间', 'key': 'time', 'sortable': True},
{'title': '类型', 'key': 'type_label', 'sortable': True},
{'title': '标题', 'key': 'title', 'sortable': False},
{'title': '预览', 'key': 'preview', 'sortable': False},
],
'items': items,
'height': '30rem',
'density': 'compact',
'fixed-header': True,
'hide-no-data': True,
'hover': True,
},
}
],
},
],
}
]
@staticmethod
def _config_card(title: str, modules_text: str, cron: str) -> dict:
return {
'component': 'VCol',
'props': {'cols': 12, 'md': 4},
'content': [{
'component': 'VCard',
'props': {'variant': 'tonal'},
'content': [{
'component': 'VCardText',
'content': [
{'component': 'div', 'props': {'class': 'text-subtitle-2 mb-1'}, 'text': f'{title}{cron}'},
{'component': 'span', 'props': {'class': 'text-caption'}, 'text': modules_text},
],
}],
}],
}
@staticmethod
def _stat_card(title: str, value: str) -> dict:
return {
'component': 'VCol',
'props': {'cols': 4, 'md': 4},
'content': [{
'component': 'VCard',
'props': {'variant': 'tonal'},
'content': [{
'component': 'VCardText',
'props': {'class': 'text-center pa-2'},
'content': [
{'component': 'div', 'props': {'class': 'text-caption'}, 'text': title},
{'component': 'div', 'props': {'class': 'text-h6'}, 'text': value},
],
}],
}],
}
def stop_service(self):
pass
# ─── 命令处理 ───
@eventmanager.register(EventType.PluginAction)
def handle_command(self, event: Event = None):
if not event:
return
action = (event.event_data or {}).get("action", "")
handler = {
"daily_summary": self.send_daily,
"weekly_summary": self.send_weekly,
"monthly_summary": self.send_monthly,
}.get(action)
if handler:
handler()
# ─── 统一报告引擎 ───
def send_daily(self):
header, text = self._build_report("daily")
self._send(report_type="daily", title=header, text=text)
def send_weekly(self):
header, text = self._build_report("weekly")
self._send(report_type="weekly", title=header, text=text)
def send_monthly(self):
header, text = self._build_report("monthly")
self._send(report_type="monthly", title=header, text=text)
def _build_report(self, report_type: str) -> Tuple[str, str]:
logger.info(f"[DailySummary] 开始生成 {report_type} 总结")
tr = self._calc_time_range(report_type)
modules = {
"daily": self._daily_modules,
"weekly": self._weekly_modules,
"monthly": self._monthly_modules,
}.get(report_type, self._daily_modules)
sections = []
for mod in modules:
result = self._run_section(mod, tr)
if result:
sections.append(result)
header = self._make_header(report_type, tr)
text = "\n\n".join(sections) if sections else "无数据"
return header, text
def _calc_time_range(self, report_type: str) -> TimeRange:
tz = pytz.timezone(settings.TZ)
now = datetime.now(tz)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
if report_type == "daily":
start = today_start
prefix = "今日"
elif report_type == "weekly":
start = today_start - timedelta(days=now.weekday())
prefix = "本周"
else:
start = today_start.replace(day=1)
prefix = "本月"
return TimeRange(
start=start,
end=now,
start_str=start.strftime("%Y-%m-%d 00:00:00"),
start_date=start.strftime("%Y-%m-%d"),
end_date=today_start.strftime("%Y-%m-%d"),
report_type=report_type,
prefix=prefix,
)
def _make_header(self, report_type: str, tr: TimeRange) -> str:
now = tr.end
if report_type == "daily":
return f"📊 每日总结 ({now.strftime('%m-%d')} {WEEKDAY_NAMES[now.weekday()]})"
elif report_type == "weekly":
return f"📈 周报 ({tr.start.strftime('%m-%d')} ~ {now.strftime('%m-%d')})"
else:
return f"📅 月报 ({now.strftime('%Y年%m月')})"
def _run_section(self, module: str, tr: TimeRange) -> Optional[str]:
handler = {
"download": self._section_download,
"transfer": self._section_transfer,
"signin": self._section_signin,
"brush": self._section_brush,
"downloader": self._section_downloader,
"site_delta": self._section_site_delta,
"site_current": self._section_site_current,
"subscribe": self._section_subscribe,
"storage": self._section_storage,
}.get(module)
if not handler:
return None
try:
return handler(tr)
except Exception as e:
logger.error(f"[DailySummary] 模块 {module} 执行失败: {e}")
return f"{MODULES.get(module, module)}】数据读取失败"
# ─── 各模块实现 ───
def _section_download(self, tr: TimeRange) -> Optional[str]:
downloads = self._get_downloads(tr.start_str)
if not downloads:
return f"{tr.prefix}下载】无"
# 日报:详细列表;周报/月报:分类汇总
if tr.report_type == "daily":
lines = [f"{tr.prefix}下载 {len(downloads)} 部】"]
for d in downloads:
ep = f" {d.seasons}{d.episodes}" if d.episodes else (f" {d.seasons}" if d.seasons else "")
site = f" - {d.torrent_site}" if d.torrent_site else ""
lines.append(f"{d.title}{ep}{site}")
return "\n".join(lines)
type_count = {}
for d in downloads:
cat = d.media_category or d.type or "其他"
type_count[cat] = type_count.get(cat, 0) + 1
type_summary = " | ".join(f"{k} {v}" for k, v in sorted(type_count.items(), key=lambda x: -x[1]))
return f"{tr.prefix}下载】共 {len(downloads)}\n {type_summary}"
def _section_transfer(self, tr: TimeRange) -> Optional[str]:
transfers = self._get_transfers(tr.start_str)
success = [t for t in transfers if t.status]
if not success:
return f"{tr.prefix}入库】无"
# 日报:详细列表;周报/月报:分类汇总
if tr.report_type == "daily":
lines = [f"{tr.prefix}入库 {len(success)} 部】"]
for t in success:
ep = f" {t.seasons}{t.episodes}" if t.episodes else (f" {t.seasons}" if t.seasons else "")
cat = f"{t.category}" if t.category else ""
lines.append(f"{t.title}{ep}{cat}")
return "\n".join(lines)
cat_count = {}
for t in success:
cat = t.category or t.type or "其他"
cat_count[cat] = cat_count.get(cat, 0) + 1
cat_summary = " | ".join(f"{k} {v}" for k, v in sorted(cat_count.items(), key=lambda x: -x[1]))
return f"{tr.prefix}入库】共 {len(success)}\n {cat_summary}"
def _section_signin(self, tr: TimeRange) -> str:
pdo = PluginDataOper()
plugin_id = "AutoSignIn"
now = tr.end
key = f"{now.month}{now.day}"
data = pdo.get_data(plugin_id, key)
if not data:
return "【签到】今日无签到记录"
signin_records = [r for r in data if "模拟登录" not in r.get("status", "")]
total = len(signin_records)
success = sum(1 for r in signin_records if "成功" in r.get("status", ""))
failed = [r for r in signin_records if "成功" not in r.get("status", "")]
if success == total:
return f"【签到】✅ 全部成功 ({success}/{total})"
fail_sites = ", ".join(r["site"] for r in failed)
return f"【签到】⚠️ {success}/{total} 成功\n 失败: {fail_sites}"
def _section_brush(self, tr: TimeRange) -> str:
pdo = PluginDataOper()
plugin_ids = ["BrushFlow"]
total_uploaded = 0
total_downloaded = 0
total_active = 0
total_deleted = 0
total_count = 0
for pid in plugin_ids:
stat = pdo.get_data(pid, "statistic")
if not stat:
continue
total_uploaded += stat.get("uploaded", 0) + stat.get("active_uploaded", 0)
total_downloaded += stat.get("downloaded", 0)
total_active += stat.get("active", 0)
total_deleted += stat.get("deleted", 0)
total_count += stat.get("count", 0)
return (
f"【刷流】总种: {total_count} | 活跃: {total_active} | 已删: {total_deleted}\n"
f" 总↑ {_human_size(total_uploaded)} | 总↓ {_human_size(total_downloaded)}"
)
def _section_downloader(self, tr: TimeRange) -> Optional[str]:
"""通过 DownloaderHelper 获取所有已配置下载器的概览"""
try:
from app.helper.downloader import DownloaderHelper
except ImportError:
logger.warning("[DailySummary] DownloaderHelper 不可用")
return None
services = DownloaderHelper().get_services()
if not services:
return None
lines = ["【下载器概览】"]
for name, svc in services.items():
if not svc or not svc.instance:
continue
inst = svc.instance
completed = inst.get_completed_torrents() or []
downloading = inst.get_downloading_torrents() or []
total = len(completed) + len(downloading)
ti = inst.transfer_info()
up_speed = ti.get("up_info_speed", 0) if ti else 0
dl_speed = ti.get("dl_info_speed", 0) if ti else 0
lines.append(f" {name}: 种子 {total} | ↑{_human_size(up_speed)}/s | ↓{_human_size(dl_speed)}/s")
return "\n".join(lines) if len(lines) > 1 else None
def _section_site_delta(self, tr: TimeRange) -> Optional[str]:
with ScopedSession() as db:
start_data = SiteUserData.get_by_date(db, tr.start_date)
end_data = SiteUserData.get_by_date(db, tr.end_date)
if not start_data or not end_data:
return None
start_map = {d.domain: d for d in start_data}
end_map = {d.domain: d for d in end_data}
label = {"daily": "站点增量", "weekly": "站点周增量", "monthly": "站点月增量"}.get(tr.report_type, "站点增量")
lines = [f"{label}", " 站点 ↑上传 ↓下载 魔力变化"]
has_data = False
for domain, end in sorted(end_map.items(), key=lambda x: (x[1].upload or 0), reverse=True):
start = start_map.get(domain)
if not start:
continue
up_delta = (end.upload or 0) - (start.upload or 0)
down_delta = (end.download or 0) - (start.download or 0)
bonus_delta = (end.bonus or 0) - (start.bonus or 0)
no_change = up_delta == 0 and down_delta == 0 and bonus_delta == 0
data_anomaly = up_delta < 0 or down_delta < 0
if no_change or data_anomaly:
continue
has_data = True
name = (end.name or domain)[:6].ljust(6)
bonus_str = f"+{bonus_delta:.0f}" if bonus_delta >= 0 else f"{bonus_delta:.0f}"
lines.append(f" {name} {_human_size(up_delta):>10} {_human_size(down_delta):>10} {bonus_str:>8}")
return "\n".join(lines) if has_data else None
def _section_site_current(self, tr: TimeRange) -> Optional[str]:
with ScopedSession() as db:
data = SiteUserData.get_latest(db)
if not data:
return None
lines = ["【站点数据】", " 站点 总↑ 总↓ 分享率 魔力"]
for d in sorted(data, key=lambda x: (x.upload or 0), reverse=True):
if not d.upload and not d.download:
continue
name = (d.name or d.domain)[:6].ljust(6)
ratio = f"{d.ratio:.2f}" if d.ratio else ""
bonus = f"{d.bonus:.0f}" if d.bonus else "0"
lines.append(f" {name} {_human_size(d.upload or 0):>10} {_human_size(d.download or 0):>10} {ratio:>6} {bonus:>8}")
return "\n".join(lines) if len(lines) > 2 else None
def _section_subscribe(self, tr: TimeRange) -> str:
subs = SubscribeOper().list(state="R") or []
if not subs:
return "【订阅进度】无活跃订阅"
lines = [f"【订阅进度】{len(subs)} 部进行中"]
for s in subs:
total = s.total_episode or 0
lack = s.lack_episode or 0
done = total - lack
season = f" S{s.season}" if s.season else ""
progress = f" {done}/{total}" if total > 0 else ""
lines.append(f"{s.name}{season}{progress}")
return "\n".join(lines)
def _section_storage(self, tr: TimeRange) -> Optional[str]:
volumes = self._parse_storage_paths()
if not volumes:
return None
lines = ["【存储空间】"]
has_data = False
for path, label in volumes:
if not os.path.exists(path):
continue
stat = os.statvfs(path)
total = stat.f_blocks * stat.f_frsize
used = (stat.f_blocks - stat.f_bfree) * stat.f_frsize
if total == 0:
continue
has_data = True
pct = used / total * 100
lines.append(f" {label}: 已用 {_human_size(used)} / {_human_size(total)} ({pct:.0f}%)")
return "\n".join(lines) if has_data else None
def _parse_storage_paths(self) -> List[Tuple[str, str]]:
"""自动检测 MP 的 LIBRARY_PATH / DOWNLOAD_PATH"""
paths = []
if hasattr(settings, "LIBRARY_PATH") and settings.LIBRARY_PATH:
paths.append((settings.LIBRARY_PATH, "媒体库"))
if hasattr(settings, "DOWNLOAD_PATH") and settings.DOWNLOAD_PATH:
paths.append((settings.DOWNLOAD_PATH, "下载目录"))
return paths
# ─── 数据查询 ───
def _get_downloads(self, since: str) -> list:
try:
from app.db.models.downloadhistory import DownloadHistory
with ScopedSession() as db:
return db.query(DownloadHistory).filter(
DownloadHistory.date > since
).order_by(DownloadHistory.date.desc()).all()
except Exception as e:
logger.error(f"[DailySummary] 查询下载记录失败: {e}")
return []
def _get_transfers(self, since: str) -> list:
try:
return TransferHistoryOper().list_by_date(since) or []
except Exception as e:
logger.error(f"[DailySummary] 查询入库记录失败: {e}")
return []
# ─── 发送通知 + 保存历史 ───
def _send(self, report_type: str, title: str, text: str):
logger.info(f"[DailySummary] {title}\n{text}")
if self._notify:
self.post_message(mtype=NotificationType.Plugin, title=title, text=text)
# 保存历史记录
tz = pytz.timezone(settings.TZ)
now = datetime.now(tz)
record = {
"time": now.strftime("%Y-%m-%d %H:%M"),
"type": report_type,
"title": title,
"text": text,
}
history = self.get_data("history") or []
history.append(record)
# 保留最近 MAX_HISTORY 条
if len(history) > MAX_HISTORY:
history = history[-MAX_HISTORY:]
self.save_data("history", history)
# ─── 工具函数 ───
def _human_size(size_bytes: float) -> str:
if size_bytes is None or size_bytes == 0:
return "0 B"
negative = size_bytes < 0
size_bytes = abs(size_bytes)
for unit in ["B", "KB", "MB", "GB", "TB", "PB"]:
if size_bytes < 1024:
formatted = f"{size_bytes:.1f} {unit}" if size_bytes != int(size_bytes) else f"{int(size_bytes)} {unit}"
return f"-{formatted}" if negative else formatted
size_bytes /= 1024
return f"{size_bytes:.1f} EB"

View File

@@ -23,7 +23,7 @@ class MoviePilotUpdateNotify(_PluginBase):
# 插件图标
plugin_icon = "Moviepilot_A.png"
# 插件版本
plugin_version = "2.3"
plugin_version = "2.3.1"
# 插件作者
plugin_author = "thsrite"
# 作者主页
@@ -83,7 +83,7 @@ class MoviePilotUpdateNotify(_PluginBase):
# 本地版本
local_version = SystemChain().get_server_local_version()
if local_version and release_version <= local_version:
if local_version and list(map(int, re.findall(r'\d+', release_version))) <= list(map(int, re.findall(r'\d+', local_version))):
logger.info(f"当前后端版本:{local_version} 远程版本:{release_version} 停止运行")
return False
@@ -108,7 +108,7 @@ class MoviePilotUpdateNotify(_PluginBase):
# 本地版本
local_version = SystemChain().get_frontend_version()
if local_version and release_version <= local_version:
if local_version and list(map(int, re.findall(r'\d+', release_version))) <= list(map(int, re.findall(r'\d+', local_version))):
logger.info(f"当前前端版本:{local_version} 远程版本:{release_version} 停止运行")
return False

View File

@@ -1,4 +1,5 @@
import datetime
import re
import pytz
from apscheduler.schedulers.background import BackgroundScheduler
@@ -22,7 +23,7 @@ class MoviePilotUpdateNotify(_PluginBase):
# 插件图标
plugin_icon = "Moviepilot_A.png"
# 插件版本
plugin_version = "1.5"
plugin_version = "1.5.1"
# 插件作者
plugin_author = "thsrite"
# 作者主页
@@ -82,7 +83,7 @@ class MoviePilotUpdateNotify(_PluginBase):
# 本地版本
local_version = SystemChain().get_server_local_version()
if local_version and release_version <= local_version:
if local_version and list(map(int, re.findall(r'\d+', release_version))) <= list(map(int, re.findall(r'\d+', local_version))):
logger.info(f"当前后端版本:{local_version} 远程版本:{release_version} 停止运行")
return False
@@ -107,7 +108,7 @@ class MoviePilotUpdateNotify(_PluginBase):
# 本地版本
local_version = SystemChain().get_frontend_version()
if local_version and release_version <= local_version:
if local_version and list(map(int, re.findall(r'\d+', release_version))) <= list(map(int, re.findall(r'\d+', local_version))):
logger.info(f"当前前端版本:{local_version} 远程版本:{release_version} 停止运行")
return False