Skip to content

feat(plugin): add Music Pusher plugin with audio upload, scheduling a…#952

Open
StarrySerendipity wants to merge 1 commit into
Project-N-E-K-O:mainfrom
StarrySerendipity:feature/music-pusher-plugin
Open

feat(plugin): add Music Pusher plugin with audio upload, scheduling a…#952
StarrySerendipity wants to merge 1 commit into
Project-N-E-K-O:mainfrom
StarrySerendipity:feature/music-pusher-plugin

Conversation

@StarrySerendipity
Copy link
Copy Markdown

@StarrySerendipity StarrySerendipity commented Apr 25, 2026

…nd stage visualization

Summary by CodeRabbit

发布说明

新功能

  • 新增音乐推送插件,支持本地音乐文件上传及在线音乐链接添加
  • 支持音乐库管理、歌词绑定、播放控制和进度报告
  • 新增定时播放任务编辑器,支持队列排序管理和自动化播放
  • 提供可视化舞台展示和实时播放进度条显示

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 25, 2026

Walkthrough

添加了完整的音乐推送和定时队列插件模块,支持音频验证解码、歌词处理、时长检测、JSON状态持久化、异步调度器和播放控制功能喵。

Changes

Cohort / File(s) Summary
音乐推送插件核心
plugin/plugins/music_pusher/__init__.py
新增MusicPusherPlugin类,实现音乐文件上传、URL推送、定时任务调度、播放控制等核心功能,包含base64解码、格式验证、时长检测、JSON状态持久化和异步调度器逻辑喵
插件配置清单
plugin/plugins/music_pusher/plugin.toml
声明插件身份、元数据(中文名称/描述)、入口点和版本,配置SDK兼容性约束、运行时自启动和数据持久化选项喵
音乐推送Web UI
plugin/plugins/music_pusher/static/index.html
完整的前端界面,提供音乐上传、URL链接、歌词管理、定时任务编辑、实时播放控制和WebGL可视化效果(Three.js粒子/波形/光线动画)喵

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: 实时推送状态更新喵
Loading
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: 返回新状态
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • Refactor/music UI async allowlist #568: 添加了完整的音乐推送插件(包含push_music_url入口点和ctx.push_message机制),直接与本PR的音乐推送UI和消息传递逻辑相关喵

Suggested reviewers

  • wehos

Poem

🎵 音乐推送舞台版来也~
上传链接随心播,歌词绑定样样俏喵
定时调度自动跳,WebGL粒子闪闪耀
任务队列顺序妙,一键推送乐逍遥~ 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.16% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR标题准确概括了变更的核心内容:添加Music Pusher插件,包含音频上传、任务调度和舞台可视化功能。
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
plugin/plugins/music_pusher/__init__.py (1)

1230-1231: _wake_scheduler 是空的喵,而且每个任务都开一个轮询协程是浪费资源的说~

两件相关的事情一起说喵:

  1. _wake_scheduler 函数体只有 return,是个不做任何事的占位(被 line 1860/1950/2023/2410 调用了 4 次)。要么干掉,要么就用 self._scheduler_stop 之外配个 asyncio.Event 在调度循环里 wait_for(timeout=...) 真正实现唤醒喵~

  2. _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 个 callEntrylist_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

📥 Commits

Reviewing files that changed from the base of the PR and between 7019c4b and 38570e7.

📒 Files selected for processing (3)
  • plugin/plugins/music_pusher/__init__.py
  • plugin/plugins/music_pusher/plugin.toml
  • plugin/plugins/music_pusher/static/index.html

Comment on lines +440 to +447
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

SSRF 警报喵!PyAV/FFmpeg 会跟着用户给的 URL 到处乱跑!

_detect_audio_duration_from_url 直接把外部传进来的 url(例如 push_music_urlurl 参数)丢给 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 做严格校验:

  1. 仅允许 http/https scheme
  2. 解析 hostname → 解析 DNS → 拒绝私网/回环/链路本地 IP(127/8、10/8、172.16/12、192.168/16、169.254/16、::1、fc00::/7 等)
  3. av.openprotocol_whitelist=http,https,tcp,tls(FFmpeg option)作为兜底
  4. 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.

Comment on lines +1438 to +1444
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
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

这里把 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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +1195 to +1208
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;
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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;
       }
     }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant