自身群监控

自身群监控
This commit is contained in:
InfernoXuaI
2026-05-28 22:21:19 +08:00
parent 5c850ec65d
commit 928f54e923
4 changed files with 346 additions and 46 deletions
+8 -8
View File
@@ -32,14 +32,13 @@ tg-watchbot 是一个轻量级 Python 服务,把 **Telegram 双向客服机器
### 2026-05-28 更新
- 新增「频道媒体下载」功能:使用 Telethon 用户账号登录 Telegram,可浏览已加入的群组/频道,选择性下载媒体文件
- 面板新增「频道媒体」页面:支持搜索群组、添加/暂停/恢复/删除监控、查看下载记录
- 支持断点续传:大文件下载中断后自动续传,不重复下载
- 支持并发下载控制:可设置同时下载数(默认 3),避免带宽打满
- 支持 SOCKS5/HTTP 代理:国内用户可配置代理访问 Telegram
- 支持按日期范围过滤:只下载指定时间段内的消息
- 支持关键词过滤、媒体类型过滤(视频/文档/图片/音频)、文件大小限制
- 下载完成可自动推送 Telegram 通知给管理员。
- 新增「频道媒体转发」:使用 Telethon 用户账号登录 TG,实时转发群组/频道消息到你的 Telegram
- 面板新增「频道媒体」页面:搜索已加入群组,一键添加转发监控
- 支持暂停/恢复监控(保留配置)、删除监控
- 支持关键词过滤:只转发包含特定关键词的消息,留空则转发全部
- 支持媒体类型过滤:可选视频、文档、图片、音频
- 支持 SOCKS5/HTTP 代理,适合国内服务器
- 内置下载到服务器、断点续传、并发下载等功能,后续可通过配置开启
- 需要在设置页填写 `TG_API_ID`、`TG_API_HASH`、`TG_API_SESSION` 后使用。
### 2026-05-22 更新
@@ -125,6 +124,7 @@ tg-watchbot 是一个轻量级 Python 服务,把 **Telegram 双向客服机器
- 支持 SOCKS5/HTTP 代理,适合国内服务器使用。
- 支持按日期范围过滤:只下载指定时间段内的消息。
- 支持关键词过滤、媒体类型选择(视频/文档/图片/音频)、文件大小限制。
- 支持实时转发模式:群消息匹配后直接转发到你的 Telegram(含视频/文档原文),无需下载到服务器。
- 下载完成可自动推送 Telegram 通知给管理员。
- 需要在设置页填写 `TG_API_ID`、`TG_API_HASH`、`TG_API_SESSION`。
+116 -38
View File
@@ -250,6 +250,8 @@ def init_db() -> None:
date_from TEXT DEFAULT '',
date_to TEXT DEFAULT '',
max_concurrent INTEGER DEFAULT 3,
forward_mode INTEGER DEFAULT 0,
forward_to TEXT DEFAULT 'admin',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@@ -277,6 +279,8 @@ def init_db() -> None:
"ALTER TABLE channel_media_monitors ADD COLUMN date_from TEXT DEFAULT ''",
"ALTER TABLE channel_media_monitors ADD COLUMN date_to TEXT DEFAULT ''",
"ALTER TABLE channel_media_monitors ADD COLUMN max_concurrent INTEGER DEFAULT 3",
"ALTER TABLE channel_media_monitors ADD COLUMN forward_mode INTEGER DEFAULT 0",
"ALTER TABLE channel_media_monitors ADD COLUMN forward_to TEXT DEFAULT 'admin'",
]:
try:
conn.execute(sql)
@@ -647,6 +651,8 @@ def channel_media_monitor_create(
date_from: str = "",
date_to: str = "",
max_concurrent: int = 3,
forward_mode: bool = False,
forward_to: str = "admin",
) -> int:
ts = now_iso()
with closing(db()) as conn:
@@ -654,11 +660,12 @@ def channel_media_monitor_create(
"""INSERT INTO channel_media_monitors
(channel_id, channel_title, channel_username, status, media_types, keywords,
max_file_size_mb, download_dir, notify_telegram, proxy, date_from, date_to,
max_concurrent, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
max_concurrent, forward_mode, forward_to, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(channel_id, channel_title, channel_username, "active", media_types, keywords,
max_file_size_mb, download_dir, 1 if notify_telegram else 0,
proxy, date_from, date_to, max_concurrent, ts, ts),
proxy, date_from, date_to, max_concurrent, 1 if forward_mode else 0,
forward_to, ts, ts),
)
conn.commit()
return int(cur.lastrowid)
@@ -667,7 +674,7 @@ def channel_media_monitor_create(
def channel_media_monitor_update(monitor_id: int, **kwargs: Any) -> None:
allowed = {"status", "media_types", "keywords", "max_file_size_mb", "download_dir",
"last_message_id", "notify_telegram", "total_downloaded", "total_size_bytes",
"proxy", "date_from", "date_to", "max_concurrent"}
"proxy", "date_from", "date_to", "max_concurrent", "forward_mode", "forward_to"}
updates = []
values = []
for k, v in kwargs.items():
@@ -871,6 +878,92 @@ async def channel_media_monitor_loop() -> None:
await disconnect_channel_media_client()
async def channel_media_forward_listener() -> None:
"""Real-time listener: forward messages from monitored groups to admin Telegram."""
if TelegramClient is None or StringSession is None:
logger.info("channel media forward listener skipped: telethon not installed")
return
api_id_raw, api_hash, session = user_session_config()
if not api_id_raw or not api_hash or not session:
logger.info("channel media forward listener skipped: user session not configured")
return
try:
api_id = int(api_id_raw)
except Exception:
return
try:
client = TelegramClient(StringSession(session), api_id, api_hash)
def _get_forward_monitors() -> dict[int, dict[str, Any]]:
monitors = channel_media_monitors_all()
result = {}
for m in monitors:
if m.get("status") != "active" or not m.get("forward_mode"):
continue
try:
cid = int(m["channel_id"])
result[cid] = m
except (TypeError, ValueError):
continue
return result
@client.on(events.NewMessage(incoming=True))
async def on_new_message_for_forward(event: Any) -> None:
try:
chat_id = getattr(event, "chat_id", None)
if chat_id is None:
return
fwd_monitors = _get_forward_monitors()
monitor = fwd_monitors.get(int(chat_id))
if not monitor:
return
msg = event.message
text = (msg.text or "") + " " + (msg.caption or "")
# Keyword filter
keywords_str = str(monitor.get("keywords") or "").strip()
if keywords_str:
keywords_list = [k.strip() for k in keywords_str.split(",") if k.strip()]
if keywords_list and not any(k.lower() in text.lower() for k in keywords_list):
return
# Media type filter
media_types_str = str(monitor.get("media_types") or "").strip()
if media_types_str:
allowed = {t.strip().lower() for t in media_types_str.split(",") if t.strip()}
if allowed and not msg.media:
has_text_only = bool(text.strip())
if not has_text_only:
return
# Determine forward target
forward_to = str(monitor.get("forward_to") or "admin").strip()
if forward_to == "admin":
targets = all_admin_chat_ids()
elif forward_to == "saved":
targets = ["me"]
else:
try:
targets = [int(forward_to)]
except (TypeError, ValueError):
targets = all_admin_chat_ids()
# Forward the message
for target in targets:
try:
await msg.forward_to(target)
except Exception:
logger.exception("forward failed target=%s msg_id=%s", target, msg.id)
logger.info("forwarded message from chat=%s msg_id=%s to %s", chat_id, msg.id, targets)
except Exception:
logger.exception("on_new_message_for_forward error")
await client.start()
logger.info("channel media forward listener started")
await client.run_until_disconnected()
except asyncio.CancelledError:
logger.info("channel media forward listener cancelled")
raise
except Exception:
logger.exception("channel media forward listener crashed")
async def telethon_download_from_channel(monitor_id: int, download_history: bool = False) -> int:
monitor = channel_media_monitor_get(monitor_id)
if not monitor:
@@ -3688,8 +3781,6 @@ HostLoc|https://hostloc.com|VPS,补货,优惠"""
badge_html = '<span class="badge" style="background:#eab308">已暂停</span>'
else:
badge_html = '<span class="badge" style="background:#6b7280">已停止</span>'
total = m.get("total_downloaded", 0)
size_mb = (m.get("total_size_bytes", 0) or 0) // 1024 // 1024
username = f"@{m.get('channel_username', '')}" if m.get("channel_username") else ""
pause_btn = ""
if status == "active":
@@ -3697,22 +3788,17 @@ HostLoc|https://hostloc.com|VPS,补货,优惠"""
elif status == "paused":
pause_btn = f"<a class='btn ok' href='/channel-media/{m['id']}/resume'>恢复</a>"
proxy_info = " · 代理: " + html_escape(m.get("proxy", "")) if m.get("proxy") else ""
date_info = ""
if m.get("date_from") or m.get("date_to"):
date_info = " · 日期: " + html_escape(m.get("date_from", "")) + "~" + html_escape(m.get("date_to", ""))
cards_html += f"""<div class=card style='margin:12px 0'>
<div style='display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px'>
<div><h3 style='margin:0 0 4px'>{badge_html} {html_escape(m.get('channel_title',''))} {html_escape(username)}</h3>
<small class=muted>chat_id: {m.get('channel_id','')} · 媒体类型: {html_escape(m.get('media_types',''))} · 并发: {m.get('max_concurrent',3)} · 已下载: {total} 个 · {size_mb} MB{proxy_info}{date_info}</small></div>
<small class=muted>chat_id: {m.get('channel_id','')} · 转发: {'开启' if m.get('forward_mode') else '关闭'}{proxy_info}</small></div>
<div class=actions>
{pause_btn}
<a class='btn ok' href='/channel-media/{m['id']}/download'>下载历史</a>
<a class='btn' href='/channel-media/{m['id']}/check'>立即检查</a>
<a class='btn danger' href='/channel-media/{m['id']}/delete' onclick='return confirm("确定删除该监控?")'>删除</a>
</div></div></div>"""
return f"""<div class=card>
<div class=toolbar><div><h2 style='margin:0 0 6px'>频道媒体监控</h2>
<p class=muted style='margin:0'>使用你的 TG 账号监控频道/群组,自动下载媒体文件。</p></div>
<div class=toolbar><div><h2 style='margin:0 0 6px'>频道媒体转发</h2>
<p class=muted style='margin:0'>使用你的 TG 账号监听群组,消息实时转发到你的 Telegram。</p></div>
<div class=actions><button class='btn primary' onclick='document.getElementById("addModal").style.display="flex"'>添加频道/群组</button></div></div>
{notice}{cards_html}</div>
@@ -3733,25 +3819,17 @@ HostLoc|https://hostloc.com|VPS,补货,优惠"""
<input type=hidden id='selChannelUsername' name='channel_username'>
</div>
<div style='margin-top:14px'>
<label>媒体类型(逗号分隔</label>
<input id='mediaTypes' value='video,document' placeholder='video,document,photo,audio'>
<label>关键词过滤(逗号分隔,留空)</label>
<input id='mediaKeywords' placeholder='留空则下载所有'>
<label>最大文件大小 (MB)</label>
<input id='maxFileSize' type=number value=2000 min=1>
<label>下载目录(留空使用默认)</label>
<input id='downloadDir' placeholder='留空则使用默认目录'>
<label>关键词过滤(逗号分隔,留空则转发所有消息</label>
<input id='mediaKeywords' placeholder='留空则转发所有消息'>
<label>媒体类型过滤(逗号分隔,留空不限</label>
<input id='mediaTypes' value='' placeholder='video,document,photo,audio(留空=所有类型)'>
<label>代理(留空不用,支持 socks5://host:port 或 http://host:port</label>
<input id='mediaProxy' placeholder='socks5://127.0.0.1:1080'>
<div class=grid><div><label>开始日期(留空不限)</label>
<input id='dateFrom' type=date></div>
<div><label>结束日期(留空不限)</label>
<input id='dateTo' type=date></div></div>
<label>最大并发下载数</label>
<input id='maxConcurrent' type=number value=3 min=1 max=10>
<div class=check-row style='margin-top:8px'>
<label><input type=checkbox id='notifyTg' checked> 下载通知推送到 Telegram</label>
<label><input type=checkbox id='forwardMode' checked> 实时转发到 Telegram</label>
</div>
<div><label>转发目标</label>
<select id='forwardTo'><option value='admin'>管理员</option><option value='saved'>我的收藏(Saved Messages)</option></select></div>
</div>
<div class=form-actions>
<button class='btn primary' onclick='addMonitor()'>添加监控</button>
@@ -3803,13 +3881,9 @@ async function addMonitor() {{
channel_username: document.getElementById('selChannelUsername').value,
media_types: document.getElementById('mediaTypes').value,
keywords: document.getElementById('mediaKeywords').value,
max_file_size_mb: document.getElementById('maxFileSize').value,
download_dir: document.getElementById('downloadDir').value,
proxy: document.getElementById('mediaProxy').value,
date_from: document.getElementById('dateFrom').value,
date_to: document.getElementById('dateTo').value,
max_concurrent: document.getElementById('maxConcurrent').value,
notify_telegram: document.getElementById('notifyTg').checked ? 'on' : '',
forward_mode: document.getElementById('forwardMode').checked ? 'on' : '',
forward_to: document.getElementById('forwardTo').value,
}});
try {{
const resp = await fetch('/api/monitors/create', {{ method: 'POST', headers: {{'Content-Type':'application/x-www-form-urlencoded'}}, body: body.toString() }});
@@ -3850,6 +3924,8 @@ document.getElementById('addModal').addEventListener('click', function(e) {{ if
date_from: str = Form(""),
date_to: str = Form(""),
max_concurrent: int = Form(3),
forward_mode: str | None = Form(None),
forward_to: str = Form("admin"),
) -> RedirectResponse:
try:
cid = int(channel_id.strip())
@@ -3868,6 +3944,8 @@ document.getElementById('addModal').addEventListener('click', function(e) {{ if
date_from.strip(),
date_to.strip(),
max(1, min(10, max_concurrent)),
bool(forward_mode),
forward_to.strip() or "admin",
)
return RedirectResponse("/channel-media", status_code=303)
@@ -3960,7 +4038,6 @@ async def main_async(run_once: bool = False, panel_only: bool = False) -> None:
while True:
await asyncio.sleep(3600)
if run_once:
# If .env is filled, send notifications during manual test; otherwise just log.
try:
token, admin_chat_id = validate_env()
admin_chat_ids = parse_admin_chat_ids(os.getenv("ADMIN_CHAT_ID", ""))
@@ -3999,9 +4076,10 @@ async def main_async(run_once: bool = False, panel_only: bool = False) -> None:
else:
user_session_listener_task = asyncio.create_task(run_user_session_group_listener())
if TelegramClient is not None and user_session_ready():
asyncio.create_task(channel_media_monitor_loop())
asyncio.create_task(channel_media_forward_listener())
logger.info("channel media forward listener started")
else:
logger.info("channel media monitor loop skipped: telethon not installed or user session not configured")
logger.info("channel media forward listener skipped: telethon not installed or user session not configured")
await admin_send(f"tg-watchbot 已启动\n时间:{now_iso()}")
logger.info("bot polling start")
await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())
+119
View File
@@ -0,0 +1,119 @@
total = monitor.get("total_downloaded", 0)
size_mb = (monitor.get("total_size_bytes", 0) or 0) // 1024 // 1024
body = f"""<div class=card><h2>下载记录 - {html_escape(monitor.get('channel_title',''))}</h2>
<p class=muted>累计:{total} 个文件,{size_mb} MB</p>
<div class=actions style='margin-bottom:16px'>
<a class='btn ok' href='/channel-media/{monitor_id}/check'>立即下载新内容</a>
<a class='btn' href='/channel-media'>返回列表</a></div>
<table><tr><th>ID</th><th>类型</th><th>文件名/说明</th><th>大小</th><th>时间</th></tr>{rows}</table></div>"""
return layout("下载记录", body)
return app
async def start_panel_server() -> uvicorn.Server | None:
if not panel_enabled():
logger.info("web panel disabled")
return None
host = os.getenv("WEB_PANEL_HOST", "127.0.0.1")
port = int(os.getenv("WEB_PANEL_PORT", "8765"))
server = uvicorn.Server(uvicorn.Config(create_panel_app(), host=host, port=port, log_level="info"))
asyncio.create_task(server.serve())
logger.info("web panel listening on http://%s:%s", host, port)
return server
def validate_env() -> tuple[str, int]:
load_dotenv(ENV_PATH)
token = os.getenv("TELEGRAM_BOT_TOKEN", "").strip()
admin = os.getenv("ADMIN_CHAT_ID", "").strip()
if not token:
raise RuntimeError(f"TELEGRAM_BOT_TOKEN is missing in {ENV_PATH}")
if not admin:
raise RuntimeError(f"ADMIN_CHAT_ID is missing in {ENV_PATH}")
ids = parse_admin_chat_ids(admin)
if not ids:
raise RuntimeError(f"ADMIN_CHAT_ID is invalid in {ENV_PATH}")
return token, ids[0]
def bot_env_configured() -> bool:
load_dotenv(ENV_PATH, override=True)
return bool(os.getenv("TELEGRAM_BOT_TOKEN", "").strip() and os.getenv("ADMIN_CHAT_ID", "").strip())
async def main_async(run_once: bool = False, panel_only: bool = False) -> None:
global bot, admin_chat_id, admin_chat_ids, config, scheduler_ref, user_session_listener_task
load_dotenv(ENV_PATH, override=True)
config = load_config()
setup_logging(os.getenv("LOG_LEVEL", "INFO"))
init_db()
if panel_only:
await start_panel_server()
logger.info("panel-only mode start")
while True:
await asyncio.sleep(3600)
if run_once:
try:
token, admin_chat_id = validate_env()
admin_chat_ids = parse_admin_chat_ids(os.getenv("ADMIN_CHAT_ID", ""))
bot = Bot(token=token, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
except Exception as e:
logger.warning("run-once without Telegram notification: %s", e)
await run_all_monitors_once()
if bot:
await bot.session.close()
return
await start_panel_server()
if not bot_env_configured():
logger.warning(
"Telegram bot is not configured. Web panel is available, but Telegram polling, monitor notifications, and admin/user messaging will not work until TELEGRAM_BOT_TOKEN and ADMIN_CHAT_ID are saved, then the service is restarted."
)
while True:
await asyncio.sleep(3600)
token, admin_chat_id = validate_env()
admin_chat_ids = parse_admin_chat_ids(os.getenv("ADMIN_CHAT_ID", ""))
bot = Bot(token=token, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
dp = Dispatcher()
dp.include_router(router)
scheduler = AsyncIOScheduler(timezone="Asia/Shanghai")
scheduler_ref = scheduler
schedule_monitors(scheduler)
scheduler.start()
asyncio.create_task(flush_pending_loop())
asyncio.create_task(cleanup_monitor_loop())
if group_monitors_need_user_session():
if TelegramClient is None:
logger.warning("group monitor with listen_source=user_session detected, but telethon is not installed")
elif not user_session_ready():
logger.warning(
"group monitor with listen_source=user_session detected, but TG_API_ID/TG_API_HASH/TG_API_SESSION is not complete"
)
else:
user_session_listener_task = asyncio.create_task(run_user_session_group_listener())
if TelegramClient is not None and user_session_ready():
asyncio.create_task(channel_media_monitor_loop())
asyncio.create_task(channel_media_forward_listener())
logger.info("channel media download + forward listeners started")
else:
logger.info("channel media listeners skipped: telethon not installed or user session not configured")
await admin_send(f"tg-watchbot 已启动\n时间:{now_iso()}")
logger.info("bot polling start")
await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--run-once", action="store_true", help="run all monitors once and exit; does not need Telegram token unless notification is sent")
parser.add_argument("--panel-only", action="store_true", help="start only the web admin panel, useful before Telegram token is configured")
args = parser.parse_args()
try:
asyncio.run(main_async(run_once=args.run_once, panel_only=args.panel_only))
except KeyboardInterrupt:
pass
except Exception:
logger.exception("fatal error")
raise
if __name__ == "__main__":
main()
+103
View File
@@ -0,0 +1,103 @@
document.getElementById('addModal').addEventListener('click', function(e) { if (e.target === this) this.style.display='none'; });
</script>"""
@app.get("/channel-media", response_class=HTMLResponse)
async def channel_media_page(_: str = Depends(panel_auth)) -> str:
return layout("频道媒体", channel_media_page_html())
@app.get("/api/groups")
async def api_groups(_: str = Depends(panel_auth), q: str = "", limit: int = 500) -> dict[str, Any]:
if not user_session_ready():
return {"groups": [], "error": "TG user session not configured"}
if q.strip():
groups = await telethon_search_dialogs(q.strip(), limit=min(limit, 200))
else:
groups = await telethon_list_dialogs(limit=min(limit, 500))
groups = [g for g in groups if g.get("type") in ("group", "channel")]
return {"groups": groups}
@app.post("/api/monitors/create")
async def api_monitor_create(
_: str = Depends(panel_auth),
channel_id: str = Form(...),
channel_title: str = Form(""),
channel_username: str = Form(""),
media_types: str = Form("video,document"),
keywords: str = Form(""),
max_file_size_mb: int = Form(2000),
download_dir: str = Form(""),
notify_telegram: str | None = Form(None),
proxy: str = Form(""),
date_from: str = Form(""),
date_to: str = Form(""),
max_concurrent: int = Form(3),
forward_mode: str | None = Form(None),
forward_to: str = Form("admin"),
) -> RedirectResponse:
try:
cid = int(channel_id.strip())
except (ValueError, TypeError):
raise HTTPException(400, "invalid channel_id")
channel_media_monitor_create(
cid,
channel_title.strip() or str(cid),
channel_username.strip(),
media_types.strip() or "video,document",
keywords.strip(),
max(1, max_file_size_mb),
download_dir.strip(),
bool(notify_telegram),
proxy.strip(),
date_from.strip(),
date_to.strip(),
max(1, min(10, max_concurrent)),
bool(forward_mode),
forward_to.strip() or "admin",
)
return RedirectResponse("/channel-media", status_code=303)
@app.get("/channel-media/{monitor_id}/pause")
async def channel_media_pause(monitor_id: int, _: str = Depends(panel_auth)) -> RedirectResponse:
channel_media_monitor_update(monitor_id, status="paused")
return RedirectResponse("/channel-media", status_code=303)
@app.get("/channel-media/{monitor_id}/resume")
async def channel_media_resume(monitor_id: int, _: str = Depends(panel_auth)) -> RedirectResponse:
channel_media_monitor_update(monitor_id, status="active")
return RedirectResponse("/channel-media", status_code=303)
@app.get("/channel-media/{monitor_id}/delete")
async def channel_media_delete_route(monitor_id: int, _: str = Depends(panel_auth)) -> RedirectResponse:
channel_media_monitor_delete(monitor_id)
return RedirectResponse("/channel-media", status_code=303)
@app.get("/channel-media/{monitor_id}/check", response_class=HTMLResponse)
async def channel_media_check(monitor_id: int, _: str = Depends(panel_auth)) -> str:
count = await telethon_download_from_channel(monitor_id)
monitor = channel_media_monitor_get(monitor_id)
name = monitor.get("channel_title", "") if monitor else ""
return layout("检查完成", f"<div class=msg>已检查频道 {html_escape(name)},新增 {count} 个文件。</div><p><a class=btn href='/channel-media'>返回</a></p>")
@app.get("/channel-media/{monitor_id}/download", response_class=HTMLResponse)
async def channel_media_download_history(monitor_id: int, _: str = Depends(panel_auth)) -> str:
monitor = channel_media_monitor_get(monitor_id)
if not monitor:
raise HTTPException(404)
downloads = channel_media_downloads_list(monitor_id, limit=200)
rows = ""
for d in downloads:
size_mb = f"{(d.get('file_size', 0) or 0) / 1024 / 1024:.1f} MB"
rows += f"<tr><td>{d.get('id')}</td><td>{html_escape(d.get('media_type',''))}</td>"
rows += f"<td>{html_escape(d.get('file_name',''))}<br><small class=muted>{html_escape(d.get('caption','')[:100])}</small></td>"
rows += f"<td>{size_mb}</td><td><small>{html_escape(d.get('created_at',''))}</small></td></tr>"
total = monitor.get("total_downloaded", 0)
size_mb = (monitor.get("total_size_bytes", 0) or 0) // 1024 // 1024
body = f"""<div class=card><h2>下载记录 - {html_escape(monitor.get('channel_title',''))}</h2>
<p class=muted>累计:{total} 个文件,{size_mb} MB</p>
<div class=actions style='margin-bottom:16px'>
<a class='btn ok' href='/channel-media/{monitor_id}/check'>立即下载新内容</a>
<a class='btn' href='/channel-media'>返回列表</a></div>
<table><tr><th>ID</th><th>类型</th><th>文件名/说明</th><th>大小</th><th>时间</th></tr>{rows}</table></div>"""
return layout("下载记录", body)
return app