feat(plugin): add Music Pusher plugin with audio upload, scheduling a…#952
feat(plugin): add Music Pusher plugin with audio upload, scheduling a…#952StarrySerendipity wants to merge 1 commit into
Conversation
…nd stage visualization
Walkthrough添加了完整的音乐推送和定时队列插件模块,支持音频验证解码、歌词处理、时长检测、JSON状态持久化、异步调度器和播放控制功能喵。 Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant Frontend as Web Frontend
participant Backend as MusicPusherPlugin
participant Storage as JSON Storage
participant Scheduler as Async Scheduler
participant Player as Playback Handler
User->>Frontend: 上传音乐文件或输入URL
Frontend->>Backend: callEntry(upload_music_file/push_music_url)
Backend->>Backend: 验证+解码base64/URL<br/>检测音频时长
Backend->>Storage: 保存音乐元数据
Storage-->>Backend: 持久化完成
Backend-->>Frontend: 返回音乐项ID
Frontend->>User: 显示上传成功喵
User->>Frontend: 创建定时任务
Frontend->>Backend: create_schedule_task(trigger_at)
Backend->>Storage: 保存任务配置
Storage-->>Backend: 任务已保存
Backend->>Scheduler: 注册定时任务
Backend-->>Frontend: 返回task_id
Scheduler->>Scheduler: 每tick检查任务是否到期
Scheduler->>Backend: 任务触发时间到达
Backend->>Backend: 遍历队列执行URL推送
Backend->>Player: 推送音乐轨道
Player-->>Backend: 播放中/已完成
Backend->>Storage: 更新任务进度/状态
Backend-->>Frontend: 实时推送状态更新喵
sequenceDiagram
actor User
participant Frontend as Web UI
participant Backend as Plugin Backend
participant Cache as Memory Cache
participant JSON as File Storage
Frontend->>Backend: list_music_items()
Backend->>JSON: 读取音乐元数据文件
JSON-->>Backend: 返回所有项
Backend->>Cache: 重建内存音乐列表
Backend-->>Frontend: 返回音乐项列表
User->>Frontend: 点击"立即推送"
Frontend->>Backend: 触发即时播放
Backend->>Backend: 构建播放快照
Backend->>Frontend: 返回推送确认
Frontend->>Frontend: 同步隐藏audio元素<br/>更新可视化效果喵
User->>Frontend: 管理播放控制(暂停/跳过)
Frontend->>Backend: skip_current_track/stop_schedule_task
Backend->>Cache: 更新任务状态
Cache-->>Backend: 状态已更新
Backend-->>Frontend: 返回新状态
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (2)
plugin/plugins/music_pusher/__init__.py (1)
1230-1231:_wake_scheduler是空的喵,而且每个任务都开一个轮询协程是浪费资源的说~两件相关的事情一起说喵:
_wake_scheduler函数体只有return,是个不做任何事的占位(被 line 1860/1950/2023/2410 调用了 4 次)。要么干掉,要么就用self._scheduler_stop之外配个asyncio.Event在调度循环里wait_for(timeout=...)真正实现唤醒喵~
_schedule_task_timer._runner给每个 pending 任务都开一个协程,里面min(delay, 1.0)死循环,这意味着主人创建 100 个一周后才触发的任务,就有 100 个协程每秒醒一次轮询同样的状态。而_scheduler_loop已经每_SCHEDULER_TICK_SECONDS=1.0秒扫一遍_execute_due_once了,功能完全重复的笨蛋喵~建议保留
_scheduler_loop作为唯一调度入口,删掉_schedule_task_timer和它在创建/更新任务后的调用;触发延迟会从「立刻」变成「最多 1 秒」,对推歌任务来说完全够用喵~Also applies to: 1253-1280
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@plugin/plugins/music_pusher/__init__.py` around lines 1230 - 1231, _wake_scheduler is a no-op placeholder and per-task timers (_schedule_task_timer and its _runner) spawn a coroutine per pending task that polls every min(delay,1.0), duplicating work already done by _scheduler_loop which runs every _SCHEDULER_TICK_SECONDS and calls _execute_due_once; remove the per-task timer machinery and its uses so the scheduler loop is the single dispatcher, or if you want to keep wake signals implement _wake_scheduler to set an asyncio.Event (e.g., self._wake_event) and have _scheduler_loop do await self._wake_event.wait_for(timeout=_SCHEDULER_TICK_SECONDS) (clearing the event after wake) while keeping self._scheduler_stop for shutdown; update task creation/update sites to call the new _wake_scheduler (or trigger the event) instead of spawning _schedule_task_timer coroutines.plugin/plugins/music_pusher/static/index.html (1)
2705-2707: 900ms 一次的refreshAll太勤快了喵~每次
refreshAll会发 2 个callEntry(list_music_items+list_schedule_tasks),每个 callEntry 会创建一个 run 记录、轮询/runs/{id}直到完成、再拉/runs/{id}/export。900ms 一轮意味着每秒都在堆 run 记录,对后端的 run 存储和事件循环都不友好喵~建议把轮询改成 2~3 秒一次的常态刷新,仅在主动操作(上传/创建/切歌等)后立即触发一次
refreshAll,这样既流畅又不会刷爆 run 表喵~🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@plugin/plugins/music_pusher/static/index.html` around lines 2705 - 2707, The polling interval for refreshAll is too frequent (900ms) and causes excessive run records; change the setInterval(refreshAll, 900) to a 2000–3000 ms interval (e.g., 2000) to reduce load, and keep the UX responsive by invoking refreshAll immediately after user-triggered operations that mutate state (e.g., after functions that perform uploads, createSchedule, change/skip track or any handlers that call the callEntry actions list_music_items or list_schedule_tasks). Locate usages of refreshAll, setInterval and callers that trigger callEntry (references: refreshAll, setInterval(refreshAll, ...), callEntry for list_music_items and list_schedule_tasks) and add immediate calls to refreshAll in those mutation handlers while leaving the longer periodic setInterval for background polling.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@plugin/plugins/music_pusher/__init__.py`:
- Around line 440-447: The _detect_audio_duration_from_url function currently
passes unvalidated user URLs to av.open, enabling SSRF; before calling av.open
(and before adding domains in _push_music_link via music_allowlist_add) validate
the URL: ensure scheme is http or https, parse hostname and resolve DNS to all
IPs and reject any private/loopback/link-local/IPv6-ULA addresses (127.0.0.0/8,
10/8, 172.16/12, 192.168/16, 169.254/16, ::1, fc00::/7, etc.), and only if the
host passes allowlist checks call av.open; also pass FFmpeg options including
protocol_whitelist=http,https,tcp,tls to av.open and only call
music_allowlist_add from _push_music_link after the URL/IP checks succeed; keep
using _extract_duration_from_av_container for duration extraction.
- Around line 1438-1444: 在 upload_music_file 的协程里把同步阻塞 I/O 调用改为在线程池执行:不要直接调用
self._save_upload_file(...) 和 self._detect_audio_duration_seconds(...),而是用
asyncio.to_thread/loop.run_in_executor 调用它们(即 await
asyncio.to_thread(self._save_upload_file, binary, ext) 和 await
asyncio.to_thread(self._detect_audio_duration_seconds,
binary)),以避免将大型文件写入(full_path.write_bytes)和 PyAV
解码(av.open(io.BytesIO(...)))阻塞事件循环;保留后续的 _normalize_duration/DEFAULT
常量逻辑不变并确保返回值按原来处理。
In `@plugin/plugins/music_pusher/static/index.html`:
- Line 1015: MAX_AUDIO_SIZE is set to 3TB but fileToDataUrl uses
FileReader.readAsDataURL which loads the entire file into memory (plus ~33%
base64 bloat) and will OOM long before that; change the MAX_AUDIO_SIZE constant
from 3TB to a realistic browser-safe limit (e.g., 200MB–500MB), add an early
size check where fileToDataUrl is invoked to reject files exceeding the new
MAX_AUDIO_SIZE and show a clear user-facing error before calling readAsDataURL,
and document/prepare for a future migration of large-file paths (e.g., uploads
to /runs) to multipart/form-data or chunked uploads instead of base64-in-JSON.
---
Nitpick comments:
In `@plugin/plugins/music_pusher/__init__.py`:
- Around line 1230-1231: _wake_scheduler is a no-op placeholder and per-task
timers (_schedule_task_timer and its _runner) spawn a coroutine per pending task
that polls every min(delay,1.0), duplicating work already done by
_scheduler_loop which runs every _SCHEDULER_TICK_SECONDS and calls
_execute_due_once; remove the per-task timer machinery and its uses so the
scheduler loop is the single dispatcher, or if you want to keep wake signals
implement _wake_scheduler to set an asyncio.Event (e.g., self._wake_event) and
have _scheduler_loop do await
self._wake_event.wait_for(timeout=_SCHEDULER_TICK_SECONDS) (clearing the event
after wake) while keeping self._scheduler_stop for shutdown; update task
creation/update sites to call the new _wake_scheduler (or trigger the event)
instead of spawning _schedule_task_timer coroutines.
In `@plugin/plugins/music_pusher/static/index.html`:
- Around line 2705-2707: The polling interval for refreshAll is too frequent
(900ms) and causes excessive run records; change the setInterval(refreshAll,
900) to a 2000–3000 ms interval (e.g., 2000) to reduce load, and keep the UX
responsive by invoking refreshAll immediately after user-triggered operations
that mutate state (e.g., after functions that perform uploads, createSchedule,
change/skip track or any handlers that call the callEntry actions
list_music_items or list_schedule_tasks). Locate usages of refreshAll,
setInterval and callers that trigger callEntry (references: refreshAll,
setInterval(refreshAll, ...), callEntry for list_music_items and
list_schedule_tasks) and add immediate calls to refreshAll in those mutation
handlers while leaving the longer periodic setInterval for background polling.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 6ccd794d-d7d2-4715-ba66-f030b357e3ba
📒 Files selected for processing (3)
plugin/plugins/music_pusher/__init__.pyplugin/plugins/music_pusher/plugin.tomlplugin/plugins/music_pusher/static/index.html
| def _detect_audio_duration_from_url(self, url: str) -> int | None: | ||
| if av is None: | ||
| return None | ||
| try: | ||
| with av.open(url, mode="r", options={"rw_timeout": "5000000"}) as container: | ||
| return _extract_duration_from_av_container(container) | ||
| except Exception: | ||
| return None |
There was a problem hiding this comment.
SSRF 警报喵!PyAV/FFmpeg 会跟着用户给的 URL 到处乱跑!
_detect_audio_duration_from_url 直接把外部传进来的 url(例如 push_music_url 的 url 参数)丢给 av.open(url, ...)。FFmpeg 后端支持 http://、https://、file://、rtsp://、udp:// 等一大堆协议,主人这样写笨蛋恶意用户就能:
- 访问云元数据服务(
http://169.254.169.254/...)窃取凭证 - 探测内网服务(
http://10.x.x.x/...、http://localhost:某端口/...) - 通过
file:///etc/passwd之类读本地文件(取决于 FFmpeg 编译选项)
而且 line 1118-1131 的 _push_music_link 还会把 urlparse(url).hostname 自动加进 music_allowlist_add,等于把用户给的任意域名信任放进白名单,雪上加霜的喵!
强烈建议在解析前对 URL 做严格校验:
- 仅允许
http/httpsscheme - 解析 hostname → 解析 DNS → 拒绝私网/回环/链路本地 IP(127/8、10/8、172.16/12、192.168/16、169.254/16、::1、fc00::/7 等)
- 给
av.open加protocol_whitelist=http,https,tcp,tls(FFmpeg option)作为兜底 music_allowlist_add仅在通过白名单校验的前提下放行
What protocols does PyAV/FFmpeg's av.open support by default and how to restrict them with protocol_whitelist?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@plugin/plugins/music_pusher/__init__.py` around lines 440 - 447, The
_detect_audio_duration_from_url function currently passes unvalidated user URLs
to av.open, enabling SSRF; before calling av.open (and before adding domains in
_push_music_link via music_allowlist_add) validate the URL: ensure scheme is
http or https, parse hostname and resolve DNS to all IPs and reject any
private/loopback/link-local/IPv6-ULA addresses (127.0.0.0/8, 10/8, 172.16/12,
192.168/16, 169.254/16, ::1, fc00::/7, etc.), and only if the host passes
allowlist checks call av.open; also pass FFmpeg options including
protocol_whitelist=http,https,tcp,tls to av.open and only call
music_allowlist_add from _push_music_link after the URL/IP checks succeed; keep
using _extract_duration_from_av_container for duration extraction.
| stored_filename, music_url = self._save_upload_file(binary, ext) | ||
| absolute_url = self._to_absolute_ui_url(music_url) | ||
|
|
||
| detected_duration = self._detect_audio_duration_seconds(binary) | ||
| final_duration = _normalize_duration(duration_sec) if duration_sec is not None else ( | ||
| detected_duration if detected_duration is not None else _DEFAULT_TRACK_DURATION_SECONDS | ||
| ) |
There was a problem hiding this comment.
这里把 await 协程当同步函数用了喵~事件循环要被卡住了!
_save_upload_file 内部是 full_path.write_bytes(binary)(line 427)的同步阻塞磁盘写,_detect_audio_duration_seconds 内部是同步的 av.open(io.BytesIO(binary)) PyAV 解码。两个调用都在 async def upload_music_file 的协程里没有用 asyncio.to_thread 包,对照 push_music_url line 1558-1561 是有用 asyncio.to_thread 的——所以是写漏了喵~
考虑到上传的可能是几百 MB 的音频,期间整个事件循环会停摆,其他用户的请求、调度器循环、定时任务都会被拖住的笨蛋!
🔧 建议改法
- stored_filename, music_url = self._save_upload_file(binary, ext)
+ stored_filename, music_url = await asyncio.to_thread(
+ self._save_upload_file, binary, ext
+ )
absolute_url = self._to_absolute_ui_url(music_url)
- detected_duration = self._detect_audio_duration_seconds(binary)
+ detected_duration = await asyncio.to_thread(
+ self._detect_audio_duration_seconds, binary
+ )🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@plugin/plugins/music_pusher/__init__.py` around lines 1438 - 1444, 在
upload_music_file 的协程里把同步阻塞 I/O 调用改为在线程池执行:不要直接调用 self._save_upload_file(...) 和
self._detect_audio_duration_seconds(...),而是用
asyncio.to_thread/loop.run_in_executor 调用它们(即 await
asyncio.to_thread(self._save_upload_file, binary, ext) 和 await
asyncio.to_thread(self._detect_audio_duration_seconds,
binary)),以避免将大型文件写入(full_path.write_bytes)和 PyAV
解码(av.open(io.BytesIO(...)))阻塞事件循环;保留后续的 _normalize_duration/DEFAULT
常量逻辑不变并确保返回值按原来处理。
|
|
||
| const PLUGIN_ID = "music_pusher"; | ||
| const RUNS_URL = "/runs"; | ||
| const MAX_AUDIO_SIZE = 3 * 1024 * 1024 * 1024 * 1024; |
There was a problem hiding this comment.
3TB 的上传上限 + FileReader.readAsDataURL 根本不可能跑通的喵!
MAX_AUDIO_SIZE = 3 * 1024 * 1024 * 1024 * 1024 是 3TB,但 fileToDataUrl 走的是 FileReader.readAsDataURL,浏览器先把整段二进制读进内存再 base64 化(额外 +33% 膨胀),然后塞进 JSON body 一次性 POST 给 /runs。实际上 1GB 量级就会让浏览器 tab 直接 OOM 崩掉了喵——这个上限根本是摆设,反而会误导主人塞进巨大的文件然后页面卡死的说!
建议把前端上限收紧到一个真实可承载的数(比如 200MB~500MB),同时未来如需大文件支持,改走 multipart/form-data 直传或分片上传喵~
🛡️ 建议改法
- const MAX_AUDIO_SIZE = 3 * 1024 * 1024 * 1024 * 1024;
+ // 走 base64+JSON 时实际可承载的上限远低于后端配置,这里给浏览器一个保守值
+ const MAX_AUDIO_SIZE = 500 * 1024 * 1024;Also applies to: 1330-1337, 1952-1969
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@plugin/plugins/music_pusher/static/index.html` at line 1015, MAX_AUDIO_SIZE
is set to 3TB but fileToDataUrl uses FileReader.readAsDataURL which loads the
entire file into memory (plus ~33% base64 bloat) and will OOM long before that;
change the MAX_AUDIO_SIZE constant from 3TB to a realistic browser-safe limit
(e.g., 200MB–500MB), add an early size check where fileToDataUrl is invoked to
reject files exceeding the new MAX_AUDIO_SIZE and show a clear user-facing error
before calling readAsDataURL, and document/prepare for a future migration of
large-file paths (e.g., uploads to /runs) to multipart/form-data or chunked
uploads instead of base64-in-JSON.
| function toPlayableUrl(rawUrl) { | ||
| const text = String(rawUrl || "").trim(); | ||
| if (!text) return ""; | ||
| try { | ||
| const parsed = new URL(text, window.location.href); | ||
| const isPluginUpload = parsed.pathname.includes(`/plugin/${PLUGIN_ID}/ui/uploads/`); | ||
| if (isPluginUpload) { | ||
| return `http://127.0.0.1:48916${parsed.pathname}${parsed.search}${parsed.hash}`; | ||
| } | ||
| return parsed.href; | ||
| } catch (_err) { | ||
| return text; | ||
| } | ||
| } |
There was a problem hiding this comment.
toPlayableUrl 把端口硬编码成 48916,会把后端配好的端口覆盖掉的喵!
主人写得太草率了喵~后端 _to_absolute_ui_url 已经通过 _resolve_public_origin() 读过 NEKO_PLUGIN_SERVER_ORIGIN / USER_PLUGIN_SERVER_PORT 等配置返回了正确的绝对 URL,可这里只要 path 匹配 /plugin/{PLUGIN_ID}/ui/uploads/ 就强行重写成 http://127.0.0.1:48916...,主人换了端口或者配了反向代理就直接 404 了,笨蛋喵!
🔧 推荐的改法
function toPlayableUrl(rawUrl) {
const text = String(rawUrl || "").trim();
if (!text) return "";
try {
const parsed = new URL(text, window.location.href);
- const isPluginUpload = parsed.pathname.includes(`/plugin/${PLUGIN_ID}/ui/uploads/`);
- if (isPluginUpload) {
- return `http://127.0.0.1:48916${parsed.pathname}${parsed.search}${parsed.hash}`;
- }
return parsed.href;
} catch (_err) {
return text;
}
}
…nd stage visualization
Summary by CodeRabbit
发布说明
新功能