feat(video-analysis): 群策略 v3 / 群文件投递通道 / Web 管理页
- policy.py:per-group 正交策略(自动解析 / 自动策略 / 禁用策略 / 存储 A·B·C /
公网 / 链接 / 群文件 + 平台限定),list.json v1/v2 → v3 自动迁移,
写入统一走 PolicyStore(加锁 + .tmp 原子替换 + 字段归一)
- 群文件并行通道 group_file.py:打包 zip(可选 pyzipper AES-256)后优先走 S3 预签名、
本地直传兜底;设了密码但 pyzipper 不可用就放弃上传,不退化成明文
- list_proc.py 收敛到「视频策略」统一入口,权限判定改走 policy
- Web 管理页 /hub/video_analysis(群策略 + 链接解析面板)与 services/web_jobs.py
(只复用纯函数层,Web 上下文不发消息;内存任务表 + 并发闸门 + 超时)
- 媒体命名统一到 utils.py({作者}_{作者id}/{作品名}[_短码]),cleanup 回收空目录
- 测试:policy / 命名 / 群文件 / web_jobs 四组
顺带 pyproject 的 pytest 加 testpaths=tests(避免收进 debug/ 下的调试脚本)。
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,10 @@
|
||||
(视频段不能与其他段混合,一条消息也放不下多段视频/图集体验差)
|
||||
→ 统一走合并转发,一个图/视频一个节点。
|
||||
|
||||
群文件(并行通道): `policy.upload_group_file` 开着时,消息链跑完后额外把文件
|
||||
传到群文件(默认打包成一个 zip,可配解压密码,见 config.group_file_settings),
|
||||
失败只记日志、不影响发送结果(打包/上传实现在 services/storage/group_file.py)。
|
||||
|
||||
temp 下的文件发送成功后也不清理(用户手动处理 data/temp)。
|
||||
"""
|
||||
|
||||
@@ -20,7 +24,11 @@ from nonebot import get_bot, get_driver, logger
|
||||
from nonebot.adapters import Event
|
||||
from nonebot_plugin_alconna import UniMessage
|
||||
|
||||
from ..config import group_file_settings
|
||||
from ..policy import Policy
|
||||
from ..services.storage.group_file import upload_group_files
|
||||
from ..services.storage.s3 import upload_with_plan
|
||||
from ..utils import media_rel_dir_of
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -30,7 +38,9 @@ class PendingMedia:
|
||||
files: list[Path]
|
||||
image_post: bool = False
|
||||
is_private: bool = False
|
||||
plan: str | None = None
|
||||
policy: Policy | None = None
|
||||
#: 平台规范标签(见 policy.match_platform),群文件限定平台用
|
||||
platform: str | None = None
|
||||
title: str = ""
|
||||
|
||||
|
||||
@@ -43,8 +53,12 @@ def _as_paths(file_paths: Path | list[Path]) -> list[Path]:
|
||||
# ─────────────────────── 合并转发(多媒体专用) ───────────────────────
|
||||
|
||||
|
||||
#: 按视频段发送的扩展名(其余按图片发;直链下载可能落 webm/mov 等)
|
||||
_VIDEO_SUFFIXES = {".mp4", ".webm", ".mov", ".flv", ".mkv", ".ts"}
|
||||
|
||||
|
||||
def _is_video(fp: Path) -> bool:
|
||||
return fp.suffix.lower() == ".mp4"
|
||||
return fp.suffix.lower() in _VIDEO_SUFFIXES
|
||||
|
||||
|
||||
def _needs_forward(files: list[Path]) -> bool:
|
||||
@@ -117,13 +131,7 @@ async def _build_s3_forward_items(
|
||||
items: list[tuple[Path, str | None]] = []
|
||||
public_url = None
|
||||
for fp in media.files:
|
||||
local_url, pub = upload_with_plan(
|
||||
fp,
|
||||
plan=media.plan,
|
||||
is_private=media.is_private,
|
||||
title=media.title,
|
||||
image_post=media.image_post,
|
||||
)
|
||||
local_url, pub = upload_with_plan(fp, policy=media.policy)
|
||||
if not local_url:
|
||||
raise RuntimeError(f"上传本地 S3 失败: {fp}")
|
||||
if pub:
|
||||
@@ -136,10 +144,10 @@ async def _build_s3_forward_items(
|
||||
|
||||
|
||||
def _build_local_msg(files: list[Path], image_post: bool) -> UniMessage:
|
||||
"""本地文件版消息(mp4 → 视频,其余 → 图片)"""
|
||||
"""本地文件版消息(视频扩展名 → 视频段,其余 → 图片段)"""
|
||||
msg = UniMessage()
|
||||
for fp in files:
|
||||
if fp.suffix.lower() == ".mp4":
|
||||
if _is_video(fp):
|
||||
msg.video(path=fp)
|
||||
else:
|
||||
msg.image(path=fp)
|
||||
@@ -153,28 +161,22 @@ def _build_s3_msg(
|
||||
msg = UniMessage()
|
||||
public_url = None
|
||||
for fp in media.files:
|
||||
local_url, pub = upload_with_plan(
|
||||
fp,
|
||||
plan=media.plan,
|
||||
is_private=media.is_private,
|
||||
title=media.title,
|
||||
image_post=media.image_post,
|
||||
)
|
||||
local_url, pub = upload_with_plan(fp, policy=media.policy)
|
||||
if not local_url:
|
||||
raise RuntimeError(f"上传本地 S3 失败: {fp}")
|
||||
if pub:
|
||||
public_url = pub
|
||||
if fp.suffix.lower() == ".mp4":
|
||||
if _is_video(fp):
|
||||
msg.video(url=local_url)
|
||||
else:
|
||||
msg.image(url=local_url)
|
||||
return msg, public_url
|
||||
|
||||
|
||||
async def send_pending_media(
|
||||
async def _send_media_core(
|
||||
media: PendingMedia, event: Event | None = None
|
||||
) -> tuple[bool, str | None]:
|
||||
"""多级发送,返回 (是否成功, public_url)
|
||||
"""多级发送主体,返回 (是否成功, public_url)
|
||||
|
||||
public_url 仅在走 S3 链接发送成功时返回(调用方决定是否发文字)。
|
||||
temp 文件发送成功后保留(用户手动清理 data/temp)。
|
||||
@@ -236,3 +238,47 @@ async def send_pending_media(
|
||||
except Exception as e:
|
||||
logger.exception(f"回退发送失败: {e}")
|
||||
return False, None
|
||||
|
||||
|
||||
async def _upload_group_files(media: PendingMedia, event: Event | None) -> None:
|
||||
"""群文件并行通道:消息链跑完后按策略额外传一份(失败只记日志)。
|
||||
|
||||
是否打包成 zip / 是否加密由全局配置决定(config.group_file_settings);
|
||||
平台清单(policy.group_file_platforms)非空时只传清单里的平台,其余平台
|
||||
照常走消息、不传群文件。
|
||||
"""
|
||||
policy = media.policy
|
||||
if policy is None or event is None:
|
||||
return
|
||||
if not policy.allows_group_file(media.platform):
|
||||
if policy.upload_group_file:
|
||||
logger.info(
|
||||
f"群文件限定平台 {policy.group_file_platforms},"
|
||||
f"本次为 {media.platform or '未知平台'},跳过群文件上传"
|
||||
)
|
||||
return
|
||||
group_id = getattr(event, "group_id", None)
|
||||
if group_id is None:
|
||||
return
|
||||
zip_files, password = group_file_settings()
|
||||
await upload_group_files(
|
||||
media.files,
|
||||
int(group_id),
|
||||
title=media.title,
|
||||
zip_files=zip_files,
|
||||
password=password,
|
||||
policy=policy,
|
||||
rel_dir=media_rel_dir_of(media.files[0]),
|
||||
)
|
||||
|
||||
|
||||
async def send_pending_media(
|
||||
media: PendingMedia, event: Event | None = None
|
||||
) -> tuple[bool, str | None]:
|
||||
"""多级发送 + 群文件并行通道,返回 (是否成功, public_url)。"""
|
||||
if not media.files:
|
||||
return False, None
|
||||
|
||||
ok, public_url = await _send_media_core(media, event)
|
||||
await _upload_group_files(media, event)
|
||||
return ok, public_url
|
||||
|
||||
Reference in New Issue
Block a user