Skip to content

fix(subsonic): 修复 Subsonic 客户端无法播放 OpenList/WebDAV 挂载音乐 - #313

Open
boy6656598 wants to merge 40 commits into
XCQ0607:mainfrom
boy6656598:260805-feat-webdav-mount-cache
Open

fix(subsonic): 修复 Subsonic 客户端无法播放 OpenList/WebDAV 挂载音乐#313
boy6656598 wants to merge 40 commits into
XCQ0607:mainfrom
boy6656598:260805-feat-webdav-mount-cache

Conversation

@boy6656598

@boy6656598 boy6656598 commented Aug 8, 2026

Copy link
Copy Markdown

主要改动

  • Subsonic 内部流认证修复:Subsonic 客户端仅用 u/p 参数认证,无 lxserver session cookie,stream 302 到 /api/openlist/stream/api/webdav-mounts/stream 后返回 401 无法播放挂载音乐。内部流 URL 附加 HMAC 签名令牌 sst(含 server+path+过期时间),两个 stream 端点校验放行(timingSafeEqual 防伪造)。
  • WebDAV 播放修复:0 字节损坏缓存不再无条件返回,回退 dav 流式;total===0 时边播边缓存落盘修复;stream Content-Type 兜底;前端重试自动带 nocache=1
  • WebDAV/OpenList 音乐挂载:多挂载源管理、目录树浏览一键入单、边播边缓存、歌词支持、目录索引合并本地音乐主列表、docker host-gateway 适配。
  • Docker/CI:镜像推送 ghcr.io,compose 使用已发布镜像。

Summary by CodeRabbit

  • New Features

    • Browse, search, stream, download, and cache music from OpenList and WebDAV sources.
    • Manage remote connections, test connectivity, and create playlists from remote folders.
    • Add card-based registration, username/password login, and optional forced player authentication.
    • Manage registration cards from the administration dashboard.
    • Access improved lyrics, uploads, cache status, and local music integration.
  • Documentation

    • Expanded Docker Compose, NAS, Synology, migration, OpenList, and WebDAV setup guidance.
  • Bug Fixes

    • Improved ranged media delivery, host connectivity, restoration reliability, and network timeout handling.

XCQ0607 and others added 30 commits August 2, 2026 03:55
…tion & forced login

- OpenList: multi-server CRUD config (openlist.json), browse/search/stream/lyric/upload via AList-compatible API, server-side proxy streaming with Range support
- Alidrive: client config, QR login binding, file browse/play/upload/download
- Cards: card-code registration for player accounts
- Player: forced login, register page, OpenList & Alidrive manager modules
- Admin: OpenList server management, Alidrive binding UI
- app.js: explicitly init/refresh AlidriveManager and OpenListManager in switchTab
- Remove switchTab-overriding IIFEs from both manager scripts (they ran before app.js, dropping original tab logic)
- docker-compose.yml: full deployment comments and env var examples (WEBPLAYER_PASSWORD/ENABLE_WEBPLAYER_AUTH)
- scripts/migrate-to-nas.sh: package config.js + data/ into a tarball with NAS-side install steps
- stream 代理改用原生 http/https 替代 needle(needle 3.x 响应约 130KB 后卡死)
- 新增边播边缓存:首次播放写入本地磁盘,后续播放/拖拽秒开
- 新增缓存接口:cache/check、cache/status、cache/clear(管理员)
- 前端 OpenList 列表显示已缓存/缓存中徽标
- config.js 解除 git 跟踪并加入 .gitignore,防止真实凭据误提交
- openlist.ts: 递归扫描目录树生成本地音乐索引(含超时/目录数/并发防护),新增 /api/openlist/local-list 接口
- server.ts: /api/music/cache/list 合并 folder='openlist' 条目;stream 代理服务端跟随 302 重定向(上游直链跳转 OSS/CDN)
- local_music.js: 本地音乐 tab 支持 openlist 筛选、播放/下载/收藏、目录加歌单、内嵌 OpenList 目录树浏览面板
- app.js: cleanSongData/formatSongToLxMusicStandard 保留 openlist 字段,收藏后恢复播放
- 新增 user.enableOpenListInLocalMusic 配置开关(默认开启)
- 部署方式重排: NAS Docker Compose 一键部署置顶, 补充 migrate-to-nas.sh 迁移脚本用法与 ./data 备份说明
- 新增群晖 SPK 套件安装指引与 Subsonic 访问地址
- 链接归属由 XCQ0607/lxserver 全部替换为 boy6656598/lxserver
- 删除不存在的 star history 图表与 docs 外链
alist.embyfd.cc.cd 等域名同时解析出 IPv6(AAAA), Node 默认优先 IPv6,
而部分部署环境 IPv6 路由不可达导致 connect ENETUNREACH。在入口设置
dns.setDefaultResultOrder('ipv4first') 覆盖 needle/webdav/原生 http 全部出站请求
- 移除阿里云盘功能:删除 alidrive.ts/aliyun_manager.js、全部 /api/alidrive/* 路由及前后端 UI 引用
- 修复本地音乐播放:serveCacheFile 增加 Range 416 校验与 suffix range 支持,避免 seek 时崩溃
- 修复 OpenList/WebDAV 内网地址挂载:无协议地址默认补 http:// 而非强制 https
…-playback

feat: remove alidrive & fix local playback & intranet mount
- 新增 src/server/webdavMount.ts: 多挂载源 CRUD 持久化(webdav-mounts.json)、
  目录扫描音频索引(TTL缓存/防护)、边播边缓存(streamToCache .tmp->rename)、
  本地Range服务/缓存进度/清空
- server.ts 新增 /api/webdav-mounts 系列路由(CRUD/test/available/browse/
  local-list/stream/cache-check/status/clear); 主音乐列表合并 webdav 索引
- subsonic.ts handleStream 扩展: webdav_/openlist_/local 走内部流 302
- 前端: 后台 WebDAV 挂载管理视图(index.html/app.js); 播放器 WebDAV 目录树
  面板与目录加歌单(local_music.js/index.html), 缓存进度徽标
- 单元测试 webdavMount.test.ts 20 项(node:test + mock WebDAV)
- 更新 tasklist 标记全部任务完成
- webdavSync.scanFiles 排除 openlist-cache/webdav-cache 缓存目录,防止音频缓存同步到 dav 后被挂载源索引为远程音乐(自我污染)
- openlist/webdav 索引扫描跳过 lx-sync/lx-sync-backups 同步目录
- webdavMount.joinRemotePath 对已含 rootPath 前缀的路径去重,修复 browse 路径翻倍导致目录树不显示文件夹
- webdavMount 索引 path 统一为相对 baseUrl 的完整路径(含 rootPath),修复主列表 stream 播放路径错误
- local_music playItem 改用 buildPlaylistSong 构造播放列表,保留 source/name/singer/url,修复 Invalid songInfo 与 Unknown 歌曲名
- webdav 播放 URL 追加 token 供 audio 鉴权
- 前端 fetchSongUrl 本地直放条件加入 /api/webdav-mounts/stream 前缀
- 前端 fetchLyric 新增 WebDAV 同目录 .lrc 歌词分支(经 /api/webdav-mounts/lyric)
- 服务端 /api/music/url 对 webdav/openlist 兜底返回 stream URL, 避免 Unknown/自定义源错误
- 服务端 /api/music/lyric 对 webdav/openlist 直接返回空, 避免 Source not supported 刷屏
- webdavMount 新增 getLyric(同目录 .lrc), 配套 /api/webdav-mounts/lyric 路由与测试
- formatSongToLxMusicStandard: 新增 webdav case, 保留 url/serverId/path/webdav/folder, 与 openlist 对称
- cleanSongData: WebDAV 歌曲保留 serverId/path 并标记 webdav/isLocal
- /api/music/url: webdav/openlist 兜底升级为自动恢复——path 可从 id 前缀解码, serverId 缺失时按 rootPath 前缀或本地索引自动查找挂载/服务器, 旧收藏(已丢失字段)也能恢复播放
- serveCacheFile 对 0 字节缓存返回无效并回退上游 dav 流式,避免 audio 收到空响应
- 修复下载/边播边缓存在上游无 Content-Length(total=0) 时无条件落盘的问题
- stream 转发 Content-Type 兜底:octet-stream/缺失时按扩展名映射正确音频 MIME
- stream 支持 nocache=1 参数;前端 webdav 播放重试时强制绕过损坏缓存重新拉流
Subsonic 客户端仅用 u/p 参数认证,无 lxserver session cookie,
stream 302 到 /api/openlist/stream、/api/webdav-mounts/stream 后返回 401。
在内部流 URL 附加 HMAC 签名令牌 sst(含 server+path+过期时间),
两个 stream 端点校验放行,使箭头音乐等 Subsonic 客户端可播放挂载音乐。
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR adds OpenList and WebDAV music sources with browsing, indexing, streaming, caching, lyrics, playlists, Subsonic support, administration, card-based registration, forced login, deployment changes, and documentation.

Changes

Remote music sources

Layer / File(s) Summary
Remote source contracts and design
.monkeycode/specs/webdav-mount-cache/*
Defines WebDAV persistence, indexing, caching, streaming, Subsonic routing, frontend behavior, and validation requirements.
Remote source services
src/server/openlist.ts, src/server/webdavMount.ts, src/utils/webdavSync.ts
Adds remote configuration, browsing, indexing, streaming, cache management, lyric lookup, uploads, URL normalization, and bounded scans.
Server and Subsonic routing
src/server/server.ts, src/server/subsonic.ts
Adds authenticated APIs, merged indexes, signed internal stream URLs, direct remote-song resolution, and Subsonic redirects.
Remote browsing and playback UI
public/app.js, public/index.html, public/music/*
Adds OpenList and WebDAV management, browsing, playback, downloads, lyrics, cache badges, and playlist collection.
Remote source validation
src/server/webdavMount.test.ts, src/server/hostResolver.test.ts
Adds coverage for persistence, indexing, streaming, ranges, caching, deduplication, lyrics, cleanup, and host resolution.

Authentication and card management

Layer / File(s) Summary
Authentication configuration
src/defaultConfig.ts, src/types/config.d.ts
Adds configuration for OpenList local music, forced player login, and user registration.
Persistent registration cards
src/server/cards.ts
Adds card generation, persistence, listing, deletion, expiration checks, and consumption.
Login and registration APIs
src/server/server.ts
Adds forced-login validation, token cookies, card-code registration, and administrator card endpoints.
Authentication and administration UI
public/music/login.html, public/index.html, public/app.js
Adds username login, card registration, card administration, OpenList management, and WebDAV mount management.

Deployment and runtime

Layer / File(s) Summary
Container and deployment configuration
.github/workflows/docker.yml, Dockerfile, docker-compose.yml, scripts/migrate-to-nas.sh, .dockerignore
Switches publishing to GitHub Container Registry, updates production artifacts, expands Compose settings, excludes downloads from the build context, and adds NAS migration support.
Deployment documentation and project records
README.md, README_EN.md, .monkeycode/MEMORY.md, .monkeycode/specs/webdav-mount-cache/tasklist.md
Documents remote music features, deployment options, configuration, migration, packaging, and operational procedures.
Runtime networking and cache behavior
src/index.ts, src/server/hostResolver.ts, src/server/fileCache.ts, public/js/config.js
Adds IPv4-preferred DNS resolution, non-fatal WebDAV restore failures, host URL resolution, improved byte-range responses, and a new build hash.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the main fixes and features, but it omits the required change type and pre-submission checklist sections. Add the required change type and checklist sections, and mark each completed checklist item.
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the Subsonic playback fix for OpenList and WebDAV mounted music.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

🟠 Major comments (19)
Dockerfile-15-17 (1)

15-17: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make the migrated config.js reachable by the container.

Line 17 removes config.js from the runtime image. Compose mounts only ./data. The migration archive includes a cleaned config.js, but the container cannot read it. Migrated configuration is therefore ignored, or startup fails if the configuration loader requires /server/config.js.

  • Dockerfile#L15-L17: retain a default runtime configuration or move configuration loading to a persisted location.
  • docker-compose.yml#L32-L34: mount the migrated configuration, or configure CONFIG_PATH to a mounted path.
  • scripts/migrate-to-nas.sh#L18-L53: package configuration only at the path that the container loads.
  • README.md#L174-L200: document the selected configuration mount and path.
  • README_EN.md#L165-L191: document the selected configuration mount and path.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Dockerfile` around lines 15 - 17, Make the migrated config.js available at
the path consumed by the container: in Dockerfile lines 15-17, retain or
relocate the runtime configuration; in docker-compose.yml lines 32-34, mount the
migrated configuration or set CONFIG_PATH to its mounted location; in
scripts/migrate-to-nas.sh lines 18-53, package config.js at that same path; and
document the selected mount and configuration path in README.md lines 174-200
and README_EN.md lines 165-191. Ensure all five files use one consistent runtime
configuration location.
public/music/login.html-227-231 (1)

227-231: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Do not persist passwords or bearer tokens in localStorage.

Any same-origin script can read lx_sync_pass and lx_user_token. This includes injected scripts. The password remains on the device after the browser closes.

  • public/music/login.html#L227-L231: remove persistent storage of password and migrate token consumers to the authenticated session flow.
  • public/music/login.html#L278-L282: apply the same change after registration.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@public/music/login.html` around lines 227 - 231, Remove persistence of
lx_sync_pass and lx_user_token from both login.html sites at lines 227-231 and
278-282, covering the success paths after login and registration. Migrate any
token consumers to the existing authenticated session flow while preserving
username and mode storage as appropriate.

Source: Linters/SAST tools

src/server/cards.ts-22-39 (1)

22-39: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Fail closed when card-state persistence fails.

loadCards converts an unreadable file into an empty card set. saveCards suppresses write failures. If consumeCard cannot persist the used state, registration can still create the user. A later request reloads the unused card from disk and accepts it again.

Throw on read or write errors. Do not replace invalid state with []. Write to a temporary file and rename it atomically.

Proposed minimum fix
 const saveCards = (): void => {
   try {
     fs.writeFileSync(cardsPath(), JSON.stringify(cards, null, 2), 'utf8')
   } catch (e) {
     console.error('[Cards] Failed to save cards:', e)
+    throw new Error('Failed to persist card state')
   }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/cards.ts` around lines 22 - 39, Update loadCards and saveCards to
fail closed: propagate read, parse, and write errors instead of replacing
invalid or unreadable state with [] or suppressing failures. In saveCards, write
the serialized cards to a temporary file and atomically rename it to cardsPath()
only after the write succeeds, preserving card state when persistence fails.
src/server/server.ts-4448-4454 (1)

4448-4454: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not treat the username admin as administrator authorization.

requirePlayerOrAdmin returns the string 'admin' for a verified administrator password, but it also returns the raw username from verifyUserAuth for a normal account. The cache-clear route at Line 4930 compares username !== 'admin'. A registered user whose name is admin therefore passes the administrator check. The registration validator at Line 4335 accepts admin as a username.

Use requireAdminAuth() directly for this route.

🔒️ Proposed fix
-        const username = requirePlayerOrAdmin()
-        if (!username || username !== 'admin') {
+        if (!requireAdminAuth()) {
           res.writeHead(403, { 'Content-Type': 'application/json' })
           res.end(JSON.stringify({ success: false, message: '需要管理员权限' }))
           return
         }

Also applies to: 4928-4934

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/server.ts` around lines 4448 - 4454, Update the cache-clear route
authorization to call requireAdminAuth() directly instead of relying on
requirePlayerOrAdmin() and comparing its returned username to 'admin'. Preserve
the existing denial behavior while ensuring a registered account named 'admin'
cannot satisfy the administrator check.
public/music/js/openlist_manager.js-260-266 (1)

260-266: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The badge refresh appends a duplicate list instead of updating it.

refreshCacheBadges calls this.renderList(false) at Line 295. In renderList, reset === false takes the insertAdjacentHTML('beforeend', html) branch at Line 263. html contains the complete folder and audio list, so the whole directory listing is appended a second time. The user sees every entry twice after the first cache badge resolves.

Re-render in place, or update only the badge elements by id, as refreshWmCacheBadges does in public/music/js/local_music.js.

🐛 Proposed fix
         if (JSON.stringify(changed) !== JSON.stringify(this._cacheBadges)) {
             this._cacheBadges = changed;
             const listEl = document.getElementById('ol-file-list');
-            if (listEl && !this.loading) this.renderList(false);
+            if (listEl && !this.loading) this.renderList(true);
         }

renderList(true) overwrites innerHTML and keeps the list correct. Note that it calls refreshCacheBadges again; the second pass produces an equal changed object and stops.

Also applies to: 292-296

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@public/music/js/openlist_manager.js` around lines 260 - 266, Update
refreshCacheBadges to call renderList(true) instead of renderList(false),
ensuring the complete listing replaces the existing content rather than being
appended as duplicates. Preserve the existing badge refresh flow and its
changed-state termination behavior.
src/server/server.ts-4326-4361 (1)

4326-4361: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Check the registration switch before you disclose account existence, and consume the card after the account is created.

Two ordering problems exist:

  1. Line 4345 returns 409 for an existing username before Line 4350 checks player.enableRegister. An anonymous caller can enumerate accounts even when registration is closed. Move the enableRegister check to the top of the handler.
  2. cards.consumeCard runs at Line 4356 before checkAndCreateDir and saveUsers. If directory creation or user persistence throws, the outer catch returns 400 and the card stays consumed. Consume the card only after the user record is saved, or release it in the failure path.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/server.ts` around lines 4326 - 4361, In the /api/auth/register
POST handler, move the player.enableRegister check before username existence
validation so closed registration never reveals whether an account exists.
Reorder cards.consumeCard to run only after checkAndCreateDir and saveUsers
successfully persist the new user, preserving the existing card-error response
behavior.
src/server/server.ts-4459-4475 (1)

4459-4475: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not fall back to a hardcoded HMAC secret.

Line 4468 uses the literal 'lxserver-internal' when frontend.password is empty. The literal is public in the repository. Any caller can then mint a valid sst token for any server and path and stream every mounted file without a session. Generate a random per-process secret, or persist a dedicated stream secret, and reject sst when no secret is configured.

The token also binds only server, path, and the expiry. It does not bind the requesting user, so a leaked stream URL is replayable until expiry. Keep the expiry window short.

🔒️ Proposed fix
+      // 模块级:进程启动时生成随机内部签名密钥
+      // const INTERNAL_STREAM_SECRET = global.lx.config['frontend.password'] || crypto.randomBytes(32).toString('hex')
-        const secret = global.lx.config['frontend.password'] || 'lxserver-internal'
+        const secret = INTERNAL_STREAM_SECRET
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/server.ts` around lines 4459 - 4475, Update
verifyInternalStreamToken to eliminate the hardcoded fallback secret: use a
securely generated per-process or persisted dedicated stream secret, and reject
sst tokens when no secret is configured. Bind tokens to the requesting user
where available and enforce a short expiry window, preserving constant-time
signature validation.
public/music/js/local_music.js-49-56 (1)

49-56: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Declare the WebDAV panel state next to the OpenList panel state.

The block declares olServers, olCurrentPath, olBreadcrumb, olItems, olSearchMode, and olPanelInitialized. The WebDAV counterparts wmServers, wmCurrentServerId, wmCurrentPath, wmBreadcrumb, wmItems, and wmPanelInitialized are never declared. They stay undefined until the first assignment.

wmAddCurrentDirToPlaylist (Line 1367) and wmPlayAudio (Line 1342) call this.wmItems.filter(...). Both buttons exist in the DOM as soon as the panel opens. If the user opens the WebDAV panel and clicks "目录加歌单" before any mount is selected, this.wmItems is undefined and the call throws a TypeError.

Also declare olSearchKeyword, which loadOlList reads at Line 876.

🐛 Proposed fix
     olItems: [],
     olSearchMode: false,
+    olSearchKeyword: '',
     olPanelInitialized: false,
+    // [新增] 内嵌 WebDAV 挂载目录树浏览面板
+    wmServers: [],
+    wmCurrentServerId: '',
+    wmCurrentPath: '/',
+    wmBreadcrumb: [],
+    wmItems: [],
+    wmPanelInitialized: false,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@public/music/js/local_music.js` around lines 49 - 56, Declare the missing
WebDAV panel state alongside the existing OpenList state: initialize wmServers,
wmCurrentServerId, wmCurrentPath, wmBreadcrumb, wmItems, and wmPanelInitialized
with appropriate empty defaults so wmAddCurrentDirToPlaylist and wmPlayAudio can
safely call wmItems.filter before mount selection. Also initialize
olSearchKeyword for loadOlList to read.
src/server/server.ts-4794-4807 (1)

4794-4807: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Apply the same zero-byte guard that the WebDAV path uses.

The WebDAV stream handler at Line 5287 requires cacheReceived > 0 before it renames the temporary file. The OpenList handler here does not. When the upstream returns an empty body without Content-Length, total is 0 and cacheReceived is 0, so a zero-byte file is promoted to the cache. Every later request then hits that broken cache entry through serveCacheFile at Line 4732.

🐛 Proposed fix
-                    if (total === 0 || cacheReceived >= total) {
+                    if (cacheReceived > 0 && (total === 0 || cacheReceived >= total)) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/server.ts` around lines 4794 - 4807, Update the completion
condition in the OpenList response end handler around cacheWs.end so temporary
files are renamed only when cacheReceived is greater than zero and the existing
total-length condition is satisfied. Preserve the current cleanup and
openlist.clearCache behavior for zero-byte or incomplete downloads, matching the
guard used by the WebDAV stream handler.
src/server/server.ts-5563-5567 (1)

5563-5567: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Remove the synchronous debug file writes from the search route.

Every search request and every search failure calls fs.appendFileSync on debug.txt in the process working directory. The call blocks the event loop on the request path, and the file grows without any rotation or size limit. Search queries are user input, so this also persists user activity to an unmanaged file.

Use the existing logger instead.

♻️ Proposed fix
-          fs.appendFileSync(path.join(process.cwd(), 'debug.txt'), `[Search] Source: ${source}, Type: ${type}, Query: ${name}, StartPage: ${page}, Pages: ${fetchPages}, Result Count: ${result.length}\n`)
           res.writeHead(200, { 'Content-Type': 'application/json' })
           res.end(JSON.stringify(result))
         } catch (err: any) {
-          fs.appendFileSync(path.join(process.cwd(), 'debug.txt'), `[Search Error] ${err.message}\n${err.stack}\n`)
-          console.error(err)
+          accessLog.warn(`[Search] failed source=${source} type=${type}: ${err.message}`)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/server.ts` around lines 5563 - 5567, Remove both synchronous
fs.appendFileSync calls from the search route’s success and catch paths, and
replace them with the existing logger while preserving the search result and
error-handling behavior. Use the logger to record the search context and failure
details without writing user queries to debug.txt.
public/music/js/openlist_manager.js-320-325 (1)

320-325: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use the parent directory when resolving OpenList search results.

_fullPath currently prefixes every search result with /, so playback, download, and cache-check requests omit the file’s actual directory. Pass each audio item into _fullPath() and build the path from item.parent where available so the resolved paths match the search response.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@public/music/js/openlist_manager.js` around lines 320 - 325, Update _fullPath
and its callers so search results resolve against each audio item’s parent
directory instead of always using the root path. Pass the relevant item into
_fullPath for playback, download, and cache-check requests, use item.parent when
available, and preserve currentPath behavior for non-search paths.
src/server/openlist.ts-478-484 (1)

478-484: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

A rejected index scan is cached permanently in both remote-source modules. getLocalIndex stores the in-flight scan promise in localIndexCache[id].pending and never removes it when the scan rejects. Later non-forced calls return that same rejected promise, so the index never recovers without a process restart or an explicit clearLocalIndex. Because getAllLocalIndex uses Promise.all, one poisoned entry rejects the merged index for every configured source. src/server/subsonic.ts resolveLocalStreamUrl awaits getAllLocalIndex, so Subsonic playback of all remote tracks fails from that point on.

  • src/server/openlist.ts#L478-L484: add a .catch to the collectAudioFiles(...).then(...) chain that deletes localIndexCache[serverId].pending when it still references this promise, logs the error, and resolves to an empty array.
  • src/server/webdavMount.ts#L282-L288: apply the identical .catch to the collectAudioFiles(mount, '/') chain, clearing localIndexCache[mountId].pending.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/openlist.ts` around lines 478 - 484, The in-flight index scan
promises remain cached after rejection, permanently poisoning remote-source
lookups. In src/server/openlist.ts#L478-L484, update the getLocalIndex
collection chain around collectAudioFiles to catch failures, delete
localIndexCache[serverId].pending only when it still references that promise,
log the error, and resolve with an empty array; apply the identical cleanup and
fallback in src/server/webdavMount.ts#L282-L288 for
localIndexCache[mountId].pending.
src/server/subsonic.ts-584-584 (1)

584-584: 🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

A cold index cache makes the Subsonic stream request block on a full remote scan.

getAllLocalIndex returns the cached index only inside the 120s TTL. On a miss it calls collectAudioFiles, which recursively walks the remote tree. The guards in src/server/webdavMount.ts allow up to MAX_SCAN_MS = 60 * 1000, 800 directories, and 5000 files, and each directory listing has its own 20s timeout.

handleStream awaits this before it can send the 302. A Subsonic client that requests the first track after the TTL expires waits for the whole scan. Most clients abort long before 60 seconds, so playback fails intermittently after idle periods.

Consider serving a stale index while a refresh runs in the background, or resolve the path directly from the songmid without requiring the full index.

Also applies to: 602-602

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/subsonic.ts` at line 584, The handleStream flow should not await a
cold getAllLocalIndex scan before issuing the stream redirect. Change the index
lookup around getAllLocalIndex to serve the existing stale cache while
triggering any refresh asynchronously, or resolve the requested path directly
from songmid; preserve normal cached lookups while ensuring the first-track
request is not blocked by collectAudioFiles.
src/server/webdavMount.ts-396-409 (1)

396-409: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Set a socket timeout on the WebDAV stream request.

lib.request is created without a timeout, and Line 406 swallows errors. If the WebDAV host accepts the TCP connection and then stops responding, no response and no error event fires. The request hangs for the process lifetime.

The consequence is not limited to one request. downloadToCache (Line 464) wraps this request in a promise that resolves only from a response or error handler. A hung request means that promise never settles, and inFlight[key] (Line 528) keeps it forever. Every later downloadToCache call for the same file returns the same never-settling promise.

🐛 Proposed fix
 export const stream = (mount: WebDAVMount, filePath: string, range?: string): http.ClientRequest => {
   const targetUrl = new URL(fileUrl(mount, filePath))
   const headers: Record<string, string> = { 'User-Agent': 'lxserver/1.0' }
   if (mount.username && mount.password) {
     const token = Buffer.from(`${mount.username}:${mount.password}`).toString('base64')
     headers['Authorization'] = `Basic ${token}`
   }
   if (range) headers['Range'] = range
   const lib = targetUrl.protocol === 'https:' ? https : http
-  const req = lib.request(targetUrl, { method: 'GET', headers } as any)
+  const req = lib.request(targetUrl, { method: 'GET', headers, timeout: 30000 } as any)
+  // 无响应时主动销毁,触发 'error',避免调用方 Promise 永不 settle
+  req.on('timeout', () => { req.destroy(new Error('WebDAV stream timed out')) })
   req.on('error', () => { /* 错误由调用方处理 */ })
   req.end()
   return req
 }

Note that req.destroy(err) emits error, which downloadToCache already handles at Line 480.

Apply the same timeout to openlist.stream in src/server/openlist.ts Line 270.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/webdavMount.ts` around lines 396 - 409, Set a socket timeout on
the requests created by both WebDAV stream and openlist.stream, and destroy the
request with an error when the timeout fires so the existing downloadToCache
error handler settles its promise. Preserve the current request setup and error
propagation while ensuring stalled connections cannot remain pending
indefinitely.
src/server/subsonic.ts-2055-2067 (1)

2055-2067: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

The sst token travels in a query string and will be written to access logs.

Line 2060 puts the signed token in the Location query string. The redirected request then reaches the main dispatcher in src/server/server.ts, which logs the full URL through accessLog.info(\${req.method} ${req.url} from ${ip}`)`. The token that authorizes file access is therefore persisted in plaintext logs, and it stays valid for the remaining part of its 600-second window. Reverse proxies and client-side logs capture it as well.

Consider reducing the validity window, and redacting the sst parameter before logging.

🛡️ Suggested log redaction
-    accessLog.info(`${req.method} ${req.url} from ${ip}`)
+    const safeUrl = (req.url ?? '').replace(/([?&](?:sst|sign)=)[^&]*/gi, '$1<redacted>')
+    accessLog.info(`${req.method} ${safeUrl} from ${ip}`)

Apply this in src/server/server.ts at the dispatcher shown in the provided context.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/subsonic.ts` around lines 2055 - 2067, Redact the sst query
parameter before the dispatcher access log in server.ts records req.url, while
preserving other URL components and existing access-log behavior. Update the
relevant request-logging flow to log a sanitized URL, and reduce the
signed-token validity window from its current 600 seconds where that token
lifetime is configured.
src/server/subsonic.ts-615-626 (1)

615-626: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Point the local cache lookup at the actual FileCache base.

getUserDirname(username) only returns the user directory name, so cacheRoot is built under process.cwd() instead of the configured cache root. For local streams, build the candidate cache root with the same logic used by serveCacheFile: check the active cache location under both cache and music, or import and call setCacheLocation instead of hard-coding music/cache. As written, local-cache Subsonic playback returns Could not resolve local track.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/subsonic.ts` around lines 615 - 626, Update the local cache lookup
around getUserDirname and cacheRoot to resolve the configured FileCache base
using the same cache-location logic as serveCacheFile, checking active cache
paths under both cache and music as applicable. Do not derive the root solely
from process.cwd() plus music/cache; reuse setCacheLocation or the established
cache-location helper so local Subsonic streams locate cached tracks correctly.
src/server/webdavMount.ts-71-84 (1)

71-84: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Validate the caller-supplied mount id before it reaches filesystem paths.

Line 75 accepts data.id from the caller and only generates an id when it is absent. addMount is reachable from the mount-creation API, so the stored id can contain path separators or ... That id is then joined into filesystem paths without normalization:

  • Line 112 / Line 113: fs.rmSync(path.join(dataPath, 'webdav-cache', id), { recursive: true, force: true }) on delete.
  • Line 316: getCacheDir creates the directory.
  • Line 561: clearCache removes it.

An id such as ../../.. turns mount deletion into an arbitrary recursive directory removal. The operation requires administrator access, but the guarantee should not depend on that. Restrict the id to a safe character set.

🛡️ Proposed fix
+const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/
+
 const normalizeMount = (data: any): WebDAVMount => {
   let baseUrl = String(data.baseUrl || '').trim().replace(/\/+$/, '')
   if (baseUrl && !/^https?:\/\//i.test(baseUrl)) baseUrl = 'http://' + baseUrl
+  const rawId = String(data.id || '')
+  if (rawId && !SAFE_ID_RE.test(rawId)) throw new Error('非法的挂载源 ID')
   return {
-    id: data.id || 'wd_' + crypto.randomBytes(6).toString('hex'),
+    id: rawId || 'wd_' + crypto.randomBytes(6).toString('hex'),

Apply the same validation to the id used by getCacheDir, clearCache, and cacheStatus if those can be reached with an id that was not loaded from the config file.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/webdavMount.ts` around lines 71 - 84, Validate mount IDs in
normalizeMount and in getCacheDir, clearCache, and cacheStatus before using them
in filesystem paths, restricting them to a safe character set that excludes path
separators, traversal components, and other unsafe input. Preserve valid
configured/generated IDs, and reject or safely handle invalid caller-supplied
IDs consistently across creation and cache operations.
src/utils/webdavSync.ts-209-234 (1)

209-234: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Do not let incomplete listings drive restore consistency uploads.

listRemoteFiles treats skipped deep subtrees and failed listings as empty subtrees, while restoreFromRemote uses the resulting remoteFileSet for the “missing on cloud” upload step. Partial listings can omit valid remote files, so local copies get re-uploaded and the restore reports success without the missing files.

Make listRemoteFiles distinguish depth truncation and listing failures from empty directories, and skip the consistency-upload step when the listing is incomplete.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/webdavSync.ts` around lines 209 - 234, Update listRemoteFiles to
propagate an explicit incomplete-listing state when LIST_DEPTH_LIMIT truncates
recursion or getDirectoryContents fails, while preserving empty results for
genuinely empty directories. In restoreFromRemote, detect that state and skip
the “missing on cloud” consistency-upload step; only perform remoteFileSet-based
uploads after a complete listing.
src/server/webdavMount.ts-149-166 (1)

149-166: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Distinguish a listing timeout from an empty directory.

Promise.race resolves [] when the timeout fires, so listFiles returns { items: [] } with no error. Callers then treat an unresponsive WebDAV directory as valid, with false-positive connection results and silent index generation that can cache a partial result. Abort the pending request instead of leaving it running after the timeout.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/webdavMount.ts` around lines 149 - 166, Update listFiles so a
timeout from Promise.race is distinguished from a genuinely empty directory:
reject or throw a timeout error when timeoutMs elapses, abort/cancel the pending
getDirectoryContents request, and let the existing catch return an error
alongside empty items. Preserve successful empty-directory results as { items:
[] }.
🟡 Minor comments (4)
README.md-181-190 (1)

181-190: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Make migration archive extraction conditional.

A fresh installation does not have lxserver-nas-deploy.tar.gz. The documented command fails before Compose starts. Split the migration extraction steps from the fresh-install steps.

  • README.md#L181-L190: run tar -xzf only for migration installs.
  • README_EN.md#L172-L181: run tar -xzf only for migration installs.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 181 - 190, Update the installation instructions in
README.md lines 181-190 and README_EN.md lines 172-181 so fresh-install steps do
not run tar extraction for the migration archive. Separate the migration-only
extraction command from the shared directory setup and Docker Compose
build/start steps, preserving equivalent guidance in both languages.
src/server/server.ts-5314-5317 (1)

5314-5317: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Client disconnect does not cancel the in-flight redirect request. Both stream handlers register req.on('close') against the initial proxyReq only. After followRedirect switches to nextReq, a client disconnect leaves the redirected upstream download running and consuming bandwidth. Track the current upstream request in a mutable variable and destroy that request on close.

  • src/server/server.ts#L5314-L5317: store each nextReq in a shared variable inside the WebDAV followRedirect and destroy it in the close handler.
  • src/server/server.ts#L4826-L4828: apply the same change in the OpenList followRedirect.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/server.ts` around lines 5314 - 5317, Update both followRedirect
implementations in src/server/server.ts at lines 5314-5317 and 4826-4828:
maintain a shared mutable reference to the current upstream request, assign each
redirected nextReq to it, and have the req close handler destroy that current
request rather than only the initial proxyReq.
src/server/hostResolver.ts-15-21 (1)

15-21: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Skip comment lines in parseHostsFile.

The function does not strip # comments. For a line such as # host.docker.internal 192.168.1.10, parts is ['#', 'host.docker.internal', '192.168.1.10']. The condition at Line 18 matches and the function returns '#'. resolveHost then builds a URL like http://#:5244, which fails with a confusing error rather than falling through to the gateway probe.

🛡️ Proposed fix
 export const parseHostsFile = (content: string): string => {
   for (const line of content.split('\n')) {
-    const parts = line.trim().split(/\s+/)
-    if (parts.includes('host.docker.internal') && parts[0]) return parts[0]
+    const stripped = line.split('#')[0].trim()
+    if (!stripped) continue
+    const parts = stripped.split(/\s+/)
+    if (parts.includes('host.docker.internal') && parts[0] !== 'host.docker.internal') return parts[0]
   }
   return ''
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/hostResolver.ts` around lines 15 - 21, Update parseHostsFile to
skip lines whose trimmed content begins with # before splitting and matching
host.docker.internal, ensuring commented entries cannot be returned as hostnames
and valid entries still use the existing resolution flow.
src/server/openlist.ts-308-316 (1)

308-316: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Apply the 0-byte cache guard to openlist.serveCacheFile.

webdavMount.serveCacheFile returns false for a 0-byte cache file so the caller falls back to the upstream stream. This function does not. If an OpenList cache file is left at 0 bytes by an interrupted download, this function responds 200 with Content-Length: 0 and playback fails with no recovery until the cache is cleared. The two functions serve the same role, so they should apply the same defense.

🐛 Proposed fix
 export const serveCacheFile = (filePath: string, range: string | undefined, res: any): boolean => {
   if (!fs.existsSync(filePath)) return false
   const stat = fs.statSync(filePath)
+  // 损坏缓存防御:0 字节文件视为无效缓存,回退到上游流式
+  if (stat.size === 0) return false
   const ext = path.extname(filePath).toLowerCase()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/openlist.ts` around lines 308 - 316, Update serveCacheFile to
return false when the cached file’s stat.size is 0, before constructing or
sending the response, so callers fall back to the upstream stream while
preserving normal handling for non-empty cache files.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/server/server.ts`:
- Around line 1863-1872: Update the login handler around the loginHeaders
construction at src/server/server.ts:1863-1872 to widen its header type to
Record<string, string | string[]> and assign the cookie array directly to
Set-Cookie instead of joining it. Apply the same change to the registration
handler around src/server/server.ts:4374-4382 for regHeaders, preserving both
individual cookie values.

In `@src/server/subsonic.ts`:
- Around line 560-566: Update signInternalStream to use a dedicated
startup-initialized internal stream secret instead of
global.lx.config['frontend.password'] or any hard-coded fallback. Persist and
restore this secret with server state, fail closed when it is uninitialized, and
canonicalize serverId/filePath/expiry so delimiters such as | cannot create
ambiguous payloads; ensure the corresponding verifier in the server flow uses
the same secret and canonical payload.

In `@src/server/webdavMount.ts`:
- Around line 497-525: Replace the manual data handling in the response download
flow with resp.pipe(ws) to apply backpressure, and handle completion via the
write stream’s finish event instead of resp.on('end')/ws.end(). Add an explicit
ws.on('error') handler that destroys or cleans up the response and temporary
file, then resolves the operation as failed; retain the existing successful
rename and cache-finalization logic in the finish path.

---

Major comments:
In `@Dockerfile`:
- Around line 15-17: Make the migrated config.js available at the path consumed
by the container: in Dockerfile lines 15-17, retain or relocate the runtime
configuration; in docker-compose.yml lines 32-34, mount the migrated
configuration or set CONFIG_PATH to its mounted location; in
scripts/migrate-to-nas.sh lines 18-53, package config.js at that same path; and
document the selected mount and configuration path in README.md lines 174-200
and README_EN.md lines 165-191. Ensure all five files use one consistent runtime
configuration location.

In `@public/music/js/local_music.js`:
- Around line 49-56: Declare the missing WebDAV panel state alongside the
existing OpenList state: initialize wmServers, wmCurrentServerId, wmCurrentPath,
wmBreadcrumb, wmItems, and wmPanelInitialized with appropriate empty defaults so
wmAddCurrentDirToPlaylist and wmPlayAudio can safely call wmItems.filter before
mount selection. Also initialize olSearchKeyword for loadOlList to read.

In `@public/music/js/openlist_manager.js`:
- Around line 260-266: Update refreshCacheBadges to call renderList(true)
instead of renderList(false), ensuring the complete listing replaces the
existing content rather than being appended as duplicates. Preserve the existing
badge refresh flow and its changed-state termination behavior.
- Around line 320-325: Update _fullPath and its callers so search results
resolve against each audio item’s parent directory instead of always using the
root path. Pass the relevant item into _fullPath for playback, download, and
cache-check requests, use item.parent when available, and preserve currentPath
behavior for non-search paths.

In `@public/music/login.html`:
- Around line 227-231: Remove persistence of lx_sync_pass and lx_user_token from
both login.html sites at lines 227-231 and 278-282, covering the success paths
after login and registration. Migrate any token consumers to the existing
authenticated session flow while preserving username and mode storage as
appropriate.

In `@src/server/cards.ts`:
- Around line 22-39: Update loadCards and saveCards to fail closed: propagate
read, parse, and write errors instead of replacing invalid or unreadable state
with [] or suppressing failures. In saveCards, write the serialized cards to a
temporary file and atomically rename it to cardsPath() only after the write
succeeds, preserving card state when persistence fails.

In `@src/server/openlist.ts`:
- Around line 478-484: The in-flight index scan promises remain cached after
rejection, permanently poisoning remote-source lookups. In
src/server/openlist.ts#L478-L484, update the getLocalIndex collection chain
around collectAudioFiles to catch failures, delete
localIndexCache[serverId].pending only when it still references that promise,
log the error, and resolve with an empty array; apply the identical cleanup and
fallback in src/server/webdavMount.ts#L282-L288 for
localIndexCache[mountId].pending.

In `@src/server/server.ts`:
- Around line 4448-4454: Update the cache-clear route authorization to call
requireAdminAuth() directly instead of relying on requirePlayerOrAdmin() and
comparing its returned username to 'admin'. Preserve the existing denial
behavior while ensuring a registered account named 'admin' cannot satisfy the
administrator check.
- Around line 4326-4361: In the /api/auth/register POST handler, move the
player.enableRegister check before username existence validation so closed
registration never reveals whether an account exists. Reorder cards.consumeCard
to run only after checkAndCreateDir and saveUsers successfully persist the new
user, preserving the existing card-error response behavior.
- Around line 4459-4475: Update verifyInternalStreamToken to eliminate the
hardcoded fallback secret: use a securely generated per-process or persisted
dedicated stream secret, and reject sst tokens when no secret is configured.
Bind tokens to the requesting user where available and enforce a short expiry
window, preserving constant-time signature validation.
- Around line 4794-4807: Update the completion condition in the OpenList
response end handler around cacheWs.end so temporary files are renamed only when
cacheReceived is greater than zero and the existing total-length condition is
satisfied. Preserve the current cleanup and openlist.clearCache behavior for
zero-byte or incomplete downloads, matching the guard used by the WebDAV stream
handler.
- Around line 5563-5567: Remove both synchronous fs.appendFileSync calls from
the search route’s success and catch paths, and replace them with the existing
logger while preserving the search result and error-handling behavior. Use the
logger to record the search context and failure details without writing user
queries to debug.txt.

In `@src/server/subsonic.ts`:
- Line 584: The handleStream flow should not await a cold getAllLocalIndex scan
before issuing the stream redirect. Change the index lookup around
getAllLocalIndex to serve the existing stale cache while triggering any refresh
asynchronously, or resolve the requested path directly from songmid; preserve
normal cached lookups while ensuring the first-track request is not blocked by
collectAudioFiles.
- Around line 2055-2067: Redact the sst query parameter before the dispatcher
access log in server.ts records req.url, while preserving other URL components
and existing access-log behavior. Update the relevant request-logging flow to
log a sanitized URL, and reduce the signed-token validity window from its
current 600 seconds where that token lifetime is configured.
- Around line 615-626: Update the local cache lookup around getUserDirname and
cacheRoot to resolve the configured FileCache base using the same cache-location
logic as serveCacheFile, checking active cache paths under both cache and music
as applicable. Do not derive the root solely from process.cwd() plus
music/cache; reuse setCacheLocation or the established cache-location helper so
local Subsonic streams locate cached tracks correctly.

In `@src/server/webdavMount.ts`:
- Around line 396-409: Set a socket timeout on the requests created by both
WebDAV stream and openlist.stream, and destroy the request with an error when
the timeout fires so the existing downloadToCache error handler settles its
promise. Preserve the current request setup and error propagation while ensuring
stalled connections cannot remain pending indefinitely.
- Around line 71-84: Validate mount IDs in normalizeMount and in getCacheDir,
clearCache, and cacheStatus before using them in filesystem paths, restricting
them to a safe character set that excludes path separators, traversal
components, and other unsafe input. Preserve valid configured/generated IDs, and
reject or safely handle invalid caller-supplied IDs consistently across creation
and cache operations.
- Around line 149-166: Update listFiles so a timeout from Promise.race is
distinguished from a genuinely empty directory: reject or throw a timeout error
when timeoutMs elapses, abort/cancel the pending getDirectoryContents request,
and let the existing catch return an error alongside empty items. Preserve
successful empty-directory results as { items: [] }.

In `@src/utils/webdavSync.ts`:
- Around line 209-234: Update listRemoteFiles to propagate an explicit
incomplete-listing state when LIST_DEPTH_LIMIT truncates recursion or
getDirectoryContents fails, while preserving empty results for genuinely empty
directories. In restoreFromRemote, detect that state and skip the “missing on
cloud” consistency-upload step; only perform remoteFileSet-based uploads after a
complete listing.

---

Minor comments:
In `@README.md`:
- Around line 181-190: Update the installation instructions in README.md lines
181-190 and README_EN.md lines 172-181 so fresh-install steps do not run tar
extraction for the migration archive. Separate the migration-only extraction
command from the shared directory setup and Docker Compose build/start steps,
preserving equivalent guidance in both languages.

In `@src/server/hostResolver.ts`:
- Around line 15-21: Update parseHostsFile to skip lines whose trimmed content
begins with # before splitting and matching host.docker.internal, ensuring
commented entries cannot be returned as hostnames and valid entries still use
the existing resolution flow.

In `@src/server/openlist.ts`:
- Around line 308-316: Update serveCacheFile to return false when the cached
file’s stat.size is 0, before constructing or sending the response, so callers
fall back to the upstream stream while preserving normal handling for non-empty
cache files.

In `@src/server/server.ts`:
- Around line 5314-5317: Update both followRedirect implementations in
src/server/server.ts at lines 5314-5317 and 4826-4828: maintain a shared mutable
reference to the current upstream request, assign each redirected nextReq to it,
and have the req close handler destroy that current request rather than only the
initial proxyReq.

---

Nitpick comments:
In @.monkeycode/specs/webdav-mount-cache/design.md:
- Around line 103-116: Update the route table to reflect the flat endpoints
implemented in server.ts: use /api/webdav-mounts with POST, PUT, and DELETE,
documenting that the id is supplied in the JSON body, and replace :id/test,
:id/browse, and :id/local-list with their query-parameter-based routes,
including browse as /api/webdav-mounts/browse?server=&path= and the
corresponding implemented parameters for testing and local-list.

In `@public/app.js`:
- Line 2022: Replace the native confirm() calls in all three new delete paths
with the shared showSelect(...) confirmation flow, matching the existing
implementations in deletePlaylist and deleteUser. Preserve each path’s current
deletion behavior and ensure deletion proceeds only after the shared dialog is
confirmed.
- Around line 2100-2102: Update the inline onclick handlers generated in the
list template to pass this.escapeHtml(s.id) instead of the raw s.id for
testOpenList, editOpenList, and deleteOpenList; apply the same escaping to m.id
in the corresponding handlers around the other referenced location, preserving
the existing method calls and template structure.

In `@public/music/app.js`:
- Around line 11993-12018: Update showInputModal to prevent interpolated title,
message, f.label, f.placeholder, and f.value from being inserted into innerHTML
unescaped. Apply consistent HTML escaping before interpolation, or construct the
input elements with DOM APIs and assign their value properties, while preserving
the existing modal structure and behavior.
- Around line 9831-9874: Remove the duplicated OpenList and WebDAV field
assignments from their switch cases, leaving those cases to set the id and any
necessary source selection. Keep the fallback blocks after the switch, widening
their conditions to include both the source flags and source values: s.openlist
|| source === 'openlist' and s.webdav || source === 'webdav'.

In `@public/music/js/local_music.js`:
- Around line 1291-1317: Update refreshWmCacheBadges to issue cache-check
fetches concurrently with Promise.all instead of awaiting each audio
sequentially, while preserving per-request error handling and badge generation.
Capture the directory/server context before requests begin and only apply
changed badges if that context still matches the current view, preventing stale
responses from overwriting badges after navigation.
- Around line 942-1009: Extract the duplicated directory-list rendering logic
from renderOlList and renderWmList into one parameterized helper, using a
per-source configuration for element IDs, directory-field name, and accent color
while preserving existing behavior. Reuse the helper for both source-specific
render methods, including shared audio filtering, folder/lyric partitioning,
empty state, navigation row, and append/reset handling. Apply the same
deduplication pattern to loadOlServers/loadWmServers, olGoTo/wmGoTo,
olGoBack/wmGoBack, and olBuildSong/wmBuildSong, keeping source-specific
differences in configuration rather than duplicated logic.

In `@public/music/js/openlist_manager.js`:
- Around line 229-234: Remove the unused index plumbing in the audio rendering
loop: update the audios.forEach callback and eliminate the globalIndex alias,
then change the playAudio call and its definition to omit the audioIndex
parameter while preserving the existing filename-based index lookup. Apply the
same call-site update to the additional location around the other audio list
rendering.

In `@src/server/openlist.ts`:
- Around line 130-132: Update the needle.request call to pass data directly
instead of using the redundant isDownload ? data : data expression, while
preserving the existing payload behavior for downloads and non-downloads.
- Around line 550-579: Update the download request in the surrounding try block
and the upload request options used by needle.put to use finite open_timeout,
response_timeout, and read_timeout values instead of timeout: 0. Apply the same
appropriate timeout configuration to both needle.get(sourceUrl, ...) and the PUT
options, preserving the existing streaming and error-handling behavior.

In `@src/server/server.ts`:
- Around line 5746-5810: The WebDAV/OpenList fallback in the server handler
performs an unbounded getLocalIndex scan across every candidate when serverId is
missing. Replace this per-request probing with a short-lived path-to-mount
cache, or resolve the mount exclusively from persisted song metadata, so
playback does not trigger multiple recursive remote scans; preserve the existing
stream URL response once a mount is resolved.

In `@src/server/webdavMount.test.ts`:
- Line 376: Remove the unused crypto import and the standalone void crypto
statement from the webdavMount test, leaving the existing tests unchanged.
- Around line 323-334: Extend the test around serveCacheFile to create a
zero-byte cache file, invoke wm.serveCacheFile with it, and assert it returns
false so the upstream fallback is exercised. Also add downloadToCache
rejection-path coverage for upstream 4xx, 3xx responses with Location, and empty
200 bodies, asserting each removes both the temporary file and final cache file.
- Around line 101-104: Move the mock-server startup from the first test into a
before hook for the webdavMount 边播边缓存 describe block, initializing the shared
port before any tests construct baseUrl. Remove the now-unnecessary mid-file
after import while preserving the existing server cleanup behavior.
- Around line 212-230: Strengthen the three tests around getLocalIndex,
getAllLocalIndex, and clearLocalIndex: track mock-server PROPFIND requests and
assert the second TTL-cached getLocalIndex call does not increase the count;
create distinct enabled mounts and assert getAllLocalIndex contains entries from
both indexes rather than merely being an array; after clearLocalIndex, assert
the subsequent getLocalIndex performs a fresh scan or otherwise differs from the
cached result to prove the cache entry was removed.

In `@src/server/webdavMount.ts`:
- Around line 535-553: Update cacheStatus and its recursive scan to avoid
synchronous filesystem operations that block the event loop: make them
asynchronous, use fs.promises.readdir with withFileTypes and fs.promises.stat,
await recursive scans and stats, and return the same { fileCount, size } result
while preserving mountId filtering and .tmp exclusion.
- Around line 124-130: Remove the unused force parameter from initClient and
update all callers, including listFiles and getLyric, to use its simplified
signature. Cache clients by mount id so repeated requests reuse the same WebDAV
client, and invalidate the corresponding cache entry in updateMount and
deleteMount.

In `@src/utils/webdavSync.ts`:
- Around line 35-42: Extract the duplicated normalizeWebdavUrl function into a
shared utility module, then remove the local definitions and import the shared
symbol in both webdavSync and webdavMount. Preserve the existing trimming,
protocol detection, and http:// fallback behavior exactly.
🪄 Autofix

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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0f69d08a-979e-4a92-b186-a158c6d1047d

📥 Commits

Reviewing files that changed from the base of the PR and between 64f4bf2 and 25bd8f1.

📒 Files selected for processing (33)
  • .github/workflows/docker.yml
  • .gitignore
  • .monkeycode/MEMORY.md
  • .monkeycode/specs/webdav-mount-cache/design.md
  • .monkeycode/specs/webdav-mount-cache/requirements.md
  • .monkeycode/specs/webdav-mount-cache/tasklist.md
  • Dockerfile
  • README.md
  • README_EN.md
  • config.js
  • docker-compose.yml
  • public/app.js
  • public/index.html
  • public/js/config.js
  • public/music/app.js
  • public/music/index.html
  • public/music/js/local_music.js
  • public/music/js/openlist_manager.js
  • public/music/login.html
  • scripts/migrate-to-nas.sh
  • src/defaultConfig.ts
  • src/index.ts
  • src/server/cards.ts
  • src/server/fileCache.ts
  • src/server/hostResolver.test.ts
  • src/server/hostResolver.ts
  • src/server/openlist.ts
  • src/server/server.ts
  • src/server/subsonic.ts
  • src/server/webdavMount.test.ts
  • src/server/webdavMount.ts
  • src/types/config.d.ts
  • src/utils/webdavSync.ts
💤 Files with no reviewable changes (1)
  • config.js

Comment thread src/server/server.ts
Comment on lines +1863 to +1872
const loginHeaders: Record<string, string> = { 'Content-Type': 'application/json' }
if (global.lx.config['player.forceLogin']) {
const sessionId = generateSessionId()
playerSessions.set(sessionId, { createdAt: Date.now() })
const cookies: string[] = []
cookies.push(`${SESSION_COOKIE_NAME}=${sessionId}; HttpOnly; Path=/; SameSite=Strict; Max-Age=${SESSION_TTL / 1000}`)
cookies.push(`${USER_TOKEN_COOKIE_NAME}=${token}; Path=/; SameSite=Lax; Max-Age=${USER_SESSION_TTL / 1000}`)
loginHeaders['Set-Cookie'] = cookies.join(', ')
}
res.writeHead(200, loginHeaders)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Comma-joined Set-Cookie header in the login and registration handlers. Both handlers build two cookies and join them with , into one header value. Browsers do not split Set-Cookie on commas, so lx_player_user_token is never stored and the user-token branch of checkPlayerAuth never matches. Pass an array of cookie strings instead.

  • src/server/server.ts#L1863-L1872: assign loginHeaders['Set-Cookie'] = cookies and widen the header type to Record<string, string | string[]>.
  • src/server/server.ts#L4374-L4382: assign regHeaders['Set-Cookie'] = cookies and widen the header type the same way.
📍 Affects 1 file
  • src/server/server.ts#L1863-L1872 (this comment)
  • src/server/server.ts#L4374-L4382
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/server.ts` around lines 1863 - 1872, Update the login handler
around the loginHeaders construction at src/server/server.ts:1863-1872 to widen
its header type to Record<string, string | string[]> and assign the cookie array
directly to Set-Cookie instead of joining it. Apply the same change to the
registration handler around src/server/server.ts:4374-4382 for regHeaders,
preserving both individual cookie values.

Comment thread src/server/subsonic.ts
Comment on lines +560 to +566
private signInternalStream(serverId: string, filePath: string): string {
const secret = global.lx.config['frontend.password'] || 'lxserver-internal'
const expiry = Date.now() + 600 * 1000
const payload = `${serverId}|${filePath}|${expiry}`
const sig = crypto.createHmac('sha256', secret).update(payload).digest('base64url')
return `${sig}.${expiry}`
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Do not fall back to a hard-coded HMAC secret.

Line 561 falls back to the literal 'lxserver-internal' when frontend.password is unset or empty. That constant is in the public source. Any deployment that has not set frontend.password uses a key that every reader of this repository knows.

The sst token is the only credential that authorizes /api/openlist/stream and /api/webdav-mounts/stream for a caller with no session cookie. With a known key, an unauthenticated attacker forges a valid sst for any serverId and any filePath. The server then fetches that path from the WebDAV or OpenList mount using its own stored credentials and streams the bytes back. This bypasses both the Subsonic u/p check and the session check.

Two further weaknesses in the same function:

  • The key is a user-facing password. It has low entropy and it rotates whenever an administrator changes the password, which silently invalidates issued tokens.
  • Line 563 joins fields with an unescaped |. A filePath that contains | makes the payload ambiguous, so distinct (serverId, filePath) pairs can produce the same signed string.

Generate a dedicated random secret at startup, persist it with the other server state, and fail closed if it is missing.

🛡️ Proposed fix
     private signInternalStream(serverId: string, filePath: string): string {
-        const secret = global.lx.config['frontend.password'] || 'lxserver-internal'
+        const secret = getInternalStreamSecret() // 启动时生成的随机密钥,缺失则抛错
         const expiry = Date.now() + 600 * 1000
-        const payload = `${serverId}|${filePath}|${expiry}`
+        // 使用 JSON 规范化,避免 filePath 含分隔符时载荷歧义
+        const payload = JSON.stringify([serverId, filePath, expiry])
         const sig = crypto.createHmac('sha256', secret).update(payload).digest('base64url')
         return `${sig}.${expiry}`
     }

Add the secret helper next to the other server state, for example:

// src/server/internalStreamToken.ts
import * as crypto from 'crypto'
let secret = ''
export const initInternalStreamSecret = (persisted?: string): string => {
  secret = persisted || crypto.randomBytes(32).toString('hex')
  return secret
}
export const getInternalStreamSecret = (): string => {
  if (!secret) throw new Error('internal stream secret not initialized')
  return secret
}

Update the verifier in src/server/server.ts to use the same helper and the same canonical payload, and compare with crypto.timingSafeEqual.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/subsonic.ts` around lines 560 - 566, Update signInternalStream to
use a dedicated startup-initialized internal stream secret instead of
global.lx.config['frontend.password'] or any hard-coded fallback. Persist and
restore this secret with server state, fail closed when it is uninitialized, and
canonicalize serverId/filePath/expiry so delimiters such as | cannot create
ambiguous payloads; ensure the corresponding verifier in the server flow uses
the same secret and canonical payload.

Comment thread src/server/webdavMount.ts
Comment on lines +497 to +525
const ws = fs.createWriteStream(tmpPath, { flags: 'w' })
resp.on('data', (chunk: any) => {
received += chunk.length
ws.write(chunk)
trackCacheProgress(mount.id, filePath, total, received)
if (onProgress) onProgress(received, total)
})
resp.on('end', () => {
ws.end(() => {
// 上游无 Content-Length(total=0)时,仍要求至少收到数据才落盘;
// 空响应(received=0)视为无效,清理临时文件避免产生空缓存
if (received > 0 && (total === 0 || received >= total)) {
try { fs.renameSync(tmpPath, cacheFilePath) } catch (e) {
try { fs.unlinkSync(tmpPath) } catch (e2) { /* ignore */ }
}
markCacheDone(mount.id, filePath)
cleanup(true)
resolve(true)
} else {
cleanup(false)
resolve(false)
}
})
})
resp.on('error', () => {
ws.destroy()
cleanup(false)
resolve(false)
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Handle write-stream errors and apply backpressure.

Two problems in this block:

  1. No error handler is attached to ws. If the write fails (for example ENOSPC or a permission error), the WriteStream emits error with no listener. Node throws an uncaught exception and the process exits. resp.on('error') at Line 521 does not cover write failures.
  2. Line 500 calls ws.write(chunk) and ignores the return value. There is no resp.pause() / drain handling. If the WebDAV source is faster than the disk, chunks accumulate in the write buffer. A large FLAC file can grow the heap without bound.

Using resp.pipe(ws) fixes the backpressure problem, and an explicit ws.on('error') fixes the crash.

🐛 Proposed fix
       total = parseInt(resp.headers['content-length'] || '0', 10)
       trackCacheProgress(mount.id, filePath, total, 0)
       const ws = fs.createWriteStream(tmpPath, { flags: 'w' })
+      let failed = false
+      ws.on('error', (e) => {
+        failed = true
+        console.error('[WebDAVMount] cache write failed:', e)
+        resp.destroy()
+        cleanup(false)
+        resolve(false)
+      })
       resp.on('data', (chunk: any) => {
         received += chunk.length
-        ws.write(chunk)
         trackCacheProgress(mount.id, filePath, total, received)
         if (onProgress) onProgress(received, total)
       })
+      resp.pipe(ws)
       resp.on('end', () => {
-        ws.end(() => {
+        if (failed) return
+        ws.once('finish', () => {
           // 上游无 Content-Length(total=0)时,仍要求至少收到数据才落盘;
           // 空响应(received=0)视为无效,清理临时文件避免产生空缓存
           if (received > 0 && (total === 0 || received >= total)) {

resp.pipe(ws) ends ws automatically when resp ends, so wait for finish instead of calling ws.end().

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const ws = fs.createWriteStream(tmpPath, { flags: 'w' })
resp.on('data', (chunk: any) => {
received += chunk.length
ws.write(chunk)
trackCacheProgress(mount.id, filePath, total, received)
if (onProgress) onProgress(received, total)
})
resp.on('end', () => {
ws.end(() => {
// 上游无 Content-Length(total=0)时,仍要求至少收到数据才落盘;
// 空响应(received=0)视为无效,清理临时文件避免产生空缓存
if (received > 0 && (total === 0 || received >= total)) {
try { fs.renameSync(tmpPath, cacheFilePath) } catch (e) {
try { fs.unlinkSync(tmpPath) } catch (e2) { /* ignore */ }
}
markCacheDone(mount.id, filePath)
cleanup(true)
resolve(true)
} else {
cleanup(false)
resolve(false)
}
})
})
resp.on('error', () => {
ws.destroy()
cleanup(false)
resolve(false)
})
const ws = fs.createWriteStream(tmpPath, { flags: 'w' })
let failed = false
ws.on('error', (e) => {
failed = true
console.error('[WebDAVMount] cache write failed:', e)
resp.destroy()
cleanup(false)
resolve(false)
})
resp.on('data', (chunk: any) => {
received += chunk.length
trackCacheProgress(mount.id, filePath, total, received)
if (onProgress) onProgress(received, total)
})
resp.pipe(ws)
resp.on('end', () => {
if (failed) return
ws.once('finish', () => {
// 上游无 Content-Length(total=0)时,仍要求至少收到数据才落盘;
// 空响应(received=0)视为无效,清理临时文件避免产生空缓存
if (received > 0 && (total === 0 || received >= total)) {
try { fs.renameSync(tmpPath, cacheFilePath) } catch (e) {
try { fs.unlinkSync(tmpPath) } catch (e2) { /* ignore */ }
}
markCacheDone(mount.id, filePath)
cleanup(true)
resolve(true)
} else {
cleanup(false)
resolve(false)
}
})
})
resp.on('error', () => {
ws.destroy()
cleanup(false)
resolve(false)
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/webdavMount.ts` around lines 497 - 525, Replace the manual data
handling in the response download flow with resp.pipe(ws) to apply backpressure,
and handle completion via the write stream’s finish event instead of
resp.on('end')/ws.end(). Add an explicit ws.on('error') handler that destroys or
cleans up the response and temporary file, then resolves the operation as
failed; retain the existing successful rename and cache-finalization logic in
the finish path.

XCQ0607 added 8 commits August 8, 2026 04:26
- resolveLocalStreamUrl 匹配索引时做路径归一化(URL 解码、合并连续斜杠),
  解决收藏时固化的双斜杠路径(如 /音乐//收藏/)与实时索引单斜杠路径不匹配的问题
- 新增 resolveFromUserList 兜底:实时索引匹配失败时,直接复用歌单中固化的
  内部流 url(Web 端已验证可播),并追加 sst 令牌供 Subsonic 客户端无 cookie 播放
箭头音乐等 Subsonic 客户端播放前会用 HEAD /stream 探测资源,
302 后跟随的 HEAD 请求此前因路由仅匹配 GET 而返回 404。
现在 /api/openlist/stream 与 /api/webdav-mounts/stream 同时接受 HEAD,
返回与 GET 一致的响应头(Content-Type/Content-Length)。
- handleGetLyricsBySongId 对 webdav/openlist 源走同目录 .lrc 歌词读取
- 抽取公共 buildLyricResponse,复用 mergedLrc 双行与 structuredLyrics 输出
- resolveMountedLyric 支持索引匹配 + 歌单固化 url 兜底多候选路径
- 歌词文本编码探测:UTF-8 严格解码失败回退 GB18030(远端 GBK .lrc)
- openlist 改用原生 httpGetBuffer 获取原始字节,避免 needle 解码损坏
- TextDecoder('gb18030') 在 Alpine node(small-icu)不受支持,GBK .lrc 解码回退 utf-8 导致乱码
- 改为 iconv-lite 的 gb18030 解码(纯 JS 实现,无 ICU 依赖),openlist 与 webdav 两处统一
- package.json / config.js / README 徽章 / docker-compose 镜像 tag 同步至 v3.0.1
- changelog 新增 v3.0.1 条目(挂载、播放修复、歌词、卡密注册等)
- 主页登录页与仪表盘展示 v3.0.1 新增功能公告
- requirements.md: Web 播放端仅管理员、有效期管理(7/30/365/永久)、30天活跃≥5分钟自动续期、N天无活跃自动封禁、TG 卡密注册/绑定/改密/线路/歌单自动下载
- design.md: 新增 userAccount.ts / telegramBot.ts(grammY) / playlistParser.ts,扩展 users.json 账号字段,Subsonic 与会话接入到期封禁校验
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.

2 participants