diff --git a/.dockerignore b/.dockerignore index f23b5894..ca606eca 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,3 +6,4 @@ logs lx-music-desktop-master dist-electron build +downloads diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index df608a20..914a9bfc 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -7,8 +7,6 @@ env: REGISTRY: ghcr.io # 使用 github.repository_owner 获取用户名,并指定镜像名为 lxserver IMAGE_NAME: ${{ github.repository_owner }}/lxserver - # Docker Hub 仓库名 - DOCKER_HUB_REPO: ${{ secrets.DOCKER_USERNAME }}/lxserver jobs: build_and_push: @@ -33,13 +31,7 @@ jobs: with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} + password: ${{ secrets.PACKAGES_TOKEN }} # Extract project version from config.js - name: Read version from config @@ -64,7 +56,6 @@ jobs: with: images: | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - ${{ env.DOCKER_HUB_REPO }} tags: | type=raw,value=latest type=raw,value=${{ env.PROJECT_VERSION }} @@ -96,7 +87,7 @@ jobs: fi done env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.PACKAGES_TOKEN }} continue-on-error: true - name: Build and push Docker image diff --git a/.gitignore b/.gitignore index e430177a..475a5fac 100644 Binary files a/.gitignore and b/.gitignore differ diff --git a/.monkeycode/MEMORY.md b/.monkeycode/MEMORY.md new file mode 100644 index 00000000..35643a60 --- /dev/null +++ b/.monkeycode/MEMORY.md @@ -0,0 +1,78 @@ +# User Instruction Memory + +This file records user instructions, preferences, and teachings for reference in future interactions. + +## Format + +### User Instruction Entry +User instruction entries should follow this format: + +[User Instruction Summary] +- Date: [YYYY-MM-DD] +- Context: [Mentioned scenario or time] +- Instructions: + - [Content of user teaching or instruction, described line by line] + +### Project Knowledge Entry +Entries discovered by the Agent during task execution should follow this format: + +[Project Knowledge Summary] +- Date: [YYYY-MM-DD] +- Context: Discovered by Agent while performing [specific task description] +- Category: [Operations & Deployment|Build Methods|Testing Methods|Troubleshooting & Debugging|Workflow & Collaboration|Environment Configuration] +- Instructions: + - [Specific knowledge points, described line by line] + +## Deduplication Strategy +- Before adding a new entry, check for similar or identical instructions. +- If a duplicate is found, skip the new entry or merge it with the existing one. +- When merging, update the context or date information. +- This helps avoid redundant entries and keeps the memory file tidy. + +## Entries + +[Project Knowledge Summary] +- Date: 2026-08-01 +- Context: Discovered by Agent while building and deploying lxserver music sync server +- Category: Operations & Deployment +- Instructions: + - Build: `npm run build`(prebuild 会自动下载 fpcalc 二进制并更新 build hash 到 config.js) + - Start: `npm start`,服务器监听 `0.0.0.0:9527`;开发时用 background terminal 启动,避免阻塞 + - 管理员后台入口 `/`,前端密码(`frontend.password`)默认 `123456`;用户密码登录播放器 + - 测试账号:admin/password(管理员)、testuser/123456;管理员鉴权头 `X-Frontend-Auth: ` + - 强制登录开启时(player.forceLogin),播放器静态资源未登录会 302 到 `/music/login`;登录接口 `/api/user/login` 同时下发 `lx_player_session` 与 user token cookie + - 卡密与阿里云盘配置分别持久化在 dataPath 下的 `cards.json`、`alidrive.json`,需配置 ClientID/ClientSecret 并在后台扫码绑定后才能使用云盘功能 + +[Project Knowledge Summary] +- Date: 2026-08-03 +- Context: Discovered by Agent while fixing OpenList 播放卡死问题 +- Category: Troubleshooting & Debugging +- Instructions: + - **needle 3.x 流式下载 bug**:`needle.get()` 在响应约 130KB(130896 字节)后会卡死不再输出数据,导致 `openlist.stream` 代理播放几秒就卡死。修复:改用 Node 原生 `http/https.request`(`src/server/openlist.ts` 的 `stream` 函数)。任何新的流式代理代码禁止使用 needle 转发大文件。 + - OpenList 播放已支持"边播边缓存":首次播放把数据同时写入 `/openlist-cache//.ext`,完整后 rename 落盘,之后播放/拖拽直接读本地(秒开)。缓存状态接口:`/api/openlist/cache/check`(单文件)、`/api/openlist/cache/status`(汇总)、`/api/openlist/cache/clear`(管理员)。前端 openlist_manager 会显示"已缓存/缓存中"徽标。 + - 真实 OpenList 上游速度实测约 360KB/s-1.3MB/s(此前 needle 卡死误判为上游限速 4KB/s),足够流畅播放。 + - config.js 含真实凭据,不进入 git 提交;NAS 部署用 `scripts/migrate-to-nas.sh` 生成清洗后的部署包。 + - OpenList `/d/` 直链会 302 到对象存储/CDN(如阿里云盘 OSS 签名 URL),代理必须服务端跟随重定向(最多 5 跳),否则播放器拿到无 Location 的 302 无法播放。OSS 签名 URL 可直接访问,无需转发 Authorization。 + - 远程目录树扫描必须加防护:单目录 listFiles 加 20s 超时(needle 对超大目录可能永久挂起)、整体 60s 截止、目录数上限 800、子目录并发 6,否则真实 OpenList(含大量网盘挂载)递归扫描会把进程拖死。 + - 本地音乐整合 OpenList:`/api/openlist/local-list?server=&refresh=` 递归扫描生成索引(TTL 120s);`/api/music/cache/list` 后端合并 folder='openlist' 条目;前端 local_music.js 过滤 tab 加 openlist 选项,内嵌目录树面板(`lm-ol-*` 元素 + LocalMusicManager.ol* 方法),收藏走 openlist 字段(url/serverId/path/sign)恢复播放。 + +[Project Knowledge Summary] +- Date: 2026-08-05 +- Context: Discovered by Agent while implementing WebDAV 音乐挂载功能(边播边缓存+目录歌单) +- Category: Build Methods / Troubleshooting & Debugging +- Instructions: + - 测试框架:node:test + `npx tsx --test `(项目无 vitest/jest);mock WebDAV 服务器须注意 PROPFIND 目录 key 去尾部斜杠归一化、响应过滤 `.`/`..`、GET 支持 Range。 + - 新增 `src/server/webdavMount.ts`(挂载源 CRUD 持久化 webdav-mounts.json、密码脱敏 hasPassword、目录扫描防护同 openlist、边播边缓存 .tmp->rename、本地 Range 206/416);`subsonic.ts handleStream` 对 webdav_/openlist_/local source 走内部流 302,由内部流路由统一承担缓存优先(避免重复实现)。 + - `stream` 返回同步 ClientRequest,server.ts 路由用 try/catch 包裹而非 `.then()`(误用 `.then` 会 TS2339)。 + - 服务启动时 config.js 的 webdav.* 备份 restore 会阻塞监听(当前环境 host.docker.internal 不可达,需等网络超时约 2-3 分钟才完成启动);冒烟测试前先 curl 首页确认 200。 + - 构建/推送镜像:`docker build -t lxserver:webdav . && docker tag ... ghcr.io/boy6656598/lxserver:latest && docker push`;容器内产物路径为 `/server/server/server/*.js`(根目录是 `/server` 非 `/app`)。 + +[Project Knowledge Summary] +- Date: 2026-08-09 +- Context: Discovered by Agent while implementing 挂载歌曲同目录 .lrc 歌词读取(Subsonic getLyricsBySongId) +- Category: Troubleshooting & Debugging +- Instructions: + - needle 3.x 始终将 body 解码为 string(即使 `decode_response:false`/`output:buffer`),GBK 编码的 .lrc 经 needle 读取后字节已损坏为 U+FFFD,无法恢复。读取远端歌词/文本必须用原生 `http/https.get` 拿原始 Buffer(openlist.ts 的 `httpGetBuffer`)。 + - 远端 .lrc 常见 GBK/GB18030 编码;解码策略:先 `new TextDecoder('utf-8',{fatal:true})` 严格解码,异常或含 `\uFFFD` 则回退 `iconv-lite` 的 gb18030。**禁止用 `TextDecoder('gb18030')`**:Docker 镜像(Alpine node,small-icu)不支持该编码,会回退 utf-8 导致乱码;iconv-lite 为纯 JS 实现且已随镜像打包,可用。 + - Subsonic 歌词端点:`handleGetLyricsBySongId` 对 `webdav`/`openlist` 源不查在线 SDK(musicSdk 无此二源,会返回错误 70),改为读歌曲同目录同名 .lrc;`resolveMountedLyric` 先按索引匹配、失败再兜底解析歌单固化 url 的 server/path/sign,多候选路径逐个尝试。 + - webdav 索引 path 为相对 baseUrl 的路径(baseUrl 已指向挂载根);openlist 索引 path 为含 rootPath 的完整 fs 路径,二者语义不同。 diff --git a/.monkeycode/specs/public-user-access-and-telegram-bot/design.md b/.monkeycode/specs/public-user-access-and-telegram-bot/design.md new file mode 100644 index 00000000..af5338b1 --- /dev/null +++ b/.monkeycode/specs/public-user-access-and-telegram-bot/design.md @@ -0,0 +1,175 @@ +# 技术设计:对外开放用户注册与 TG 机器人 + +Feature Name: public-user-access-and-telegram-bot +Updated: 2026-08-10 + +## Description + +将 lx-music-sync-server 改造为对外开放注册形态: + +1. Web 播放端仅管理员(后台密码 `frontend.password`)可登录,普通用户被拒。 +2. 账号生命周期管理:有效期(7/30/365/永久)、30 天周期活跃 ≥5 分钟自动续期、管理后台手动续期、超期/封禁统一拦截。 +3. 记录并展示用户活跃时间;可配置 N 天无活跃自动封禁。 +4. 新增 TG 机器人:卡密注册、`/bind` 绑定、改密、获取线路、接收歌单(文本/LX JSON/平台链接)并自动下载生成用户收藏歌单。 + +## Architecture + +### 模块总览 + +```mermaid +graph TD + A["管理员 Web 后台"] -->|"x-frontend-auth"| B["server.ts HTTP Router"] + C["TG 用户"] -->|"Telegram Bot API"| D["telegramBot.ts"] + E["Subsonic 客户端"] -->|"/rest"| F["subsonic.ts"] + B --> G["userAccount.ts"] + D --> G + F --> G + G --> H["users.json + 定时任务"] + D --> I["playlistParser.ts"] + I --> J["musicSdk 搜索"] + D --> K["serverDownloadQueue.enqueue"] + K --> L["fileCache.downloadAndCache"] + K --> M["ListManage 生成收藏歌单"] +``` + +### TG 歌单处理数据流 + +```mermaid +graph TD + S1["TG 收到文本/LX JSON/平台链接"] --> P["playlistParser 解析"] + P --> R["对比全服务器本地曲库索引"] + R -->|"缺失歌曲"| Q["搜索解析为 songInfo"] + Q --> DQ["serverDownloadQueue.enqueue"] + DQ --> DL["下载至 /music/username"] + DQ --> UL["ListManage 写入 TG 歌单"] + UL --> R2["TG 回复统计"] +``` + +## Components and Interfaces + +### 1. `src/server/userAccount.ts`(新增,账号状态中枢) + +集中账号生命周期策略,被 server.ts、subsonic.ts、telegramBot.ts 调用。 + +| 接口 | 说明 | +|---|---| +| `initAccountManager()` | 加载扩展字段(兼容旧数据默认值),启动定时任务(每日扫描:自动续期 + 自动封禁) | +| `registerUser(name, password, cardCode, telegramId?)` | 卡密校验 + 创建用户 + 初始化账号字段,返回结果(复用现有注册逻辑) | +| `bindTelegram(name, password, telegramId)` | 校验密码并绑定 TG ID | +| `recordActivity(name)` | 刷新 `lastActiveAt` 并累加活跃秒(节流写入) | +| `checkUserAccess(name): AccessResult` | 返回 `{ok, reason}`,校验 banned / 过期 | +| `renewExpire(name, days)` | 管理后台手动续期 | +| `setExpire(name, expireAt)` | 设置有效期(含永久) | +| `setBanned(name, banned)` | 封禁/解封 | +| `listAccounts(): UserAccount[]` | 返回含扩展字段的用户列表(后台展示用) | + +### 2. `src/server/telegramBot.ts`(新增,grammY 长轮询) + +- 初始化读取 `telegram.botToken` / `telegram.enable`,`bot.start()` 长轮询。 +- 命令路由:`/start`、`/register`、`/bind`、`/changepassword`、`/server`、`/status`。 +- 非命令消息/文档/链接 → 歌单流程。 +- 未绑定用户仅允许 `/start`、`/register`、`/bind`。 +- 所有用户级操作前调用 `userAccount.checkUserAccess`。 + +### 3. `src/server/playlistParser.ts`(新增,歌单解析) + +| 入参 | 行为 | +|---|---| +| 文本 | 按行解析 `歌手 - 歌名`,无分隔符时按整行歌名搜索 | +| LX JSON | 直接读 `list` 数组为 `MusicInfo[]` | +| 平台链接 | 识别网易云/QQ 等域名 → musicSdk 获取歌单歌曲 | + +解析结果统一为待搜索文本列表 → 调 musicSdk 搜索得到 `songInfo[]`。 + +### 4. 本地曲库对比 + +复用现有 `fileCache` 的索引:合并所有用户 `music_index.json` 生成全量 `(歌手 - 歌名)` 集合,按归一化标题匹配,判断缺失。 + +### 5. 下载编排(telegramBot 内) + +- 缺失歌曲 → `serverDownloadQueue.enqueue(username, tasks)`(复用现有 resolver `resolveServerSong`)。 +- 轮询队列状态,全部结束(finished/exists/error)后汇总回复。 +- 生成收藏歌单:调用 `ListManage` 在该用户 `userList` 写入"TG 歌单"。 + +### 6. `server.ts` 改动 + +- `saveUsers` / 注册逻辑:保存并初始化扩展字段(`expireAt/banned/lastActiveAt/activeSeconds/periodStart/telegramId`)。 +- Web 请求活跃记录:在用户已鉴权(`x-user-token` / session cookie)的 API 入口调用 `recordActivity`。 +- 播放端登录拦截:`/api/user/login` 对普通用户返回拒绝(仅管理员后台密码可进入播放端);保留 `/api/music/auth` 校验 `frontend.password`。 +- 管理后台 API 扩展:`GET /api/users` 返回扩展字段;新增设置时长/手动续期/封禁/解封接口;`config` API 支持 `telegram.*`、`user.autoBanInactiveDays`、`server.publicUrl`。 +- 启动时初始化 accountManager 与 telegramBot。 + +### 7. `subsonic.ts` 改动 + +在用户密码认证建立会话处插入 `userAccount.checkUserAccess(username)`,拒绝到期/封禁用户。 + +## Data Models + +### 用户扩展字段(存于 `users.json`) + +```ts +interface UserAccount { + name: string + password: string + expireAt: number | null // null = 永久 + banned: boolean + lastActiveAt: number // 上次活跃时间戳 + activeSeconds: number // 当前周期累计活跃秒 + periodStart: number // 当前续期周期起始时间戳 + telegramId: number | null + dataPath?: string +} +``` + +- 注册:`expireAt = now + card.expireDays`(永久卡为 null),`periodStart = now`,`activeSeconds = 0`,`banned = false`。 +- 活跃续期判定(每日扫描 + 到期拦截时):`now - periodStart >= 30d` 时,若 `activeSeconds >= 300` 则 `expireAt += 30d`;随后 `activeSeconds = 0`,`periodStart = now`。 +- 自动封禁:`autoBanInactiveDays > 0` 且非永久且 `now - lastActiveAt > N 天` → `banned = true`。 + +### 配置新增键(defaultConfig + config.js) + +```ts +'telegram.enable': false, +'telegram.botToken': '', +'user.autoBanInactiveDays': 0, // 0 = 关闭自动封禁 +'server.publicUrl': '', // 对外地址,/server 命令使用 +``` + +## Correctness Properties + +1. 永久账号(`expireAt === null`)SHALL 不受到期拦截、续期与自动封禁影响。 +2. 活跃累加 SHALL 节流(同一用户名每秒最多累计一次差值,单次差值上限 60 秒),防止高频轮询刷时长。 +3. 到期/封禁检查 SHALL 在所有入口(Web 播放端登录、Subsonic 会话、TG 用户命令)一致生效。 +4. `saveUsers` SHALL 保留新增字段,热重载 SHALL 不丢失(加载时缺失字段补默认值)。 +5. 卡密 SHALL 在注册时消耗一次且不可重复使用(沿用 `consumeCard`)。 +6. 管理员(frontend.password)SHALL 不受账号有效期/封禁限制。 + +## Error Handling + +| 场景 | 处理 | +|---|---| +| 卡密无效/已用/过期 | TG `/register` 回复对应错误文案 | +| 用户名重复 | TG `/register` 回复"用户名已存在" | +| 账号到期/封禁 | Subsonic 返回认证失败;TG 用户命令回复"账号已到期/被封禁" | +| TG 歌单无法解析/为空 | 回复解析失败提示,不进入下载 | +| 下载任务部分失败 | 汇总回复统计(总数/已存在/已排队/失败),失败原因附在列表 | +| 未配置 botToken | telegramBot 跳过启动并记录日志,不影响其他功能 | +| users.json 损坏 | 沿用现有兜底(置空数组),扩展字段初始化默认值 | + +## Test Strategy + +1. **单元测试**:`playlistParser`(文本/LX JSON/链接三种格式)、活跃续期判定(30 天周期 + 5 分钟阈值边界)、封禁判定(N 天边界)。 +2. **集成验证**(本地 `node ./index.js` + curl): + - `/api/auth/register` 注册后 `expireAt/banned` 初始化正确,`users.json` 字段齐全。 + - 手动将 `expireAt` 置为过去 → Subsonic 登录失败、`/api/user/login` 拒绝。 + - 活跃记录:模拟请求后 `lastActiveAt/activeSeconds` 更新。 + - TG 命令:使用测试 botToken 验证 `/register`、`/bind`、`/server`、歌单文本下载链路(与现有 openlist/webdav 播放验证一致的本地流程)。 +3. **构建与镜像**:`npm run build` 通过后构建 `ghcr.io/boy6656598/lxserver:3.0.2` 并推送;TG 库 grammY 需在 Alpine 容器可安装运行。 + +## References + +[^1]: (src/server/cards.ts) - 卡密生成/消耗:`consumeCard` L97、`generateCards` L56 +[^2]: (src/server/server.ts) - 注册接口 L4326、`saveUsers` L499、用户管理 `/api/users` L1210 +[^3]: (src/server/serverDownloadQueue.ts) - 下载队列 `enqueue`/`resolver` 注入 L7704 +[^4]: (src/server/fileCache.ts) - `downloadAndCache` L1855、本地索引 `syncCacheIndex` L655 +[^5]: (src/modules/list/manage.ts) - 用户收藏歌单 `ListManage` +[^6]: (src/defaultConfig.ts) - 配置默认值(player.enableRegister L48 等) diff --git a/.monkeycode/specs/public-user-access-and-telegram-bot/requirements.md b/.monkeycode/specs/public-user-access-and-telegram-bot/requirements.md new file mode 100644 index 00000000..20eb18e7 --- /dev/null +++ b/.monkeycode/specs/public-user-access-and-telegram-bot/requirements.md @@ -0,0 +1,134 @@ +# 需求文档:对外开放用户注册与 TG 机器人(卡密注册 / 有效期 / 封禁 / 歌单下载) + +## Introduction + +本项目为 lx-music-sync-server。当前已具备:卡密生成与消耗注册(`/api/auth/register`)、用户管理后台(`/api/users`)、服务器下载队列(`serverDownloadQueue`)、本地音乐索引(`music_index.json`)、用户收藏歌单(`ListManage`)。本需求将其扩展为"对外开放注册"的产品形态: + +- Web 播放端仅管理员可登录进入,普通注册用户被拒绝。 +- 账号按使用时间授权(7 天 / 30 天 / 365 天 / 永久),管理后台可查看与设置。 +- 每个 30 天周期内累计活跃超过 5 分钟自动续期 30 天;管理后台可手动续期。 +- 记录用户活跃使用时间,超过可配置天数无活跃自动封禁。 +- 普通用户仅能通过 TG 机器人完成:卡密注册、修改密码、获取线路(连接信息)、发送歌单。 +- TG 机器人收到歌单(文本 / LX JSON / 平台链接三种格式),对比全服务器本地曲库,缺失歌曲自动加入下载队列,并为该用户生成/更新单独的收藏歌单。 + +## Glossary + +- **管理员**:通过 `frontend.password`(`X-Frontend-Auth`)访问管理后台与 Web 播放端的角色。 +- **普通用户(注册用户)**:通过卡密注册获得账号的用户,可通过 Subsonic 客户端与 TG 机器人使用服务。 +- **有效期(expireAt)**:账号可用截止时间戳。永久账号该值为空。 +- **永久账号**:`expireAt` 为空,不参与续期与封禁。 +- **活跃(activity)**:用户任意使用行为(Web 播放端请求、Subsonic 请求、本地播放、TG 交互)触发一次活跃刷新,更新 `lastActiveAt` 并累加活跃时长。 +- **活跃续期**:每个 30 天周期内累计活跃时长 ≥ 5 分钟,到期时自动延长 30 天。 +- **自动封禁**:超过 N 天(管理后台可配置,0 表示关闭)无活跃记录,系统自动置为封禁状态。 +- **线路(connection info)**:服务器对外连接参数(Subsonic 地址、WebDAV 地址、用户名等),供用户配置客户端。 +- **TG 机器人**:本系统新增的 Telegram Bot,为普通用户提供注册/改密/线路/歌单服务。 +- **本地曲库**:服务器上所有用户已下载音乐文件的全量集合(按 `/music//` 落盘,由 `music_index.json` 索引),本需求中歌单对比针对全服务器曲库。 +- **歌单**:用户通过 TG 发送的歌曲集合,支持三种格式:文本(每行`歌手 - 歌名`)、LX Music JSON 歌单、平台(网易云/QQ 等)歌单链接。 + +## Requirements + +### R1. Web 播放端访问控制 + +**User Story:** AS 管理员,I want 普通用户无法登录 Web 播放端与后台,SO THAT 播放端仅对管理员开放。 + +#### Acceptance Criteria + +1. WHEN 普通注册用户调用播放端登录接口(用户名/密码),THEN 系统 SHALL 拒绝登录并提示"仅管理员可用"。 +2. WHEN 管理员使用后台密码(`frontend.password`)登录 Web 播放端,THEN 系统 SHALL 允许进入并正常使用播放功能。 +3. WHEN 普通用户访问管理后台 API,THEN 系统 SHALL 返回 401(沿用现有 `x-frontend-auth` 校验)。 +4. WHEN 未开启 `player.enableAuth` 且未开启 `player.forceLogin`,THEN 系统 SHALL 保持现有"直接放行"行为。 + +### R2. 用户有效期管理 + +**User Story:** AS 管理员,I want 为每个用户设置可使用时间(7/30/365/永久),SO THAT 控制账号授权时长。 + +#### Acceptance Criteria + +1. WHEN 用户通过卡密注册,THEN 系统 SHALL 依据卡密 `expireDays` 设置账号初始 `expireAt`(`now + expireDays`,永久卡 `expireAt = null`)。 +2. WHEN 管理后台为某用户设置时长为 7/30/365 天,THEN 系统 SHALL 将 `expireAt` 更新为 `now + 对应天数`。 +3. WHEN 管理后台为某用户设置"永久",THEN 系统 SHALL 将 `expireAt` 置为空。 +4. WHEN 用户的 `expireAt` 已过期,THEN 系统 SHALL 在播放端、Subsonic、TG 三入口同步拒绝该用户使用,并提示"账号已到期"。 + +### R3. 活跃续期与手动续期 + +**User Story:** AS 普通用户,I want 账号到期前只要保持活跃即可自动续期,SO THAT 正常使用的用户不会被中断。 + +#### Acceptance Criteria + +1. WHEN 一个 30 天周期内该用户累计活跃时长 ≥ 5 分钟,THEN 系统 SHALL 在其到期时将 `expireAt` 延长 30 天并清零该周期累计活跃时长。 +2. WHEN 用户为永久账号,THEN 系统 SHALL 跳过续期判定。 +3. WHEN 管理后台点击"手动续期",THEN 系统 SHALL 将 `expireAt` 延长 30 天(永久账号不适用)。 + +### R4. 活跃时间记录与展示 + +**User Story:** AS 管理员,I want 查看每个用户的活跃使用时间,SO THAT 了解用户使用情况。 + +#### Acceptance Criteria + +1. WHEN 用户发生任意活跃行为,THEN 系统 SHALL 更新该用户 `lastActiveAt` 并累加当前周期活跃秒数。 +2. WHEN 管理后台打开用户管理页,THEN 系统 SHALL 展示每个用户的 `expireAt`(或"永久")、`lastActiveAt`、本周期累计活跃时长与封禁状态。 +3. WHEN 管理后台请求用户详情,THEN 系统 SHALL 返回上述字段。 + +### R5. 无活跃自动封禁 + +**User Story:** AS 管理员,I want 超过指定天数无活跃的账号被自动封禁,SO THAT 清理僵尸账号。 + +#### Acceptance Criteria + +1. WHEN 管理后台配置封禁阈值 N 天(0 表示关闭),THEN 系统 SHALL 按该阈值执行自动封禁扫描。 +2. WHEN 定时扫描发现非永久用户 `now - lastActiveAt > N 天`,THEN 系统 SHALL 将该用户置为封禁状态。 +3. WHEN 封禁用户尝试使用任一入口(播放端/Subsonic/TG),THEN 系统 SHALL 拒绝并提示"账号已被封禁"。 +4. WHEN 管理后台对封禁用户执行"解封",THEN 系统 SHALL 清除封禁状态并刷新 `lastActiveAt`。 + +### R6. TG 机器人:卡密注册 + +**User Story:** AS 普通用户,I want 通过 TG 机器人使用卡密注册,SO THAT 无需访问 Web 页面。 + +#### Acceptance Criteria + +1. WHEN 用户在 TG 中发送 `/register <用户名> <密码> <卡密>`,THEN 机器人 SHALL 校验用户名格式、密码长度与卡密有效性,并创建账号(与 `/api/auth/register` 一致)。 +2. WHEN 注册成功,THEN 机器人 SHALL 将 TG 用户 ID 与该账号绑定并回复成功信息。 +3. WHEN 卡密无效/已使用/已过期,THEN 机器人 SHALL 回复对应错误信息。 +4. WHEN 注册功能被关闭(`player.enableRegister = false`),THEN 机器人 SHALL 拒绝注册。 +5. WHEN 已注册的老用户发送 `/bind <用户名> <密码>`,THEN 机器人 SHALL 校验密码并将 TG 用户 ID 与该账号绑定。 + +### R7. TG 机器人:修改密码 + +**User Story:** AS 普通用户,I want 通过 TG 机器人修改密码,SO THAT 管理账号安全。 + +#### Acceptance Criteria + +1. WHEN 已绑定账号的用户发送 `/changepassword <旧密码> <新密码>`,THEN 机器人 SHALL 校验旧密码并将新密码写入用户配置。 +2. WHEN 未绑定账号,THEN 机器人 SHALL 提示先注册或绑定。 +3. WHEN 新密码长度小于 6,THEN 机器人 SHALL 拒绝并提示密码规则。 + +### R8. TG 机器人:获取线路 + +**User Story:** AS 普通用户,I want 通过 TG 机器人获取线路连接信息,SO THAT 配置 Subsonic 等客户端。 + +#### Acceptance Criteria + +1. WHEN 已绑定账号的用户发送 `/server`,THEN 机器人 SHALL 返回服务器连接信息(Subsonic 地址、用户名、WebDAV 地址等,取自配置)。 +2. WHEN 服务器对外地址未配置,THEN 机器人 SHALL 返回提示"请管理员配置对外地址"。 +3. WHEN 未绑定账号,THEN 机器人 SHALL 提示先注册或绑定。 + +### R9. TG 机器人:接收歌单并自动下载 + +**User Story:** AS 普通用户,I want 发送歌单给 TG 机器人,SO THAT 缺失歌曲被自动下载并生成我的收藏歌单。 + +#### Acceptance Criteria + +1. WHEN 已绑定账号的用户发送文本歌单(每行`歌手 - 歌名`),THEN 机器人 SHALL 解析出歌曲列表。 +2. WHEN 已绑定账号的用户发送 LX Music JSON 歌单文件,THEN 机器人 SHALL 解析出歌曲列表。 +3. WHEN 已绑定账号的用户发送平台歌单链接(网易云/QQ 等),THEN 机器人 SHALL 通过 musicSdk 获取歌单歌曲列表。 +4. WHEN 解析成功,THEN 机器人 SHALL 将歌曲列表与全服务器本地曲库对比,标记缺失歌曲。 +5. WHEN 存在缺失歌曲,THEN 机器人 SHALL 将该用户名的下载任务加入 `serverDownloadQueue`,歌曲保存至 `/music/<用户名>/`。 +6. WHEN 下载完成后,THEN 机器人 SHALL 为该用户创建/更新单独收藏歌单(`userList`,命名为"TG 歌单"),回复下载结果统计(总数/已存在/已排队/失败)。 +7. WHEN 歌单为空或无法解析,THEN 机器人 SHALL 回复错误提示。 + +## Non-Functional Requirements + +1. **安全**:TG 绑定需验证;卡密不可重复使用;所有入口统一执行到期/封禁校验。 +2. **可配置**:TG Bot Token、功能开关、封禁阈值 N 天、服务器对外地址均通过管理后台或配置文件设置。 +3. **一致性**:`global.lx.config.users` 扩展字段需在保存/加载/热重载时保持一致,避免覆盖丢失。 +4. **资源边界**:TG 歌单下载复用现有 `serverDownloadQueue` 的并发与持久化机制,不引入新下载链路。 diff --git a/.monkeycode/specs/webdav-mount-cache/design.md b/.monkeycode/specs/webdav-mount-cache/design.md new file mode 100644 index 00000000..7a6064d7 --- /dev/null +++ b/.monkeycode/specs/webdav-mount-cache/design.md @@ -0,0 +1,226 @@ +# WebDAV 音乐挂载:边播边缓存到本地 + 目录歌单 + +Feature Name: webdav-mount-cache +Updated: 2026-08-05 + +## Description + +为 LX Music Sync Server 新增「WebDAV 音乐挂载」能力。后台可配置多个 WebDAV 源(独立存储于 `webdav-mounts.json`,与现有 config.js 的 `webdav.*` 备份同步互不影响)。WebDAV 歌曲采用「边播边缓存到本地」链路:第三方播放器(Subsonic /rest)请求播放时,服务端从 WebDAV 流式拉取并同步写入本地缓存目录 `/webdav-cache//`,缓存完成后直接播放本地文件。任意目录(WebDAV / OpenList / 本地缓存)可一键收藏为歌单,歌单歌曲保留来源字段(source/folder/url),无需关联在线音源即可直接播放。 + +设计完全复用既有 OpenList 整合的成熟模式:`src/server/openlist.ts` 的目录树扫描、流式代理、边播边缓存、缓存进度跟踪,以及前端 `local_music.js` 的目录树面板与「目录收藏为歌单」逻辑。 + +## Architecture + +```mermaid +graph TD + A["第三方播放器 Subsonic /rest"] --> B["subsonic.ts handleStream"] + B --> C{"source 判断"} + C -->|"webdav/openlist/local"| D["webdavMount.ts 本地缓存优先流"] + C -->|"在线源 kw/kg/tx/..."| E["callUserApiGetMusicUrl 现有逻辑"] + + D --> F{"本地缓存命中?"} + F -->|"命中"| G["serveCacheFile 直接读本地(支持 Range)"] + F -->|"未命中"| H["webdav 流式代理 + 边播边写 .tmp"] + H --> I["完整后 rename 落盘"] + + J["后台管理 WebDAV 视图"] --> K["/api/webdav-mounts/* CRUD + 测试"] + K --> L["webdav-mounts.json 持久化"] + M["播放器本地音乐视图"] --> N["/api/webdav-mounts/browse 目录树"] + N --> O["local_music.js ol* 面板(复用)"] + P["目录收藏为歌单"] --> Q["addCurrentDirToPlaylist 复用前端逻辑"] +``` + +```mermaid +sequenceDiagram + participant P as 第三方播放器 + participant S as subsonic handleStream + participant W as webdavMount module + participant D as WebDAV 源 + + P->>S: GET /rest/stream?id=webdav_ + S->>S: 解析 source=webdav + S->>W: stream(serverId, filePath, range) + alt 本地缓存命中 + W-->>P: 读本地文件(206/200) + else 未命中 + 全量请求 + W->>D: 代理 GET(range 0-) + D-->>W: 数据流 + W-->>P: 转发响应 + W->>W: 同时写入 .tmp + W->>W: 完整后 rename 落盘 + else 未命中 + 分段 Range + W->>D: 仅转发该 Range + D-->>W: 数据流 + W-->>P: 转发响应 + end +``` + +## Components and Interfaces + +### 1. 新模块 `src/server/webdavMount.ts` + +独立模块,管理 WebDAV 挂载源与边播边缓存,逻辑镜像 `openlist.ts`。 + +**数据模型(持久化 `webdav-mounts.json`)** + +```ts +interface WebDAVMount { + id: string // crypto.randomUUID() + name: string // 显示名,如"我的WebDAV" + baseUrl: string // 如 https://alist.embyfd.cc.cd/dav/音乐/音乐 + username: string + password: string + rootPath: string // 浏览根目录,默认 '/' + enabled: boolean + createdAt: number +} +``` + +**导出函数** + +| 函数 | 说明 | +|---|---| +| `listMounts(): WebDAVMount[]` | 返回全部挂载源(密码字段脱敏:`hasPassword: !!password`) | +| `getMount(id): WebDAVMount \| undefined` | 按 id 获取 | +| `saveMounts()`, `addMount(m)`, `updateMount(id, patch)`, `deleteMount(id)` | CRUD + 持久化;deleteMount 同时清理 `/webdav-cache//` | +| `initClient(mount): Promise` | 动态 `import('webdav')` 的 `createClient`(复用 webdavSync.ts:76-101 模式),带 URL 归一化(无协议补 http://) | +| `listFiles(mount, dirPath, timeoutMs?)` | `getDirectoryContents` + 20s 超时(镜像 openlist.ts:384-389) | +| `browse(mountId, dirPath)` | 目录浏览接口,返回 `{ items: [{ name, isDir, size, mtime }] }` | +| `collectAudioFiles(mount, dirPath, ...)` | 递归收集音频,复用 openlist.ts:394-457 的防护参数(深度20/5000文件/800目录/60s/并发6),条目映射 `source='webdav'` | +| `getLocalIndex(mountId, forceRefresh?)` | TTL 120s 索引缓存,镜像 openlist.ts:462-479 | +| `getAllLocalIndex(forceRefresh?)` | 合并全部启用挂载源索引,镜像 openlist.ts:484-491 | +| `getCacheDir(mount)` / `getCacheFilePath(mount, path)` / `isFileCached` | 镜像 openlist.ts:276-289 / 364-366,目录 `webdav-cache//`,hash 命名 | +| `serveCacheFile(filePath, range, res)` | 直接复用 openlist.ts:305-342 的本地 Range 服务 | +| `stream(mount, filePath, range?)` | 原生 http/https 代理(禁止 needle,镜像 openlist.ts:257-271),递归跟随重定向 | +| `getCacheProgress / trackCacheProgress / markCacheDone / clearCacheProgress` | 镜像 openlist.ts:294-359 | +| `streamToCache(mount, filePath, res)` | **核心**:未命中缓存且全量请求时的边播边写(镜像 server.ts:4754-4795) | +| `cacheStatus(mountId?)` | 汇总:文件数/占用大小;`clearCache(mountId?)` 清空 | +| `testConnection(mountId)` | 列出 rootPath 验证连通 | +| `resolveStreamId(songmid): { mountId, filePath }` | 解析 Subsonic id 中的编码路径 | + +### 2. `src/server/server.ts` 新增路由 + +| 路由 | 方法 | 说明 | +|---|---|---| +| `/api/webdav-mounts` | GET | 列表(脱敏) | +| `/api/webdav-mounts` | POST | 新增(管理员鉴权 `x-frontend-auth`) | +| `/api/webdav-mounts/:id` | PUT | 更新 | +| `/api/webdav-mounts/:id` | DELETE | 删除(含清理缓存) | +| `/api/webdav-mounts/:id/test` | POST | 测试连接 | +| `/api/webdav-mounts/:id/browse` | GET | 目录浏览 `?path=` | +| `/api/webdav-mounts/:id/local-list` | GET | 音频索引 `?refresh=` | +| `/api/webdav-mounts/local-list` | GET | 全部挂载合并索引 | +| `/api/webdav-mounts/stream` | GET | 播放/代理 `?server=&path=&sign=`(镜像 openlist stream 路由 server.ts:4683-4808) | +| `/api/webdav-mounts/cache/check` | GET | 单文件缓存状态 | +| `/api/webdav-mounts/cache/status` | GET | 缓存汇总 | +| `/api/webdav-mounts/cache/clear` | POST | 清空缓存(管理员) | + +路由代码风格、管理员鉴权方式、`readBody` 均沿用现有 server.ts 模式。 + +### 3. `src/server/subsonic.ts` 扩展 handleStream + +在 `handleStream`(1955)的 source/songmid 解析后(约 1976 行后)新增分支: + +```ts +const LOCAL_SOURCES = ['webdav', 'openlist', 'local'] +if (LOCAL_SOURCES.includes(source)) { + // webdav: songmid 为 encodeURIComponent 后的远程路径,需 decodeURIComponent + // 查找对应挂载源:webdav 用 path 前缀匹配 mount;openlist 从 openlist.json 取 server + // 构造内部流 URL 并 302(或直接代理) + // webdav: /api/webdav-mounts/stream?server=&path= + // openlist: /api/openlist/stream?server=&path= + // local: 解析为 /api/music/cache/file// 或本地文件直接服务 +} +``` + +- **302 重定向**到内部流 URL,让内部流路由统一负责「本地缓存优先 + 边播边写」(Requirement 5 AC1-3),避免在 subsonic.ts 重复实现缓存逻辑 +- 该分支在 `findMusicById`/`callUserApiGetMusicUrl` 之前执行,命中即返回,不落入在线源解析(Requirement 5 AC4) +- source 归属判定:`webdav_` 前缀 → webdav;`openlist_` 前缀 → openlist;`local`/本地歌曲 → local + +### 4. 前端 + +**后台管理(`public/index.html` + `public/app.js`)** +- 新增导航 `data-view="webdav-mounts"`,镜像现有 `view-openlist`(index.html:1309-1369) +- 视图内容:挂载源列表 + 添加/编辑弹窗(name/base-url/root-path/username/password/enabled)+ 测试按钮 +- JS:`loadWebdavMounts`/`renderWebdavMounts`/`showWebdavMountModal`/`saveWebdavMount`/`deleteWebdavMount`/`testWebdavMount`,镜像 app.js:2070-2201 + +**播放器(`public/music/`)** +- `local_music.js` 本地音乐视图:在 OpenList 面板旁新增 WebDAV 挂载源下拉/目录树(复用 `lm-ol-*` 面板与 `ol*` 方法,新增 `wm-*` 对应方法,调 browse 接口) +- 「目录收藏为歌单」复用 `addCurrentDirToPlaylist`(local_music.js:1981-2024),`buildPlaylistSong` 增加 webdav 分支(1902-1948 处,仿 openlist 豁免音源关联) +- 缓存进度徽标:镜像 openlist_manager.js:270-297 轮询 `/api/webdav-mounts/cache/check` + +## Data Models + +### webdav-mounts.json(`/`) + +```json +{ + "version": 1, + "mounts": [ + { + "id": "wd_xxx", + "name": "我的WebDAV", + "baseUrl": "https://alist.example.com/dav/音乐", + "username": "user", + "password": "pass", + "rootPath": "/", + "enabled": true, + "createdAt": 1785000000000 + } + ] +} +``` + +### 音频索引条目(CacheItem 兼容,source='webdav') + +```ts +{ + id: `webdav_${encodeURIComponent(fullPath)}`, + songmid: id, songId: id, + name: fileName(去扩展名), singer: '', album: '', + source: 'webdav', downloadSource: 'webdav', + sourceName: mount.name, + quality: ext, filename: fullPath, folder: 'webdav', subPath: dir, + mtime, size, ext, hasCover: false, coverType: 'none', hasLyric: false, + serverId: mount.id, path: fullPath, sign: '', + isLocal: true, webdav: true, interval: 0, + url: `/api/webdav-mounts/stream?server=${mount.id}&path=${encodeURIComponent(fullPath)}` +} +``` + +## Correctness Properties + +1. **缓存完整性**:`.tmp` 写入期间,正式缓存文件不出现;仅完整下载后才 rename 落盘,中断即删除临时文件 +2. **索引防护**:递归扫描受深度/文件数/目录数/总时长/并发限制,任何越界立即停止,不拖垮进程 +3. **并发去重**:同一文件同时被多客户端请求时只发起一次 WebDAV 下载(单飞),后续请求共享该次下载的本地文件或等待进度 +4. **密码不泄露**:任何 API 返回的挂载源条目不包含 password 明文,仅 `hasPassword` +5. **删除幂等**:删除挂载源后其缓存目录不存在残留;删除不存在的挂载源不报错 +6. **不破坏现有链路**:subsonic 在线源(kw/kg/tx/wy/mg)解析逻辑保持原样;`webdav.*` 备份同步不受影响 + +## Error Handling + +| 场景 | 处理 | +|---|---| +| WebDAV 连接失败/认证失败 | stream/索引接口返回 `{success:false, message}`;test 返回可读错误 | +| 递归扫描超时/超限 | 返回已收集部分,不抛异常(镜像 openlist.ts:394-457) | +| 下载中断(网络错误) | 清理 `.tmp`,已响应部分随流结束;下次请求重新缓存 | +| 重定向超过 N 跳 | 停止跟随并返回错误响应 | +| 挂载源不存在/被删除 | stream 返回 404 `{success:false, message:'挂载源不存在'}` | +| 密码为空 | 允许匿名 WebDAV(不传 username/password) | +| Subsonic 流解析失败 | 落入现有 `sendError(res, 0, 'Could not resolve music URL')` | + +## Test Strategy + +1. **单测(Node 内置 node:test)**:`webdavMount.ts` 的路径归一化、条目映射、缓存 hash 计算、resolveStreamId 解析、索引防护边界 +2. **本地集成(mock WebDAV)**:用轻量 WebDAV 测试服务器(如 `webdav` npm 包的服务端或自建 http server 响应 PROPFIND/GET)验证:browse、collectAudioFiles、stream 代理、边播边写落盘、Range 请求、缓存命中秒开 +3. **Subsonic 链路**:构造 `webdav_` id 的 stream 请求,验证命中本地缓存分支且不调用在线源 +4. **回归**:现有 openlist 播放/缓存、webdav 备份同步、在线源 subsonic 播放不受影响(手工验证 + 已有路由冒烟) +5. **前端手工**:后台 WebDAV 挂载源 CRUD/测试;播放器浏览目录树、播放、收藏为歌单、缓存徽标 + +## References + +[^1]: (Source) - [openlist.ts 本地音乐整合与边播边缓存](/workspace/src/server/openlist.ts) +[^2]: (Source) - [server.ts OpenList stream 路由(边播边写)](file:///workspace/src/server/server.ts#L4683) +[^3]: (Source) - [subsonic.ts handleStream(在线源解析)](file:///workspace/src/server/subsonic.ts#L1955) +[^4]: (Source) - [local_music.js addCurrentDirToPlaylist 目录收藏歌单](file:///workspace/public/music/js/local_music.js#L1981) +[^5]: (Source) - [webdavSync.ts createClient 初始化模式](file:///workspace/src/utils/webdavSync.ts#L76) diff --git a/.monkeycode/specs/webdav-mount-cache/requirements.md b/.monkeycode/specs/webdav-mount-cache/requirements.md new file mode 100644 index 00000000..6bcd3cfb --- /dev/null +++ b/.monkeycode/specs/webdav-mount-cache/requirements.md @@ -0,0 +1,87 @@ +# Requirements Document + +## Introduction + +在 LX Music Sync Server 中新增「WebDAV 音乐挂载」能力:后台可配置多个 WebDAV 源,将其作为与 OpenList 同级的音乐来源。歌曲采用「边播边缓存到本地」链路——第三方播放器请求播放时,服务端从 WebDAV 流式拉取并同步写入本地缓存目录,缓存完成后直接播放本地文件。任意目录(WebDAV/OpenList/本地缓存目录)可一键收藏为歌单,歌单内歌曲保留来源字段,无需关联在线音源即可直接播放。 + +## Glossary + +- **WebDAV 挂载源**:后台配置的 WebDAV 服务器条目(name/baseUrl/username/password/rootPath/enabled),可配置多个 +- **缓存目录**:服务端本地目录 `/webdav-cache//`,存放从 WebDAV 边播边下载的音频文件 +- **内部流 URL**:形如 `/api/webdav/stream?server=&path=` 的服务端代理/本地缓存服务地址 +- **目录歌单**:把某个目录下收集的歌曲一键收藏为歌单,歌曲条目保留源信息(source/folder/url)可直接播放 +- **第三方播放器**:通过 Subsonic API(/rest)接入的外部客户端(如音流、Symfonium、DSub 等) + +## Requirements + +### Requirement 1:WebDAV 挂载源管理 + +**User Story:** AS 管理员, I want 在后台增删改查 WebDAV 挂载源并测试连通性, so that 可以把任意 WebDAV 服务作为音乐来源。 + +#### Acceptance Criteria + +1. WHEN 管理员在后台创建 WebDAV 挂载源,系统 SHALL 持久化保存该条目(含 name/baseUrl/username/password/rootPath/enabled 字段)到 `/webdav-mounts.json` +2. WHEN 管理员编辑已有挂载源,系统 SHALL 更新对应条目并持久化 +3. WHEN 管理员删除挂载源,系统 SHALL 移除该条目及其本地缓存目录中的文件 +4. WHEN 管理员点击测试连接,系统 SHALL 尝试连接该 WebDAV 的 rootPath 并返回连通结果与目录项数量 +5. WHEN 系统读取挂载源列表,系统 SHALL 返回全部条目(密码字段仅返回 hasPassword 布尔值,不返回真实密码) + +### Requirement 2:目录浏览与音频索引 + +**User Story:** AS 管理员/用户, I want 浏览 WebDAV 挂载源的目录树并生成音频索引, so that 可以按目录定位和播放歌曲。 + +#### Acceptance Criteria + +1. WHEN 请求浏览指定挂载源的指定目录,系统 SHALL 返回该目录的子目录与文件列表 +2. WHEN 请求刷新某挂载源的音频索引,系统 SHALL 从 rootPath 递归收集全部音频文件,映射为与 OpenList 兼容的条目结构(含 source='webdav'、serverId、path、url 内部流地址) +3. WHEN 递归扫描时,系统 SHALL 应用防护限制(深度上限、文件数上限、目录数上限、扫描总时长、并发上限),避免超大远程目录拖垮进程 +4. WHEN 音频索引已生成且未过期(TTL 内),系统 SHALL 直接返回缓存索引;WHEN 超过 TTL 或请求强制刷新,系统 SHALL 重新扫描 +5. WHEN 扫描的目录含音频文件,系统 SHALL 为每个音频生成条目,字段至少包含 id/name/source='webdav'/serverId/path/url/folder='webdav' 等,确保播放链路可用 + +### Requirement 3:边播边缓存到本地播放 + +**User Story:** AS 用户, I want WebDAV 歌曲在请求播放时边播边缓存到本地目录, so that 首次播放秒开、后续播放直接读本地且不依赖 WebDAV 源稳定性。 + +#### Acceptance Criteria + +1. WHEN 播放请求命中本地缓存文件,系统 SHALL 直接以本地文件响应(支持 Range,秒开跳转) +2. WHEN 播放请求未命中缓存且为完整范围请求(无 Range 或 bytes=0-),系统 SHALL 从 WebDAV 流式代理并在写入响应同时把数据写入 `/webdav-cache//` 的临时文件,完整下载后落盘为正式缓存 +3. WHEN 播放请求为分段 Range 且未命中缓存,系统 SHALL 仅代理转发该 Range 到 WebDAV,不触发写入 +4. WHEN 下载中断/失败,系统 SHALL 清理残留临时文件,不产生损坏的正式缓存 +5. WHEN 播放请求需要跟随 WebDAV 重定向(如对象存储签名 URL),系统 SHALL 递归跟随重定向(最多 N 跳) +6. WHEN 查询缓存状态,系统 SHALL 返回单文件缓存进度或整体缓存汇总(文件数/占用大小);管理员可清空缓存 + +### Requirement 4:任意目录收藏为歌单 + +**User Story:** AS 用户, I want 把 WebDAV/OpenList/本地缓存中的任意目录一键收藏为歌单, so that 无需手动逐首添加。 + +#### Acceptance Criteria + +1. WHEN 用户选择当前目录并触发「收藏为歌单」,系统 SHALL 收集该目录及其子目录下的全部歌曲条目 +2. WHEN 目录条目含 openlist/webdav 来源歌曲,系统 SHALL 保留其 source/folder/url/serverId/path 字段,加入歌单后仍可直接播放,无需关联在线音源 +3. WHEN 目录条目为纯本地缓存歌曲(无平台音源关联),系统 SHALL 参照 openlist 分支豁免音源关联,直接可用 +4. WHEN 用户选择目标歌单并确认,系统 SHALL 调用歌单添加接口批量写入并创建快照 +5. WHEN 歌单内歌曲被播放,系统 SHALL 优先使用条目自带 url(内部流地址)播放,不走在线音源解析 + +### Requirement 5:第三方播放器(Subsonic)远程缓存后播放 + +**User Story:** AS 第三方播放器用户, I want 通过 Subsonic API 播放 WebDAV/OpenList/本地歌曲时服务端先缓存到本地再返回音频流, so that 播放稳定且不依赖远程源实时可用性。 + +#### Acceptance Criteria + +1. WHEN Subsonic stream 请求的 id 解析为 source='webdav',系统 SHALL 触发边播边缓存流程并以本地缓存流响应,而非返回在线源 302 +2. WHEN Subsonic stream 请求的 id 解析为 source='openlist' 或 'local',系统 SHALL 同样走本地缓存优先链路 +3. WHEN 缓存未完整,系统 SHALL 先响应已下载部分(流式),下载完成后后续 Range 请求直接读本地文件 +4. WHEN 请求不支持的 source,系统 SHALL 维持现有在线音源解析逻辑不变 +5. WHEN stream 请求涉及 WebDAV/OpenList 缓存,系统 SHALL 在返回响应时设置正确的 Content-Type 与 Range 支持 + +### Requirement 6:前端接入 + +**User Story:** AS 用户, I want 在管理后台与播放器界面管理 WebDAV 挂载源、浏览目录并收藏歌单, so that 完整使用该功能。 + +#### Acceptance Criteria + +1. WHEN 管理员进入后台 WebDAV 管理视图,系统 SHALL 展示挂载源列表、添加/编辑/删除/测试操作入口 +2. WHEN 用户在播放器本地音乐视图切换 WebDAV 源,系统 SHALL 展示目录树并可浏览/播放歌曲 +3. WHEN 用户对 WebDAV 目录触发播放,系统 SHALL 在界面显示缓存进度徽标(已缓存/缓存中) +4. WHEN 用户触发「目录收藏为歌单」,系统 SHALL 弹出歌单选择并完成添加,收藏后歌曲可直接播放 diff --git a/.monkeycode/specs/webdav-mount-cache/tasklist.md b/.monkeycode/specs/webdav-mount-cache/tasklist.md new file mode 100644 index 00000000..01ca3086 --- /dev/null +++ b/.monkeycode/specs/webdav-mount-cache/tasklist.md @@ -0,0 +1,58 @@ +# Tasklist: WebDAV 音乐挂载(边播边缓存 + 目录歌单) + +## 任务 + +- [x] **1. 新模块 `src/server/webdavMount.ts` 基础框架** + - 实现 WebDAVMount 类型与持久化(`webdav-mounts.json`:listMounts/getMount/addMount/updateMount/deleteMount) + - `initClient`(动态 import webdav,URL 归一化)、`listFiles`(带 20s 超时)、`browse` + - `testConnection`(列出 rootPath 验证连通) + - 单元测试:持久化 CRUD、路径归一化、密码脱敏、删除清理缓存目录 + +- [x] **2. 目录浏览与音频索引** + - `collectAudioFiles`(递归扫描,复用 openlist 防护参数:深度20/5000文件/800目录/60s/并发6) + - `getLocalIndex`(TTL 120s 索引缓存)、`getAllLocalIndex`、`clearLocalIndex` + - 条目映射 `source='webdav'`(含 id/songmid/url 内部流地址) + - 单元测试:条目字段完整性、防护边界(深度/文件数/超时)、TTL 缓存命中 + +- [x] **3. 边播边缓存到本地** + - `getCacheDir`/`getCacheFilePath`/`isFileCached`(`webdav-cache//` hash 命名) + - `serveCacheFile`(复用 openlist 本地 Range 服务) + - `stream`(原生 http/https 代理 + 递归跟随重定向,禁用 needle) + - `streamToCache`(未命中缓存 + 全量请求时边播边写 .tmp,完整后 rename 落盘) + - 缓存进度跟踪(trackCacheProgress/markCacheDone/getCacheProgress/clearCacheProgress) + - 单元测试:缓存路径/hash、serveCacheFile Range 响应、进度状态机 + +- [x] **4. server.ts 新增 WebDAV 挂载路由** + - `/api/webdav-mounts` GET/POST、`/:id` PUT/DELETE、`/:id/test` POST + - `/:id/browse` GET、`/:id/local-list` GET、`/local-list` GET(合并) + - `/api/webdav-mounts/stream` GET(本地缓存优先 + 边播边写,镜像 openlist stream) + - `/cache/check` GET、`/cache/status` GET、`/cache/clear` POST + - 管理员/播放器鉴权沿用现有模式 + - 冒烟验证通过:挂载 CRUD/脱敏、browse、索引 731 项、stream 边播边缓存(.tmp→rename)、二次 206 命中、cache/check/status/clear、断开清理 .tmp、DELETE/clear + +- [x] **5. subsonic.ts handleStream 扩展** + - 解析 source:`webdav_`/`openlist_`/`local` 走内部流分支 + - webdav:构造 `/api/webdav-mounts/stream` 302;openlist:`/api/openlist/stream` 302;local:缓存文件服务 + - 在线源(kw/kg/tx/wy/mg)逻辑保持不变 + - 新增 `resolveLocalStreamUrl`:从挂载索引匹配 serverId 构造内部流 URL + +- [x] **6. 前端后台管理视图** + - `public/index.html` 新增 `data-view="webdav-mounts"` 导航与视图(镜像 view-openlist) + - `public/app.js` 新增挂载源 CRUD/测试逻辑(镜像 loadOpenList 系列) + +- [x] **7. 前端播放器目录树与歌单收藏** + - `public/music/js/local_music.js` 新增 WebDAV 挂载源目录树面板(镜像 ol* 方法) + - `buildPlaylistSong` 增加 webdav 分支(豁免音源关联) + - `addCurrentDirToPlaylist` 支持 webdav 目录;缓存进度徽标 + - `public/music/index.html` 新增 WebDAV 面板 + folder 筛选选项 webdav + - `public/app.js`/`index.html` 后台管理视图 + - server.ts 主列表合并 webdav 索引条目(镜像 openlist 合并) + +- [x] **8. 构建验证与文档更新** + - `npx tsc --noEmit` 类型检查通过 + - 全量测试通过(webdavMount.test.ts 20 项全过) + - 后台/播放器手工冒烟(挂载 CRUD、浏览、播放、缓存、歌单收藏、Subsonic 流) + - Subsonic stream webdav_ → 302 `/api/webdav-mounts/stream`(可播放 200 audio/mpeg) + - Subsonic stream openlist_ → 302 `/api/openlist/stream`(含 sign) + - 主列表 `/api/music/cache/list` 合并 webdav 索引条目 + - 更新 `.monkeycode/docs/` 与 MEMORY.md;git 提交(待办) diff --git a/Dockerfile b/Dockerfile index 8455a45f..3618abda 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,9 +12,9 @@ RUN apk add --update \ npm \ && (apk add --no-cache chromaprint || true) \ && npm install --ignore-scripts --no-audit --no-fund && npm run build \ - && rm -rf node_modules && npm install --omit=dev --no-audit --no-fund \ + && rm -rf node_modules && npm install --omit=dev --ignore-scripts --no-audit --no-fund \ && mkdir -p build-output \ - && mv server node_modules config.js index.js package.json public -t build-output + && mv server node_modules index.js package.json public scripts -t build-output FROM base AS final diff --git a/README.md b/README.md index d272b2fd..15933669 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,25 @@ # LX Music Sync Server (Enhanced Edition) -![lxserver](https://socialify.git.ci/XCQ0607/lxserver/image?description=1&forks=0&issues=0&logo=https://raw.githubusercontent.com/XCQ0607/lxserver/refs/heads/main/public/icon.svg&owner=1&pulls=0&stargazers=0&theme=Auto) +![lxserver](https://socialify.git.ci/boy6656598/lxserver/image?description=1&forks=0&issues=0&logo=https://raw.githubusercontent.com/boy6656598/lxserver/refs/heads/main/public/icon.svg&owner=1&pulls=0&stargazers=0&theme=Auto)
- -

Build Status - Version + Version Node Version - License + License

- GitHub stars - GitHub forks - GitHub issues - Last Commit - Commit Activity - Total Downloads + GitHub stars + GitHub forks + GitHub issues + Last Commit + Commit Activity + Total Downloads

-[帮助文档 Documentation](https://xcq0607.github.io/lxserver/) | [同步服务器 SyncServer](md/lxserver.md) | [更新日志 Changelog](changelog.md) | [English](README_EN.md) +[同步服务器 SyncServer](md/lxserver.md) | [更新日志 Changelog](changelog.md) | [English](README_EN.md) --- @@ -126,6 +123,20 @@ 公共曲库与共享收藏

+### 12. WebDAV / OpenList 音乐挂载 + +支持直接挂载 **WebDAV 网盘**(坚果云、Nextcloud、Alist 等)与 **OpenList (AList)** 服务器,将远端音乐库无缝接入播放器: + +- **多挂载源管理**:可同时配置多个 WebDAV 挂载源与 OpenList 服务器,支持测试连接与启用/停用。 +- **目录树浏览与一键入单**:在本地音乐管理器内嵌 **WebDAV 目录** / **OpenList 目录** 面板,浏览远端目录树并一键将音频加入歌单。 +- **边播边缓存**:播放时自动下载远端音频到本地缓存,配合缓存控制面板实现颗粒化管理,弱网环境也能流畅播放。 +- **目录索引与主列表合并**:自动扫描挂载目录下的音频文件,索引结果合并进本地音乐主列表,可直接搜索与播放。 +- **歌词支持**:自动读取音乐同目录下的 `.lrc` 歌词文件并随播放显示。 + +

+ WebDAV 音乐挂载 +

+ ## 🔒 访问控制与安全 为了保护你的隐私,Web 播放器支持开启访问密码。 @@ -154,26 +165,66 @@ Web 播放器针对移动端进行了深度优化,手机浏览器访问也能 ## 🚀 快速启动 -本项目基于 **Node.js** 开发,支持多种部署方式。 +本项目基于 **Node.js** 开发,支持多种部署方式。推荐在 NAS / VPS 上使用 **Docker Compose** 一键部署。 + +### 方式一:NAS / Docker Compose 一键部署(推荐) + +适用于飞牛 NAS、群晖 NAS、及任何支持 Docker 的主机。项目内置了 `Dockerfile` 与 `docker-compose.yml`,可直接构建运行。 + +**已有数据迁移(可选)**:如果你已有运行中的数据(用户、OpenList、卡密、云盘配置、缓存),先执行迁移脚本生成部署包: + +```bash +# 在项目目录执行,生成 lxserver-nas-deploy.tar.gz(含清洗后的 config.js + 完整 data/) +bash scripts/migrate-to-nas.sh +``` + +**全新安装 / 迁移安装通用步骤:** + +```bash +# 1. 将项目代码放到 NAS 固定目录 +mkdir -p /vol1/docker/lxserver +# (若迁移,把上面的部署包也解压进来) +tar -xzf lxserver-nas-deploy.tar.gz -C /vol1/docker/lxserver + +# 2. 构建并启动(首次构建需编译 TS,约 5-15 分钟) +cd /vol1/docker/lxserver && docker compose up -d --build -### 方式一:桌面客户端 +# 3. 访问 +# 同步管理后台: http://:9527/ +# Web 播放器: http://:9527/music/ +``` + +> **提示**: +> - 端口被占用时修改 `docker-compose.yml` 中 `ports` 左侧宿主机端口即可(如 `"9000:9527"`)。 +> - 所有数据(用户、OpenList、云盘配置、缓存)持久化在 `./data` 目录,**备份只需复制该目录**。 +> - 常用环境变量(优先级高于 `config.js`)见 `docker-compose.yml` 内注释:`LX_USER_<用户名>` 追加用户、`FRONTEND_PASSWORD` 后台密码、`ENABLE_WEBPLAYER_AUTH` / `WEBPLAYER_PASSWORD` 播放器密码、`WEBDAV_URL` 等开启 WebDAV 同步。 + +### 方式二:直接运行 (Git Clone) + +```bash +# 1. 克隆项目 +git clone https://github.com/boy6656598/lxserver.git && cd lxserver + +# 2. 安装依赖并编译 +npm ci && npm run build + +# 3. 启动服务 +npm start +``` + +### 方式三:桌面客户端 可以通过桌面端更方便地运行 LX Music Sync Server,支持 Windows、macOS 和 Linux。 -- **📦 最新版本下载**: [GitHub Releases](https://github.com/XCQ0607/lxserver/releases/latest) +- **📦 最新版本下载**: [GitHub Releases](https://github.com/boy6656598/lxserver/releases/latest) - **✨ 桌面端优势**: - **单窗口管理**: 服务器管理与 Web 播放器合二为一,界面更统一。 - **托盘常驻**: 窗口关闭后自动缩回托盘,服务在后台始终运行。 - **全架构支持**: 提供 Windows (x64/x86/ARM64 Setup 及 Portable)、macOS (Intel/Apple Silicon) 及 Linux (amd64/arm64/armv7l) 全家桶。 -### 方式二:使用 Docker - -本项目支持从 Docker Hub 或 GitHub Packages 拉取镜像: - -- **Docker Hub**: `xcq0607/lxserver:latest` -- **GitHub Packages**: `ghcr.io/xcq0607/lxserver:latest` +### 方式四:使用 Docker 镜像 -**Docker Run 示例:** +> 镜像发布在 **GitHub Container Registry**(Docker Hub 发布见下方说明): ```bash docker run -d \ @@ -184,59 +235,39 @@ docker run -d \ -v $(pwd)/music:/server/music \ --name lx-sync-server \ --restart unless-stopped \ - xcq0607/lxserver:latest + ghcr.io/boy6656598/lxserver:3.0.1 ``` -**Docker Compose 示例:** - -新建 `docker-compose.yml` 文件: - -```yaml -version: '3' -services: - lx-sync-server: - image: xcq0607/lxserver:latest - container_name: lx-sync-server - restart: unless-stopped - ports: - - "9527:9527" - volumes: - - ./data:/server/data - - ./logs:/server/logs - - ./cache:/server/cache - - ./music:/server/music - environment: - - NODE_ENV=production - # - FRONTEND_PASSWORD=123456 - # - ENABLE_WEBPLAYER_AUTH=true - # - WEBPLAYER_PASSWORD=yourpassword - # - ADMIN_PATH= - # - PLAYER_PATH=/music -``` - -### 方式三:直接运行 (Git Clone) +**从源码构建镜像(如需自定义或自行发布 Docker Hub):** ```bash -# 1. 克隆项目 -git clone https://github.com/XCQ0607/lxserver.git && cd lxserver - -# 2. 安装依赖并编译 -npm ci && npm run build - -# 3. 启动服务 -npm start +docker build -t lxserver . +docker run -d \ + -p 9527:9527 \ + -v $(pwd)/data:/server/data \ + -v $(pwd)/logs:/server/logs \ + -v $(pwd)/cache:/server/cache \ + -v $(pwd)/music:/server/music \ + --name lx-sync-server \ + --restart unless-stopped \ + lxserver ``` -### 方式四:使用 Release 版本 +### 方式五:使用 Release 版本 1. 在 GitHub Releases 下载压缩包。 2. 解压后运行 `npm install --production`。 3. 执行 `npm start` 启动。 -### 3. 访问说明 +### 群晖套件安装 + +群晖用户也可使用项目内置的 SPK 打包方案(`packaging/synology-spk/`),支持 DSM 7 手动安装套件,详见 [packaging/synology-spk/README.md](packaging/synology-spk/README.md)。 + +### 访问说明 -- **Web 播放器**: `http://your-ip:9527/music` (默认路径,可通过 `PLAYER_PATH` 修改) - **同步管理后台**: `http://your-ip:9527` (默认路径,可通过 `ADMIN_PATH` 修改,默认密码: `123456`) +- **Web 播放器**: `http://your-ip:9527/music` (默认路径,可通过 `PLAYER_PATH` 修改) +- **Subsonic**: `http://your-ip:9527/rest` (可被音流、Feishin 等客户端连接) --- @@ -312,6 +343,19 @@ npm start > **提示**:目前服务支持 `启用根路径` (URL配置为 `ip:port`) 和 `启用用户路径` (URL配置为 `ip:port/username`) 两种数据同步连接方式。如果没有启用用户路径,则必须保证每一个同步用户的鉴权密码不重复。 +### 🎵 WebDAV / OpenList 音乐挂载配置 + +音乐挂载功能在 **Web 播放器**(`/music`)内配置,配置数据持久化于 `./data` 目录: + +| 数据文件 | 说明 | +| --- | --- | +| `./data/webdav-mounts.json` | WebDAV 挂载源配置(地址、账号、根目录等) | +| `./data/openlist.json` | OpenList 服务器配置 | + +- **WebDAV 挂载源**:填写 WebDAV 地址(如 `https://dav.example.com/音乐`,可包含根目录路径)、用户名与密码,可指定根目录路径并测试连接。 +- **OpenList 服务器**:填写 AList 服务地址、路径前缀及访问令牌(可选匿名访问)。 +- 挂载源启用后,Web 播放器将自动扫描远端音频并合并进本地音乐列表,支持目录浏览、边播边缓存与同目录歌词。 + --- ## 🛡️ 数据收集与隐私说明 @@ -334,28 +378,15 @@ npm start ### 👥 贡献者 (Contributors) - - + + - -## 📈 Star History - - - - - - Star History Chart - - - - - ## 📄 开源协议 本项目基于 Apache License 2.0 许可证发行,以下协议是对于 Apache License 2.0 的补充,如有冲突,以以下协议为准。 -Apache License 2.0 copyright (c) 2026 [xcq0607](https://github.com/xcq0607) +Apache License 2.0 copyright (c) 2026 [boy6656598](https://github.com/boy6656598) **词语约定**:本协议中的“本项目”指 LX Music Web 播放器;“使用者”指签署本协议的使用者;“官方音乐平台”指对本项目内置的包括酷我、酷狗、咪咕等音乐源的官方平台统称;“版权数据”指包括但不限于图像、音频、名字等在内的他人拥有所属版权的数据。 diff --git a/README_EN.md b/README_EN.md index d8f72550..f79cfffc 100644 --- a/README_EN.md +++ b/README_EN.md @@ -1,25 +1,25 @@ # LX Music Sync Server (Enhanced Edition) -![lxserver](https://socialify.git.ci/XCQ0607/lxserver/image?description=1&forks=0&issues=0&logo=https://raw.githubusercontent.com/XCQ0607/lxserver/refs/heads/main/public/icon.svg&owner=1&pulls=0&stargazers=0&theme=Auto) +![lxserver](https://socialify.git.ci/boy6656598/lxserver/image?description=1&forks=0&issues=0&logo=https://raw.githubusercontent.com/boy6656598/lxserver/refs/heads/main/public/icon.svg&owner=1&pulls=0&stargazers=0&theme=Auto)

Build Status Version Node Version - License + License

- GitHub stars - GitHub forks - GitHub issues - Last Commit - Commit Activity - Total Downloads + GitHub stars + GitHub forks + GitHub issues + Last Commit + Commit Activity + Total Downloads

-[Documentation](https://xcq0607.github.io/lxserver/) | [SyncServer](md/lxserver_EN.md) | [Changelog](changelog.md) | [中文版](README.md) +[SyncServer](md/lxserver_EN.md) | [Changelog](changelog.md) | [中文版](README.md) --- This project features a powerful built-in **Web Player**, allowing you to enjoy music anywhere in your browser. It also serves as an enhanced [LX Music Data Sync Server](md/lxserver_EN.md). @@ -117,6 +117,20 @@ When **"Enable Public Favorites and Songs"** is enabled in the backend settings, Public Library & Shared Favorites

+### 12. WebDAV / OpenList Music Mounting + +Mount **WebDAV** cloud drives (Nutstore, Nextcloud, Alist, etc.) and **OpenList (AList)** servers directly, seamlessly integrating your remote music library into the player: + +- **Multiple mount sources**: Configure multiple WebDAV mounts and OpenList servers at once, with connection testing and enable/disable. +- **Directory tree & one-click add**: Embedded **WebDAV Directory** / **OpenList Directory** panels in the Local Music manager let you browse the remote directory tree and add audio to playlists in one click. +- **Play-while-caching**: Remote audio is automatically downloaded to the local cache during playback, managed granularly via the cache control panel — smooth playback even on weak networks. +- **Directory indexing & merged list**: Audio files under mounted directories are scanned automatically and merged into the local music main list for direct search and playback. +- **Lyrics support**: `.lrc` lyrics files in the same directory are read automatically and displayed during playback. + +

+ WebDAV Music Mounting +

+ ## 🔒 Access Control & Security To protect your privacy, the Web Player supports password protection. ### How to Enable @@ -142,14 +156,58 @@ The Web Player is deeply optimized for mobile devices, providing a native App-li ## 🚀 Quick Start -Built with **Node.js**, supporting multiple deployment methods. +Built with **Node.js**, supporting multiple deployment methods. For NAS / VPS, **Docker Compose** is the recommended one-click deployment. + +### Option 1: NAS / Docker Compose One-Click Deployment (Recommended) + +Works on FeiNiu NAS, Synology NAS, and any Docker-capable host. This repo ships with `Dockerfile` and `docker-compose.yml`, so you can build and run directly. +**Migrate existing data (optional)**: If you already have runtime data (users, OpenList, card keys, cloud drive config, cache), run the migration script first: -### Option 1: Desktop Client +```bash +# Generates lxserver-nas-deploy.tar.gz (cleaned config.js + full data/) +bash scripts/migrate-to-nas.sh +``` + +**Fresh install / migration, common steps:** + +```bash +# 1. Put the project code in a fixed NAS directory +mkdir -p /vol1/docker/lxserver +# (for migration, extract the deploy package here as well) +tar -xzf lxserver-nas-deploy.tar.gz -C /vol1/docker/lxserver + +# 2. Build and start (first build compiles TS, ~5-15 min) +cd /vol1/docker/lxserver && docker compose up -d --build + +# 3. Access +# Sync Dashboard: http://:9527/ +# Web Player: http://:9527/music/ +``` + +> **Notes**: +> - If the port is taken, change the left-hand host port under `ports` in `docker-compose.yml` (e.g. `"9000:9527"`). +> - All data (users, OpenList, cloud drive config, cache) persists in `./data` — **backing up is just copying that directory**. +> - Common env vars (higher priority than `config.js`) are documented as comments in `docker-compose.yml`: `LX_USER_` to add users, `FRONTEND_PASSWORD` for dashboard password, `ENABLE_WEBPLAYER_AUTH` / `WEBPLAYER_PASSWORD` for player password, `WEBDAV_URL` etc. to enable WebDAV sync. + +### Option 2: Manual Run (Git Clone) + +```bash +# 1. Clone project +git clone https://github.com/boy6656598/lxserver.git && cd lxserver + +# 2. Install dependencies and build +npm ci && npm run build + +# 3. Start service +npm start +``` + +### Option 3: Desktop Client You can now run LX Music Sync Server more conveniently via our Desktop Client, available for Windows, macOS, and Linux. -- **📦 Download Latest**: [GitHub Releases](https://github.com/XCQ0607/lxserver/releases/latest) +- **📦 Download Latest**: [GitHub Releases](https://github.com/boy6656598/lxserver/releases/latest) - **✨ Key Advantages**: - **Single Window**: Integrated management dashboard and Web player for a unified experience. - **System Tray**: Minimizes to tray on close, ensuring the sync service stays active in the background. @@ -157,13 +215,9 @@ You can now run LX Music Sync Server more conveniently via our Desktop Client, a - **Setup Wizard**: Guided data path selection on first launch, supports **Portable Mode**. - **Multi-Arch Support**: Builds for Windows (x64/x86/ARM64 Setup & Portable), macOS (Intel x64 & Apple Silicon arm64), and Linux (amd64/arm64/armv7l deb/AppImage). -### Option 2: Containerized Deployment via Docker - -This project supports pulling images from Docker Hub or GitHub Packages: -- **Docker Hub**: `xcq0607/lxserver:latest` -- **GitHub Packages**: `ghcr.io/xcq0607/lxserver:latest` +### Option 4: Containerized Deployment via Docker -**Docker Run Example:** +> Image published on **GitHub Container Registry** (see below for building your own / publishing to Docker Hub): ```bash docker run -d \ @@ -174,59 +228,39 @@ docker run -d \ -v $(pwd)/music:/server/music \ --name lx-sync-server \ --restart unless-stopped \ - xcq0607/lxserver:latest + ghcr.io/boy6656598/lxserver:latest ``` -**Docker Compose Example:** - -Create a `docker-compose.yml` file: - -```yaml -version: '3' -services: - lx-sync-server: - image: xcq0607/lxserver:latest - container_name: lx-sync-server - restart: unless-stopped - ports: - - "9527:9527" - volumes: - - ./data:/server/data - - ./logs:/server/logs - - ./cache:/server/cache - - ./music:/server/music - environment: - - NODE_ENV=production - # - FRONTEND_PASSWORD=123456 - # - ENABLE_WEBPLAYER_AUTH=true - # - WEBPLAYER_PASSWORD=yourpassword - # - ADMIN_PATH= - # - PLAYER_PATH=/music -``` - -### Option 3: Manual Run (Git Clone) +**Build the image from source (for customization or publishing to Docker Hub):** ```bash -# 1. Clone project -git clone https://github.com/XCQ0607/lxserver.git && cd lxserver - -# 2. Install dependencies and build -npm ci && npm run build - -# 3. Start service -npm start +docker build -t lxserver . +docker run -d \ + -p 9527:9527 \ + -v $(pwd)/data:/server/data \ + -v $(pwd)/logs:/server/logs \ + -v $(pwd)/cache:/server/cache \ + -v $(pwd)/music:/server/music \ + --name lx-sync-server \ + --restart unless-stopped \ + lxserver ``` -### Option 4: Using Release Build +### Option 5: Using Release Build 1. Download the archive from GitHub Releases. 2. Extract and run `npm install --production`. 3. Execute `npm start`. -### 3. Access Info +### Synology Package (SPK) + +Synology users can also use the bundled SPK packaging (`packaging/synology-spk/`) for DSM 7 manual install, see [packaging/synology-spk/README.md](packaging/synology-spk/README.md). + +### Access Info -- **Web Player**: `http://your-ip:9527/music` (Default path, configurable via `PLAYER_PATH`) - **Sync Dashboard**: `http://your-ip:9527` (Default path, configurable via `ADMIN_PATH`, default password: `123456`) +- **Web Player**: `http://your-ip:9527/music` (Default path, configurable via `PLAYER_PATH`) +- **Subsonic**: `http://your-ip:9527/rest` (connectable by YinLiu, Feishin and other clients) --- @@ -302,6 +336,19 @@ Some advanced options are only configurable by directly editing `config.js`: > **Note**: The service currently supports two types of sync connection URLs: `Root Path` (URL configuration is `ip:port`) and `User Path` (URL configuration is `ip:port/username`). If the User Path is disabled, all sync user passwords must be completely unique. +### 🎵 WebDAV / OpenList Music Mounting Config + +The music mounting feature is configured inside the **Web Player** (`/music`), with data persisted in the `./data` directory: + +| Data File | Description | +| --- | --- | +| `./data/webdav-mounts.json` | WebDAV mount source config (URL, credentials, root path, etc.) | +| `./data/openlist.json` | OpenList server config | + +- **WebDAV mount**: Enter the WebDAV URL (e.g. `https://dav.example.com/music`, may include the root directory path), username and password; optionally set a root path and test the connection. +- **OpenList server**: Enter the AList service URL, path prefix and access token (anonymous access optional). +- Once enabled, the Web Player automatically scans remote audio, merges it into the local music list, and supports directory browsing, play-while-caching and same-directory lyrics. + --- ## 🛡️ Data Collection & Privacy @@ -324,29 +371,17 @@ Anonymous telemetry via PostHog is used for: ### 👥 Contributors - - + + -## 📈 Star History - - - - - - - Star History Chart - - - - ---- +## 📄 Open Source License--- ## 📄 License This project is released under the Apache License 2.0. The following agreement is a supplement to the Apache License 2.0. In case of conflict, this agreement shall prevail. -Apache License 2.0 copyright (c) 2026 [xcq0607](https://github.com/xcq0607) +Apache License 2.0 copyright (c) 2026 [boy6656598](https://github.com/boy6656598) **Terminology**: "This Project" refers to LX Music Web Player; "User" refers to the user who agrees to this agreement; "Official Music Platforms" refers to the collective official platforms of the music sources built into this project, including Kuwo, Kugou, Migu, etc.; "Copyrighted Data" refers to data owned by others, including but not limited to images, audio, names, etc. diff --git a/changelog.md b/changelog.md index 9f50f5f1..2a3858ce 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,35 @@ # Changelog +## v3.0.1 (2026-08-09) + +### 🌟 新增功能 + +- **WebDAV 音乐挂载 (@bobcc4)**: + - **边播边缓存**: 挂载远程 WebDAV 音乐目录后,首次播放同时写入本地缓存,之后播放/拖拽秒开。 + - **目录歌单**: 自动扫描挂载目录生成音乐列表,支持目录树浏览与收藏,收藏恢复播放无需重新连接。 + - **连接自检**: 支持多挂载源,密码脱敏保存,连接失败友好提示。 +- **OpenList 音乐整合 (@bobcc4)**: + - **目录扫描**: 递归扫描 OpenList 网盘音乐目录并合并到本地音乐列表,内置目录树面板。 + - **收藏与播放**: 收藏歌曲按 `url/serverId/path/sign` 恢复播放,`/d/` 直链 302 自动跟随,sign 过期实时刷新。 +- **Subsonic 客户端播放挂载音乐 (@bobcc4)**: + - 音流、Feishin 等 Subsonic 客户端可直接播放 WebDAV/OpenList 挂载音乐(内部流路由,支持 GET/HEAD/Range)。 + - 播放路径归一化:兼容收藏中固化的路径与实时索引在「多斜杠、URL 编码」上的差异,索引匹配失败时兜底解析歌单固化播放地址。 +- **挂载歌曲同目录 .lrc 歌词 (@bobcc4)**: + - `getLyricsBySongId`/`getLyrics` 支持读取 WebDAV/OpenList 挂载音乐同目录的同名 `.lrc` 歌词文件。 + - **编码自适应**: UTF-8 与 GBK/GB18030 自动识别(iconv-lite 解码),兼容 Docker/Alpine 环境。 +- **卡密注册与强制登录 (@bobcc4)**: + - 支持卡密批量生成、激活与导出;开启强制登录后未登录用户无法访问播放器。 + - 新增用户管理、数据快照回滚、WebDAV 同步/备份等管理功能完善。 + +### 🐛 修复 + +- **OpenList 播放卡死**: 修复 needle 3.x 流式代理在约 130KB 后卡死的 bug,改用原生 http/https 转发。 +- **WebDAV/OpenList 歌曲无法播放**: 修复 Subsonic 客户端与刷新页面后的播放源丢失、路径翻倍问题。 +- **出站连接**: 强制 DNS 优先 IPv4;容器内自动解析 `host.docker.internal` 访问宿主机 Alist/WebDAV。 +- **Docker 镜像**: 排除 `downloads` 目录(APK 等下载物),避免镜像膨胀与误同步。 + +--- + ## v2.0.0 (2026-07-22) ### 🌟 新增功能 diff --git a/config.js b/config.js deleted file mode 100644 index 2bad39c8..00000000 --- a/config.js +++ /dev/null @@ -1,194 +0,0 @@ -/** - * 配置文件 - * 配置优先级:WEBDAV备份数据 > 环境变量 > config.js (本文件) > src/defaultConfig.ts (默认配置) - */ -module.exports = { - // 同步服务名称 - // 环境变量: SERVER_NAME - "serverName": "lxserver", - - // 是否使用代理转发请求到本服务器 (如果配置了 proxy.header,此项会自动设为 true) - // 环境变量: 无 (通过 PROXY_HEADER 隐式开启) - "proxy.enabled": false, - - // 代理转发的请求头 原始IP - // 环境变量: PROXY_HEADER - "proxy.header": "x-real-ip", - - // 服务绑定IP (0.0.0.0 允许外网访问,127.0.0.1 仅限本机) - // 环境变量: BIND_IP - "bindIP": "0.0.0.0", - - // 服务监听端口 - // 环境变量: PORT - "port": 9527, - - // 是否开启用户路径 (baseurl/用户名) - // 开启后连接URL需包含用户名,允许不同用户使用相同密码。关闭后仅使用密码鉴权,要求所有用户密码唯一。 - // 环境变量: USER_ENABLE_PATH (true/false) - "user.enablePath": true, - - // 是否开启根路径 (baseurl) - // 开启后连接URL即为根路径,不允许不同用户使用相同密码。 - // 环境变量: USER_ENABLE_ROOT (true/false) - "user.enableRoot": false, - - // 是否启用公开用户权限限制 (开启后将限制公开用户的某些敏感操作,如上传、删除自定义源) - // 环境变量: ENABLE_PUBLIC_USER_RESTRICTION (true/false) - "user.enablePublicRestriction": true, - - // 是否开启公开收藏和歌曲 (开启后允许公开/未登录用户查看及播放公开收藏列表) - // 环境变量: ENABLE_PUBLIC_FAVORITES (true/false) - "user.enablePublicFavorites": false, - - // 是否开启非管理员访问本地音乐 (开启后允许未登录管理员的公开账号访问本地音乐) - // 环境变量: ENABLE_PUBLIC_NON_ADMIN_LOCAL_MUSIC (true/false) - "user.enablePublicNonAdminLocalMusic": false, - - // 是否开启非管理员访问公开收藏和歌曲 (开启后允许未登录管理员的公开账号查看公开收藏和歌曲) - // 环境变量: ENABLE_PUBLIC_NON_ADMIN_ACCESS (true/false) - "user.enablePublicNonAdminAccess": false, - - // 是否启用登录用户缓存限制 (开启后将限制非管理员登录用户的核心缓存设置) - // 环境变量: ENABLE_LOGIN_USER_CACHE_RESTRICTION (true/false) - "user.enableLoginCacheRestriction": false, - - // 是否启用缓存空间限制 (开启后超出容量将按 LRU 自动清理) - // 环境变量: ENABLE_CACHE_SIZE_LIMIT (true/false) - "user.enableCacheSizeLimit": false, - - // 缓存空间限制大小 (单位: MB) - // 环境变量: CACHE_SIZE_LIMIT - "user.cacheSizeLimit": 2000, - - // 最大快照数 (用于数据回滚) - // 环境变量: MAX_SNAPSHOT_NUM - "maxSnapshotNum": 10, - - // 添加歌曲到列表时的位置 (top: 顶部, bottom: 底部) - // 环境变量: LIST_ADD_MUSIC_LOCATION_TYPE - "list.addMusicLocationType": "top", - - // 是否禁用数据收集 - // 环境变量: DISABLE_TELEMETRY (true/false) - // 说明:仅收集版本号、运行环境(Docker/Node)、OS类型等非敏感信息用于项目改进。绝对匿名,不收集IP。 - "disableTelemetry": false, - - // 前端管理控制台访问密码 - // 环境变量: FRONTEND_PASSWORD - "frontend.password": "123456", - - // 用户列表 - // 环境变量: LX_USER_<用户名>=<密码> (例如: LX_USER_user1=123456) - "users": [ - { - "name": "admin", - "password": "password" - } - ], - - // WebDAV 同步配置 (可选,用于数据备份) - // 是否启用 WebDAV 同步与备份 - // 环境变量: WEBDAV_ENABLE (true/false) - "webdav.enable": false, - - // WebDAV 服务地址 - // 环境变量: WEBDAV_URL - "webdav.url": "", - - // WebDAV 用户名 - // 环境变量: WEBDAV_USERNAME - "webdav.username": "", - - // WebDAV 密码 - // 环境变量: WEBDAV_PASSWORD - "webdav.password": "", - - // WebDAV 增量同步远端路径 - // 环境变量: WEBDAV_SYNC_PATH - "webdav.syncPath": "/lx-sync", - - // WebDAV 全量备份远端路径 - // 环境变量: WEBDAV_BACKUP_PATH - "webdav.backupPath": "/lx-sync-backups", - - // 同步检测间隔 (分钟) - // 环境变量: SYNC_INTERVAL - "sync.interval": 60, - - // 全量备份间隔 (小时) - // 环境变量: BACKUP_INTERVAL - "sync.backupInterval": 24, - - // 是否启用 Web播放器 访问密码 - // 环境变量: ENABLE_WEBPLAYER_AUTH (true/false) - "player.enableAuth": false, - - // Web播放器 访问密码 - // 环境变量: WEBPLAYER_PASSWORD - "player.password": "123456", - - // 是否启用针对所有外发的请求代理 (目前主要用于离线音源的播放链接获取) - // 环境变量: PROXY_ALL_ENABLED (true/false) - "proxy.all.enabled": false, - - // 代理地址 (支持 http:// 或 socks5://) - // 环境变量: PROXY_ALL_ADDRESS (例如: http://127.0.0.1:7890) - "proxy.all.address": "", - - // 后台管理界面访问路径(默认为空,即根路径 /) - // 环境变量: ADMIN_PATH - "admin.path": "", - - // Web播放器访问路径(默认为 /music) - // 环境变量: PLAYER_PATH - "player.path": "/music", - - // Subsonic 协议配置 - // 是否启用 Subsonic 协议支持 (服务默认开启) - // 环境变量: SUBSONIC_ENABLE - "subsonic.enable": true, - - // Subsonic 访问路径 (默认为 /rest) - // 环境变量: SUBSONIC_PATH - "subsonic.path": "/rest", - - // 是否开启 Subsonic 调试日志模式 - // 环境变量: 无 - "subsonic.enableDebug": true, - - // 是否开启 Subsonic 在线全网搜索 - // 环境变量: 无 - "subsonic.onlineSearch": true, - - // Subsonic 在线搜索模式 (fallback: 回退模式, merge: 合并模式, local_only: 仅本地) - // 环境变量: 无 - "subsonic.onlineSearchMode": "fallback", - - // Subsonic 在线搜索默认平台 - // 环境变量: 无 - "subsonic.onlineSearchSources": "wy,tx,kw,kg,mg", - - // 是否在 Subsonic 歌词中包含翻译 - // 环境变量: 无 - "subsonic.lyricTranslation": true, - - // 歌手信息源优先级 (多个源用逗号分隔,如 tx,wy) - // 环境变量: SINGER_SOURCE_PRIORITY - "singer.sourcePriority": [ - "tx", - "wy" - ], - - // 歌手歌曲最大抓取页数 - // 环境变量: 无 - "artist.maxFetchPages": 20, - - // 缓存文件命名规则 (simple / custom) - // 环境变量: 无 - "cache.namingPattern": "simple", - - // 是否允许运行 VM 模式自定义源脚本 (默认关闭) - // 环境变量: 无 - "system.allowUnsafeVM": false -} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index f3b03212..8f7555a8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,17 +1,74 @@ -version: '3.8' +# lxserver 同步服务器 - Docker Compose 配置(适用于飞牛 NAS / 群晖 / 任意 Docker 主机) +# 使用步骤: +# 1. 将本项目代码放到 NAS 固定目录, 如 /vol1/docker/lxserver +# 2. 将迁移生成的 config.js 与 data/ 目录放至同一目录(可省略, 首次启动会自动生成默认配置) +# 3. docker compose up -d --build +# 4. 浏览器访问 http://:9527/ (后台) 与 http://:9527/music/ (播放器) +# +# 注意: +# - 构建耗时较长(需编译 TS 并下载 fpcalc), 首次启动约 5-15 分钟 +# - 所有数据(用户/OpenList/卡密/云盘配置/缓存)持久化在 ./data 目录, 备份只需复制该目录 services: lx-sync-server: - build: . + # 使用已发布到 ghcr.io 的官方镜像(推荐, 无需本地源码/Dockerfile) + image: ghcr.io/boy6656598/lxserver:3.0.1 + # 如需从源码自行构建, 请注释掉上面 image 行, 并取消下面一行的注释 + # build: . container_name: lx-sync-server restart: always + extra_hosts: + # 容器内访问宿主机服务的专用地址(如本机 NAS 上的 Alist/WebDAV): + # 后台填写地址时用 http://host.docker.internal:端口 替代 宿主机IP:端口 + # (bridge 网络下直接访问宿主机内网 IP 可能被网络栈重置导致 socket hang up) + # + # 注意: 部分环境(如飞牛 NAS) 对 host-gateway 支持不稳定, 容器内可能无法解析 + # host.docker.internal. 此时服务端会自动把该域名替换为探测到的宿主机网关 IP, + # 若仍失败可在下方 environment 中显式设置 LX_HOST_ADDR=<宿主机IP> 兜底. + - "host.docker.internal:host-gateway" ports: + # 端口映射: 宿主机端口:容器端口. 若 9527 被占用, 改左侧为其他端口即可, 例如 "9000:9527" - "9527:9527" volumes: - # 数据持久化 + # 数据持久化: 映射到容器内 /server/data (Dockerfile 已设置 DATA_PATH=/server/data) - ./data:/server/data environment: - # 环境变量配置,优先级高于 config.js + # 环境变量配置, 优先级高于 config.js - NODE_ENV=production - # 示例:通过环境变量设置用户 + + # ===== 可选配置(按需取消注释) ===== + + # 服务名称(显示在客户端) + # - SERVER_NAME=My Sync Server + + # 监听端口(默认 9527, 一般无需修改) + # - PORT=9527 + + # 管理员后台访问密码(默认见 config.js 的 frontend.password) + # - FRONTEND_PASSWORD=你的后台密码 + + # 播放器访问密码与是否强制登录 + # - ENABLE_WEBPLAYER_AUTH=true + # - WEBPLAYER_PASSWORD=123456 + + # 用户管理: 追加/覆盖用户账号, 格式 LX_USER_<用户名>=密码 + # 复杂配置可使用 JSON: LX_USER_user1='{ "password": "123.456", "maxSnapshotNum": 10 }' # - LX_USER_myuser=mypassword + + # 同步与备份间隔(分钟) + # - SYNC_INTERVAL=60 + + # 是否启用 WEBDAV 同步(留空则关闭) + # - WEBDAV_URL= + # - WEBDAV_USERNAME= + # - WEBDAV_PASSWORD= + + # 自定义配置文件路径(默认读取镜像内 /server/config.js) + # - CONFIG_PATH=/server/config.js + + # 宿主机可达地址(可选兜底): 当 host.docker.internal 在容器内无法解析时, + # 服务端会自动把它替换为探测到的宿主机网关地址(通常为 docker0 网关, 如 172.17.0.1), + # 一般无需手动设置. 仅当自动探测仍失败时才取消下面注释. + # 注意: 应填容器内可达的 docker 网关地址(如 172.17.0.1); 填 NAS 内网 IP + # (如 192.168.2.100) 在 bridge 网络下可能被网络栈重置导致连接超时失败. + # - LX_HOST_ADDR=172.17.0.1 diff --git a/package.json b/package.json index 8f8cd7cc..7f63c5b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lx-music-sync-server", - "version": "2.0.0", + "version": "3.0.1", "private": true, "scripts": { "prebuild": "node scripts/download-binary.js && node scripts/update-build-hash.js", diff --git a/public/app.js b/public/app.js index 124ad90e..23d966eb 100644 --- a/public/app.js +++ b/public/app.js @@ -276,6 +276,7 @@ class App { webdav: 'WebDAV同步', files: '文件管理', snapshots: '快照管理', + cards: '卡密管理', about: '关于' }; document.getElementById('page-title').textContent = titles[viewName] || viewName; @@ -311,6 +312,15 @@ class App { case 'snapshots': this.loadSnapshots(); break; + case 'cards': + this.loadCards(); + break; + case 'openlist': + this.loadOpenList(); + break; + case 'webdav-mounts': + this.loadWebdavMounts(); + break; case 'about': this.loadAbout(); break; @@ -1956,6 +1966,356 @@ class App { document.getElementById('modal').classList.add('hidden'); } + // ========== 卡密管理 ========== + async loadCards() { + try { + const res = await this.request('/api/card/list'); + const cards = res.cards || []; + const tbody = document.getElementById('cards-table-body'); + if (!tbody) return; + + const total = cards.length; + const unused = cards.filter(c => c.status === 'unused').length; + const used = total - unused; + document.getElementById('cards-total-count').textContent = total; + document.getElementById('cards-unused-count').textContent = unused; + document.getElementById('cards-used-count').textContent = used; + + if (!cards.length) { + tbody.innerHTML = '暂无卡密,点击右上角"生成卡密"'; + return; + } + + tbody.innerHTML = cards.map(card => ` + + + ${this.escapeHtml(card.code)} + ${card.status === 'used' + ? '已使用' + : '未使用'} + ${this.escapeHtml(card.boundUser || '-')} + ${this.escapeHtml(card.remark || '-')} + ${card.expireDays ? card.expireDays + ' 天' : '永久'} + ${new Date(card.createdAt).toLocaleString()} + ${card.usedAt ? new Date(card.usedAt).toLocaleString() : '-'} + + `).join(''); + + document.getElementById('cards-select-all').checked = false; + } catch (err) { + showError('加载卡密失败: ' + err.message); + } + } + + toggleSelectAllCards(checked) { + document.querySelectorAll('.card-checkbox').forEach(cb => { + cb.checked = checked; + }); + } + + deleteSelectedCards() { + const ids = Array.from(document.querySelectorAll('.card-checkbox:checked')).map(cb => cb.value); + if (!ids.length) { + showError('请先勾选要删除的卡密'); + return; + } + if (!confirm(`确定删除选中的 ${ids.length} 张卡密吗?`)) return; + this.request('/api/card/delete', { + method: 'POST', + body: JSON.stringify({ ids }) + }).then(() => { + showSuccess('删除成功'); + this.loadCards(); + }).catch(err => showError('删除失败: ' + err.message)); + } + + showGenerateCardsModal() { + const modal = document.getElementById('modal'); + document.getElementById('modal-title').textContent = '生成卡密'; + document.getElementById('modal-body').innerHTML = ` +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ `; + modal.classList.remove('hidden'); + + document.getElementById('confirm-generate-cards').addEventListener('click', () => { + const count = parseInt(document.getElementById('generate-cards-count').value || '1', 10); + const expireDays = document.getElementById('generate-cards-expire').value; + const remark = document.getElementById('generate-cards-remark').value.trim(); + this.request('/api/card/generate', { + method: 'POST', + body: JSON.stringify({ count, expireDays: expireDays ? parseInt(expireDays, 10) : null, remark }) + }).then(res => { + modal.classList.add('hidden'); + showSuccess(`成功生成 ${res.cards.length} 张卡密`); + this.loadCards(); + }).catch(err => showError('生成失败: ' + err.message)); + }); + } + + // ========== OpenList ========== + async loadOpenList() { + try { + const res = await this.request('/api/openlist/servers'); + this.renderOpenListServers(res.servers || []); + } catch (err) { + showError('加载 OpenList 服务器失败: ' + err.message); + } + } + + renderOpenListServers(servers) { + const container = document.getElementById('openlist-server-list'); + if (!container) return; + if (!servers.length) { + container.innerHTML = '
尚未添加任何 OpenList 服务器,点击右上角"添加服务器"开始配置。
'; + return; + } + const rows = servers.map(s => ` +
+
+
+ ${this.escapeHtml(s.name)} + ${s.enabled ? '已启用' : '已停用'} + ${s.hasAuth ? '已配置认证' : '公开访问'} +
+
${this.escapeHtml(s.baseUrl)}${s.rootPath && s.rootPath !== '/' ? ' · 根: ' + this.escapeHtml(s.rootPath) : ''}
+
+
+ + + +
+
`).join(''); + container.innerHTML = rows; + } + + openListModalData = null; + + showOpenListModal(server) { + this.openListModalData = server || null; + document.getElementById('openlist-modal-title').textContent = server ? '编辑 OpenList 服务器' : '添加 OpenList 服务器'; + document.getElementById('ol-name').value = server ? server.name : ''; + document.getElementById('ol-base-url').value = server ? server.baseUrl : ''; + document.getElementById('ol-root-path').value = server ? (server.rootPath || '/') : '/'; + document.getElementById('ol-username').value = server ? server.username : ''; + document.getElementById('ol-password').value = ''; + document.getElementById('ol-token').value = server ? server.token : ''; + document.getElementById('openlist-modal').classList.remove('hidden'); + } + + closeOpenListModal() { + document.getElementById('openlist-modal').classList.add('hidden'); + this.openListModalData = null; + } + + async saveOpenListServer() { + const data = { + name: document.getElementById('ol-name').value.trim(), + baseUrl: document.getElementById('ol-base-url').value.trim(), + rootPath: document.getElementById('ol-root-path').value.trim() || '/', + username: document.getElementById('ol-username').value.trim(), + password: document.getElementById('ol-password').value, + token: document.getElementById('ol-token').value.trim(), + enabled: true, + }; + if (!data.baseUrl) { + showError('请填写 OpenList 地址'); + return; + } + try { + if (this.openListModalData && this.openListModalData.id) { + await this.request('/api/openlist/servers', { + method: 'PUT', + body: JSON.stringify({ id: this.openListModalData.id, ...data }) + }); + showSuccess('服务器已更新'); + } else { + await this.request('/api/openlist/servers', { + method: 'POST', + body: JSON.stringify(data) + }); + showSuccess('服务器已添加'); + } + this.closeOpenListModal(); + this.loadOpenList(); + } catch (err) { + showError('保存失败: ' + err.message); + } + } + + editOpenList(id) { + this.request('/api/openlist/servers').then(res => { + const server = (res.servers || []).find(s => s.id === id); + if (server) this.showOpenListModal(server); + }).catch(err => showError('加载失败: ' + err.message)); + } + + async deleteOpenList(id) { + if (!confirm('确定删除该 OpenList 服务器吗?')) return; + try { + await this.request('/api/openlist/servers', { + method: 'DELETE', + body: JSON.stringify({ id }) + }); + showSuccess('已删除'); + this.loadOpenList(); + } catch (err) { + showError('删除失败: ' + err.message); + } + } + + async testOpenList(id) { + try { + const res = await this.request('/api/openlist/test', { + method: 'POST', + body: JSON.stringify({ id }) + }); + if (res.success) showSuccess('测试成功: ' + res.message); + else showError('测试失败: ' + res.message); + } catch (err) { + showError('测试失败: ' + err.message); + } + } + + // ========== WebDAV 挂载 ========== + async loadWebdavMounts() { + try { + const res = await this.request('/api/webdav-mounts'); + this.renderWebdavMounts(res.mounts || []); + } catch (err) { + showError('加载 WebDAV 挂载源失败: ' + err.message); + } + } + + renderWebdavMounts(mounts) { + const container = document.getElementById('webdav-mount-list'); + if (!container) return; + if (!mounts.length) { + container.innerHTML = '
尚未添加任何 WebDAV 挂载源,点击右上角"添加挂载源"开始配置。
'; + return; + } + const rows = mounts.map(m => ` +
+
+
+ ${this.escapeHtml(m.name)} + ${m.enabled ? '已启用' : '已停用'} + ${m.hasPassword ? '已配置认证' : '无密码'} +
+
${this.escapeHtml(m.baseUrl)}${m.rootPath && m.rootPath !== '/' ? ' · 根: ' + this.escapeHtml(m.rootPath) : ''}
+
+
+ + + +
+
`).join(''); + container.innerHTML = rows; + } + + webdavMountModalData = null; + + showWebdavMountModal(mount) { + this.webdavMountModalData = mount || null; + document.getElementById('webdav-mount-modal-title').textContent = mount ? '编辑 WebDAV 挂载源' : '添加 WebDAV 挂载源'; + document.getElementById('wd-name').value = mount ? mount.name : ''; + document.getElementById('wd-base-url').value = mount ? mount.baseUrl : ''; + document.getElementById('wd-root-path').value = mount ? (mount.rootPath || '/') : '/'; + document.getElementById('wd-username').value = mount ? mount.username : ''; + document.getElementById('wd-password').value = ''; + document.getElementById('wd-enabled').checked = mount ? (mount.enabled !== false) : true; + document.getElementById('webdav-mount-modal').classList.remove('hidden'); + } + + closeWebdavMountModal() { + document.getElementById('webdav-mount-modal').classList.add('hidden'); + this.webdavMountModalData = null; + } + + async saveWebdavMount() { + const data = { + name: document.getElementById('wd-name').value.trim(), + baseUrl: document.getElementById('wd-base-url').value.trim(), + rootPath: document.getElementById('wd-root-path').value.trim() || '/', + username: document.getElementById('wd-username').value.trim(), + password: document.getElementById('wd-password').value, + enabled: document.getElementById('wd-enabled').checked, + }; + if (!data.baseUrl) { + showError('请填写 WebDAV 地址'); + return; + } + try { + if (this.webdavMountModalData && this.webdavMountModalData.id) { + await this.request('/api/webdav-mounts', { + method: 'PUT', + body: JSON.stringify({ id: this.webdavMountModalData.id, ...data }) + }); + showSuccess('挂载源已更新'); + } else { + await this.request('/api/webdav-mounts', { + method: 'POST', + body: JSON.stringify(data) + }); + showSuccess('挂载源已添加'); + } + this.closeWebdavMountModal(); + this.loadWebdavMounts(); + } catch (err) { + showError('保存失败: ' + err.message); + } + } + + editWebdavMount(id) { + this.request('/api/webdav-mounts').then(res => { + const mount = (res.mounts || []).find(m => m.id === id); + if (mount) this.showWebdavMountModal(mount); + }).catch(err => showError('加载失败: ' + err.message)); + } + + async deleteWebdavMount(id) { + if (!confirm('确定删除该 WebDAV 挂载源吗?')) return; + try { + await this.request('/api/webdav-mounts', { + method: 'DELETE', + body: JSON.stringify({ id }) + }); + showSuccess('已删除'); + this.loadWebdavMounts(); + } catch (err) { + showError('删除失败: ' + err.message); + } + } + + async testWebdavMount(id) { + try { + const res = await this.request('/api/webdav-mounts/test', { + method: 'POST', + body: JSON.stringify({ id }) + }); + if (res.success) showSuccess('测试成功: ' + res.message); + else showError('测试失败: ' + res.message); + } catch (err) { + showError('测试失败: ' + err.message); + } + } + async request(url, options = {}) { const defaultOptions = { headers: { diff --git a/public/index.html b/public/index.html index a69f740b..bde78be7 100644 --- a/public/index.html +++ b/public/index.html @@ -59,19 +59,32 @@
- + +
+
🎉 v3.0.1 更新公告
+
    +
  • WebDAV 音乐挂载:挂载远程 WebDAV 音乐目录,边播边缓存 + 目录歌单。
  • +
  • OpenList 音乐整合:网盘目录扫描、合并列表、内嵌目录树与收藏播放。
  • +
  • Subsonic 客户端播放:音流 / Feishin 等客户端可直接播放 WebDAV / OpenList 挂载音乐。
  • +
  • 挂载歌曲同目录 .lrc 歌词:`getLyricsBySongId` 读取同目录歌词,UTF-8 / GBK 编码自适应。
  • +
+
+
@@ -1218,6 +1268,202 @@

同步日志

+ +
+
+
+

卡密管理

+

生成卡密供用户注册账号使用,每个卡密只能使用一次。

+
+
+ + +
+
+ +
+
+
0
+
卡密总数
+
+
+
0
+
未使用
+
+
+
0
+
已使用
+
+
+ +
+ + + + + + + + + + + + + + + + +
卡密状态绑定用户备注有效期生成时间使用时间
暂无卡密,点击右上角"生成卡密"
+
+
+ +
+
+ + +
+
+
+

OpenList 存储

+

添加自己的 OpenList 实例作为存储与读取目录,支持云端音乐播放、自动上传与歌词识别。

+
+
+ +
+
+ +
+
正在加载服务器列表...
+
+ + + +
+ + +
+
+
+

WebDAV 挂载

+

挂载 WebDAV 服务器作为音乐存储源,支持浏览、播放、边播边缓存到本地与收藏为歌单。

+
+
+ +
+
+ +
+
正在加载挂载源列表...
+
+ + + +
+
diff --git a/public/js/config.js b/public/js/config.js index d30b9241..5716d0f3 100644 --- a/public/js/config.js +++ b/public/js/config.js @@ -2,6 +2,6 @@ // 其余配置由服务端在运行时动态注入 (环境变量 > config.js > defaultConfig.ts) // 服务端拦截 /js/config.js 请求, 读取此处版本号并合并服务端配置后返回 window.CONFIG = { - buildHash: 'ed6ced2', - version: 'v2.0.0', + buildHash: 'e1dc867', + version: 'v3.0.1', }; diff --git a/public/music/app.js b/public/music/app.js index 5cec8a6d..7980c92c 100644 --- a/public/music/app.js +++ b/public/music/app.js @@ -1098,6 +1098,11 @@ function switchTab(tabId) { document.getElementById('page-title').innerText = "本地音乐"; } + if (tabId === 'openlist') { + document.getElementById('page-title').innerText = "OpenList"; + if (window.OpenListManager) window.OpenListManager.init(); + } + // Collapse Favorites if leaving if (tabId !== 'favorites') { const favList = document.getElementById('favorites-children'); @@ -3639,9 +3644,16 @@ async function fetchSongUrl(song, quality, isRetry = false, isSilent = false) { const cacheKey = `lx_url_${cleanedSong.id}_${quality}`; // 0. 本地文件/带有本地播放 URL 的歌曲:直接播放本地文件,无需走在线 API 解析 - if ((song.isLocal || song.url?.startsWith('/api/music/cache/file/')) && song.url && !isRetry) { + // 覆盖本地缓存、OpenList stream、WebDAV mount stream + const isWebdavStream = song.webdav || (song.url && song.url.indexOf('/api/webdav-mounts/stream') === 0); + const canDirectPlay = (song.isLocal || song.url?.startsWith('/api/music/cache/file/') || song.url?.startsWith('/api/openlist/stream') || isWebdavStream) && song.url; + if (canDirectPlay && (!isRetry || isWebdavStream)) { console.log(`[Cache] Direct Local File Hit: ${song.name}`); let localUrl = await applyAutoProxy(song.url, song); + // webdav 重试(首次拉流失败)时绕过本地损坏缓存,强制从上游重新拉流 + if (isRetry && isWebdavStream && localUrl && localUrl.indexOf('/api/webdav-mounts/stream') === 0) { + localUrl += (localUrl.indexOf('?') >= 0 ? '&' : '?') + 'nocache=1'; + } return { url: localUrl, sourceType: 'server_cache', quality: song.quality || quality }; } @@ -7075,6 +7087,68 @@ async function fetchLyric(song, quality = null) { } } + + // ===== 2.6 OpenList 歌曲:从同目录读取 .lrc 歌词 ===== + if (source === 'openlist' && song.path) { + try { + let lyricUrl = `/api/openlist/lyric?server=${encodeURIComponent(song.serverId || '')}&path=${encodeURIComponent(song.path)}`; + if (song.sign) lyricUrl += `&sign=${encodeURIComponent(song.sign)}`; + const lyricRes = await fetch(lyricUrl, { headers }); + if (lyricRes.ok) { + const lyricData = await lyricRes.json(); + const lrcText = (lyricData && lyricData.lyric) || ''; + if (lrcText) { + currentRawLrc = lrcText; + currentRawTlrc = ''; + currentRawRlrc = ''; + currentRawKlrc = ''; + if (settings.enableLyricCache !== false) { + try { + localStorage.setItem(cacheKey, JSON.stringify({ lrc: lrcText, tlyric: '', rlyric: '', klyric: '' })); + } catch (e) { } + } + initLyricPlayer(); + applyLyricUpdate(); + return; + } + } + } catch (e) { + console.warn('[Lyric] OpenList 歌词获取失败:', e); + } + renderLyric([], '暂无歌词'); + return; + } + + // ===== 2.7 WebDAV 歌曲:从同目录读取 .lrc 歌词 ===== + if (source === 'webdav' && song.path) { + try { + let lyricUrl = `/api/webdav-mounts/lyric?server=${encodeURIComponent(song.serverId || '')}&path=${encodeURIComponent(song.path)}`; + const lyricRes = await fetch(lyricUrl, { headers }); + if (lyricRes.ok) { + const lyricData = await lyricRes.json(); + const lrcText = (lyricData && lyricData.lyric) || ''; + if (lrcText) { + currentRawLrc = lrcText; + currentRawTlrc = ''; + currentRawRlrc = ''; + currentRawKlrc = ''; + if (settings.enableLyricCache !== false) { + try { + localStorage.setItem(cacheKey, JSON.stringify({ lrc: lrcText, tlyric: '', rlyric: '', klyric: '' })); + } catch (e) { } + } + initLyricPlayer(); + applyLyricUpdate(); + return; + } + } + } catch (e) { + console.warn('[Lyric] WebDAV 歌词获取失败:', e); + } + renderLyric([], '暂无歌词'); + return; + } + // ===== 3. 从网络抓取最新歌词 ===== try { const params = new URLSearchParams({ @@ -9754,11 +9828,51 @@ function formatSongToLxMusicStandard(item) { case 'kw': rootItem.id = `kw_${songmid}`; break; + case 'openlist': + // OpenList 本地歌曲:保留播放所需的完整字段,收藏后可直接恢复播放 + rootItem.id = songmid; + if (s.url) rootItem.url = s.url; + if (s.serverId) rootItem.serverId = s.serverId; + if (s.path) rootItem.path = s.path; + if (s.sign) rootItem.sign = s.sign; + rootItem.openlist = true; + rootItem.isLocal = true; + rootItem.folder = s.folder || 'openlist'; + break; + case 'webdav': + // WebDAV 挂载歌曲:保留播放所需的完整字段,收藏后可直接恢复播放 + rootItem.id = songmid; + if (s.url) rootItem.url = s.url; + if (s.serverId) rootItem.serverId = s.serverId; + if (s.path) rootItem.path = s.path; + rootItem.webdav = true; + rootItem.isLocal = true; + rootItem.folder = s.folder || 'webdav'; + break; default: rootItem.id = songmid; break; } + // 兜底:保留本地歌曲的 url/文件字段(覆盖 openlist/webdav 等已在 case 中处理的场景) + if (s.openlist) { + if (s.url) rootItem.url = s.url; + if (s.serverId) rootItem.serverId = s.serverId; + if (s.path) rootItem.path = s.path; + if (s.sign) rootItem.sign = s.sign; + rootItem.openlist = true; + rootItem.isLocal = true; + rootItem.folder = s.folder || 'openlist'; + } + if (s.webdav) { + if (s.url) rootItem.url = s.url; + if (s.serverId) rootItem.serverId = s.serverId; + if (s.path) rootItem.path = s.path; + rootItem.webdav = true; + rootItem.isLocal = true; + rootItem.folder = s.folder || 'webdav'; + } + return rootItem; } @@ -11133,6 +11247,22 @@ function cleanSongData(song) { if (song.folder) cleanSong.folder = song.folder; if (song.filename) cleanSong.filename = song.filename; + // [OpenList] 保留本地播放所需字段,保证收藏/歌单恢复后可直链播放并读取歌词 + if (song.source === 'openlist' || song.openlist) { + if (song.serverId) cleanSong.serverId = song.serverId; + if (song.path) cleanSong.path = song.path; + if (song.sign) cleanSong.sign = song.sign; + cleanSong.openlist = true; + } + + // [WebDAV] 保留挂载播放所需字段,保证收藏/歌单恢复后可直链播放并读取歌词 + if (song.source === 'webdav' || song.webdav) { + if (song.serverId) cleanSong.serverId = song.serverId; + if (song.path) cleanSong.path = song.path; + cleanSong.webdav = true; + cleanSong.isLocal = true; + } + // Remove undefined keys const removeUndefined = (obj) => { Object.keys(obj).forEach(key => { @@ -11854,6 +11984,76 @@ function showSelect(title, message, options = {}) { }); } +/** + * 通用表单输入弹窗 + */ +function showInputModal({ title, message = '', fields = [], onConfirm = null, onCancel = null }) { + const modal = document.createElement('div'); + modal.className = "fixed inset-0 z-[200] flex items-center justify-center p-4 animate-fade-in"; + const fieldsHtml = fields.map((f, idx) => ` +
+ + +
+ `).join(''); + modal.innerHTML = ` +
+
+
+

${title}

+ +
+
+ ${message ? `

${message}

` : ''} + ${fieldsHtml} +
+
+ + +
+
+ `; + document.body.appendChild(modal); + + const close = (result, values = null) => { + const content = modal.querySelector('.max-w-sm'); + if (content) content.classList.add('scale-95', 'opacity-0'); + modal.classList.add('opacity-0'); + setTimeout(() => { + modal.remove(); + if (result) { + if (onConfirm && values) onConfirm(values); + } else { + if (onCancel) onCancel(); + } + }, 200); + }; + + modal.querySelector('#input-modal-ok').onclick = () => { + const values = {}; + let valid = true; + fields.forEach((f, idx) => { + const el = modal.querySelector(`#input-field-${idx}`); + const val = (el && el.value || '').trim(); + if (f.required && !val) valid = false; + values[f.id || idx] = val; + }); + if (!valid) { + if (typeof showError === 'function') showError('请填写所有必填项'); + return; + } + close(true, values); + }; + modal.querySelector('#input-modal-cancel').onclick = () => close(false); + modal.querySelector('#input-modal-close-x').onclick = () => close(false); + modal.querySelector('div:first-child').onclick = () => close(false); +} +window.showInputModal = showInputModal; + + /** * 通用多选选择列表 */ diff --git a/public/music/index.html b/public/music/index.html index e1c89912..ebc6349c 100644 --- a/public/music/index.html +++ b/public/music/index.html @@ -120,6 +120,13 @@

LX MUSIC

本地音乐
+
  • + + + OpenList + +
  • @@ -1007,6 +1014,14 @@

    本地 公开歌曲 + +

  • @@ -1183,6 +1198,8 @@

    本地 + +

    @@ -1316,6 +1333,74 @@

    本地

    + +
    + + +
    + + +
    + + +
    +
    @@ -1357,6 +1442,55 @@

    本地

    + + + - -
    + +
    + + +
    + + + +
    +
    + +
    + +
    -
    @@ -72,97 +92,226 @@

    LX Music Web

    +
    + + +
    -

    请联系管理员获取访问密码

    +
    - \ No newline at end of file + diff --git a/public/style.css b/public/style.css index 34d400c7..a84c1c90 100644 --- a/public/style.css +++ b/public/style.css @@ -67,6 +67,69 @@ body { animation: fadeInUp 0.6s ease-out; } +.login-stack { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + width: 100%; +} + +.login-features { + width: 420px; + max-width: 90%; + padding: 1.1rem 1.5rem; + text-align: left; + border-radius: 12px; + animation: fadeInUp 0.6s ease-out; +} + +.login-features h3 { + margin: 0 0 0.6rem; + font-size: 0.9rem; + font-weight: 600; + color: var(--accent-secondary); + text-align: center; +} + +.login-features ul { + margin: 0; + padding-left: 1.2rem; + color: var(--text-secondary); + font-size: 0.82rem; + line-height: 1.8; +} + +.login-features li b { + color: var(--text-primary); +} + +.release-note { + margin: 1.2rem 0; + padding: 1.2rem 1.5rem; + border-radius: 12px; + border-left: 4px solid var(--accent-secondary); +} + +.release-note-title { + font-size: 0.95rem; + font-weight: 600; + margin-bottom: 0.6rem; + color: var(--accent-secondary); +} + +.release-note-list { + margin: 0; + padding-left: 1.2rem; + color: var(--text-secondary); + font-size: 0.85rem; + line-height: 1.8; +} + +.release-note-list b { + color: var(--text-primary); +} + .login-header { display: flex; flex-direction: column; diff --git a/scripts/migrate-to-nas.sh b/scripts/migrate-to-nas.sh new file mode 100644 index 00000000..96a3a8f4 --- /dev/null +++ b/scripts/migrate-to-nas.sh @@ -0,0 +1,85 @@ +#!/bin/bash +# lxserver 迁移到飞牛 NAS 辅助脚本 +# 用法: bash scripts/migrate-to-nas.sh [输出目录] +# 默认输出到 /tmp/opencode/nas-deploy/ +set -e + +PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +OUT_DIR="${1:-/tmp/opencode/nas-deploy}" +TARBALL="${OUT_DIR}/lxserver-nas-deploy.tar.gz" + +if [ ! -d "${PROJECT_DIR}/data" ]; then + echo "错误: 未找到 ${PROJECT_DIR}/data 目录" + exit 1 +fi + +mkdir -p "${OUT_DIR}" + +# 1. 生成清洗后的 config.js(移除 users 的本地绝对 dataPath,NAS 上由系统重建) +echo "[1/3] 生成 NAS 用 config.js ..." +python3 - "${PROJECT_DIR}/config.js" "${OUT_DIR}/config.js" <<'PY' +import json, re, sys + +src, dst = sys.argv[1], sys.argv[2] +with open(src, 'r', encoding='utf-8') as f: + raw = f.read() + +# config.js 是 module.exports = {...},提取对象文本 +m = re.search(r'module\.exports\s*=\s*(\{.*\})\s*$', raw, re.S) +if not m: + # 兜底:直接复制 + with open(dst, 'w', encoding='utf-8') as f: + f.write(raw) + sys.exit(0) + +obj_text = m.group(1) +try: + obj = json.loads(obj_text) +except Exception: + # 含注释/单引号等 JSON5 风格,直接复制 + with open(dst, 'w', encoding='utf-8') as f: + f.write(raw) + sys.exit(0) + +# 清空每个用户的 dataPath,NAS 上由服务器按用户名重建 +for u in obj.get('users', []): + u['dataPath'] = '' + +with open(dst, 'w', encoding='utf-8') as f: + f.write('module.exports = ') + f.write(json.dumps(obj, ensure_ascii=False, indent=2)) + f.write('\n') +print(' config.js 已生成, 共', len(obj.get('users', [])), '个用户') +PY + +# 2. 复制整个 data 目录 +echo "[2/3] 打包 data/ 目录 ..." +STAGE="${OUT_DIR}/data" +rm -rf "${STAGE}" +mkdir -p "${STAGE}" +cp -a "${PROJECT_DIR}/data/." "${STAGE}/" + +# 3. 打包 +echo "[3/3] 生成部署包 ..." +cd "${OUT_DIR}" +tar -czf "${TARBALL}" config.js data +echo "" +echo "==== 部署包已生成: ${TARBALL} ====" +echo "" +echo "==== NAS 侧安装步骤 ====" +echo "1. 将 ${TARBALL} 上传到 NAS,并解压到项目目录:" +echo " mkdir -p /vol1/docker/lxserver && tar -xzf lxserver-nas-deploy.tar.gz -C /vol1/docker/lxserver" +echo "" +echo "2. 将项目代码(Dockerfile / docker-compose.yml / src / public 等)放到同一目录" +echo "" +echo "3. 修改 docker-compose.yml 映射端口后启动:" +echo " cd /vol1/docker/lxserver && docker compose up -d --build" +echo "" +echo "4. 访问:" +echo " 后台: http://:9527/" +echo " 播放器: http://:9527/music/" +echo "" +echo "==== 环境变量可选覆盖(docker-compose.yml 中配置,优先级高于 config.js)====" +echo " LX_USER_用户名=密码 # 追加/覆盖用户" +echo " WEBPLAYER_PASSWORD=密码 # 播放器访问密码" +echo " ENABLE_WEBPLAYER_AUTH=true # 开启播放器密码验证" diff --git a/src/defaultConfig.ts b/src/defaultConfig.ts index 8d2dbdef..2663fa07 100644 --- a/src/defaultConfig.ts +++ b/src/defaultConfig.ts @@ -14,6 +14,7 @@ const config: LX.Config = { 'user.enableLoginCacheRestriction': false, // 是否启用登录用户缓存限制 'user.enableCacheSizeLimit': false, // 是否启用缓存空间限制 'user.cacheSizeLimit': 2000, // 缓存空间限制大小 (MB) + 'user.enableOpenListInLocalMusic': true, // 是否将 OpenList 目录整合到本地音乐列表 maxSnapshotNum: 10, // 公共最大备份快照数 'list.addMusicLocationType': 'top', // 公共添加歌曲到我的列表时的位置 top | bottom,参考客户端的「设置 → 列表设置 → 添加歌曲到列表时的位置」 @@ -44,6 +45,8 @@ const config: LX.Config = { // Web播放器配置 'player.enableAuth': false, 'player.password': '123456', + 'player.forceLogin': true, // 是否强制登录(未注册/未登录用户无法进入播放器) + 'player.enableRegister': true, // 是否开放注册(允许使用卡密注册新账号) // 代理配置 'proxy.all.enabled': false, diff --git a/src/index.ts b/src/index.ts index 0f7a6123..535f91d5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import fs from 'fs' import path from 'path' import crypto from 'crypto' +import dns from 'dns' import moduleAlias from 'module-alias' // @ts-ignore moduleAlias.addAliases({ @@ -11,6 +12,14 @@ moduleAlias.addAliases({ '@': __dirname }) +// 强制 DNS 优先 IPv4:部分环境 IPv6 路由不可达(如 alist 域名解析出 AAAA 后 ENETUNREACH) +// 需在 http/https 请求前设置,覆盖 needle / webdav / 原生 http 等所有出站请求 +try { + dns.setDefaultResultOrder('ipv4first') +} catch (e) { + // Node <17 不支持该 API,忽略 +} + if (typeof (global as any).navigator === 'undefined') { (global as any).navigator = { userAgent: 'node.js' } } @@ -468,6 +477,8 @@ if (webdavSync.isConfigured()) { } // 启动自动同步 webdavSync.startAutoSync() + }).catch((err: any) => { + console.error('WebDAV restore failed (non-fatal):', err) }) } else { console.log('WebDAV not configured, skipping remote restore') diff --git a/src/server/cards.ts b/src/server/cards.ts new file mode 100644 index 00000000..4e151b59 --- /dev/null +++ b/src/server/cards.ts @@ -0,0 +1,116 @@ +import * as fs from 'fs' +import * as path from 'path' +import * as crypto from 'crypto' + +const CARDS_FILE = 'cards.json' + +interface Card { + id: string + code: string + status: 'unused' | 'used' + createdAt: number + usedAt: number | null + boundUser: string | null + remark: string | null + expireDays: number | null +} + +let cards: Card[] = [] + +const cardsPath = () => path.join(global.lx.dataPath, CARDS_FILE) + +const loadCards = (): void => { + const p = cardsPath() + if (fs.existsSync(p)) { + try { + cards = JSON.parse(fs.readFileSync(p, 'utf8')) + if (!Array.isArray(cards)) cards = [] + } catch (e) { + cards = [] + } + } +} + +const saveCards = (): void => { + try { + fs.writeFileSync(cardsPath(), JSON.stringify(cards, null, 2), 'utf8') + } catch (e) { + console.error('[Cards] Failed to save cards:', e) + } +} + +const generateCode = (): string => { + const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + let code = '' + const bytes = crypto.randomBytes(16) + for (let i = 0; i < 16; i++) { + code += alphabet[bytes[i] % alphabet.length] + if (i % 4 === 3 && i < 15) code += '-' + } + return code +} + +/** + * 批量生成卡密 + */ +export const generateCards = (count: number, expireDays: number | null, remark: string | null): Card[] => { + loadCards() + const now = Date.now() + const newCards: Card[] = [] + for (let i = 0; i < count; i++) { + newCards.push({ + id: crypto.randomBytes(8).toString('hex'), + code: generateCode(), + status: 'unused', + createdAt: now, + usedAt: null, + boundUser: null, + remark: remark || null, + expireDays: expireDays && expireDays > 0 ? expireDays : null, + }) + } + cards = [...cards, ...newCards] + saveCards() + return newCards +} + +export const listCards = (): Card[] => { + loadCards() + return cards.slice().sort((a, b) => b.createdAt - a.createdAt) +} + +/** + * 删除卡密(支持批量) + */ +export const deleteCards = (ids: string[]): number => { + loadCards() + const before = cards.length + cards = cards.filter(c => !ids.includes(c.id)) + saveCards() + return before - cards.length +} + +/** + * 校验并占用一张卡密 + * @returns 占用成功返回 true,失败抛出错误 + */ +export const consumeCard = (code: string, boundUser: string): boolean => { + loadCards() + const normalized = code.trim().toUpperCase() + const card = cards.find(c => c.code === normalized) + if (!card) throw new Error('卡密不存在') + if (card.status === 'used') throw new Error('卡密已被使用') + if (card.expireDays) { + const expireAt = card.createdAt + card.expireDays * 24 * 60 * 60 * 1000 + if (Date.now() > expireAt) throw new Error('卡密已过期') + } + card.status = 'used' + card.usedAt = Date.now() + card.boundUser = boundUser + saveCards() + return true +} + +export const initCards = (): void => { + loadCards() +} diff --git a/src/server/fileCache.ts b/src/server/fileCache.ts index 19646de3..9d5f9cfd 100644 --- a/src/server/fileCache.ts +++ b/src/server/fileCache.ts @@ -2484,16 +2484,38 @@ export const serveCacheFile = (req: http.IncomingMessage, res: http.ServerRespon const range = req.headers.range if (range) { const parts = range.replace(/bytes=/, "").split("-") - const start = parseInt(parts[0], 10) - const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1 + // [Fix] 支持 suffix range(如 bytes=-500)与无效 range,避免 ERR_OUT_OF_RANGE 崩溃 + let start: number + let end: number + if (parts[0] === '') { + // suffix range: 请求最后 N 字节 + const suffix = parseInt(parts[1], 10) + if (!Number.isFinite(suffix) || suffix <= 0) { + res.writeHead(416, { 'Content-Range': `bytes */${stat.size}` }) + res.end() + return + } + start = Math.max(0, stat.size - suffix) + end = stat.size - 1 + } else { + start = parseInt(parts[0], 10) + end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1 + } + if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start >= stat.size || end < start) { + res.writeHead(416, { 'Content-Range': `bytes */${stat.size}` }) + res.end() + return + } + if (end >= stat.size) end = stat.size - 1 const chunksize = (end - start) + 1 res.writeHead(206, { 'Content-Range': `bytes ${start}-${end}/${stat.size}`, 'Accept-Ranges': 'bytes', 'Content-Length': chunksize, 'Content-Type': contentType, + 'Cache-Control': 'no-cache', }) fs.createReadStream(filePath, { start, end }).pipe(res) } else { - res.writeHead(200, { 'Content-Length': stat.size, 'Content-Type': contentType, 'Accept-Ranges': 'bytes' }) + res.writeHead(200, { 'Content-Length': stat.size, 'Content-Type': contentType, 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-cache' }) fs.createReadStream(filePath).pipe(res) } } diff --git a/src/server/hostResolver.test.ts b/src/server/hostResolver.test.ts new file mode 100644 index 00000000..0cf67d1d --- /dev/null +++ b/src/server/hostResolver.test.ts @@ -0,0 +1,49 @@ +import { test } from 'node:test' +import assert from 'node:assert' +import { parseHostsFile, parseRouteFile, resolveHost } from './hostResolver' + +test('parseHostsFile 从 /etc/hosts 提取 host.docker.internal 映射', () => { + const content = [ + '127.0.0.1\tlocalhost', + '172.17.0.1\thost.docker.internal', + '192.168.2.100\thost.docker.internal lxhost', + ].join('\n') + assert.strictEqual(parseHostsFile(content), '172.17.0.1') +}) + +test('parseHostsFile 无映射时返回空串', () => { + assert.strictEqual(parseHostsFile('127.0.0.1\tlocalhost\n'), '') +}) + +test('parseRouteFile 从 /proc/net/route 提取默认网关 IP', () => { + const content = [ + 'Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT', + 'eth0\t00000000\t0110A8C0\t0003\t0\t0\t0\t00000000\t0\t0\t0', + ].join('\n') + assert.strictEqual(parseRouteFile(content), '192.168.16.1') +}) + +test('parseRouteFile 无默认路由时返回空串', () => { + const content = [ + 'Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT', + 'eth0\t0010A8C0\t00000000\t0001\t0\t0\t0\t00F0FFFF\t0\t0\t0', + ].join('\n') + assert.strictEqual(parseRouteFile(content), '') +}) + +test('resolveHost 将 host.docker.internal 替换为环境变量指定地址', () => { + const prev = process.env.LX_HOST_ADDR + process.env.LX_HOST_ADDR = '192.168.2.100' + try { + assert.strictEqual(resolveHost('http://host.docker.internal:5244/api'), 'http://192.168.2.100:5244/api') + assert.strictEqual(resolveHost('http://HOST.DOCKER.INTERNAL:5244'), 'http://192.168.2.100:5244') + } finally { + if (prev === undefined) delete process.env.LX_HOST_ADDR + else process.env.LX_HOST_ADDR = prev + } +}) + +test('resolveHost 不包含 host.docker.internal 时原样返回', () => { + assert.strictEqual(resolveHost('http://192.168.2.100:5244/api'), 'http://192.168.2.100:5244/api') + assert.strictEqual(resolveHost(''), '') +}) diff --git a/src/server/hostResolver.ts b/src/server/hostResolver.ts new file mode 100644 index 00000000..925c8c24 --- /dev/null +++ b/src/server/hostResolver.ts @@ -0,0 +1,79 @@ +import * as fs from 'fs' + +/** + * 解析容器内访问宿主机服务的可用地址。 + * + * fnOS / 部分 Docker 环境下 `extra_hosts: host.docker.internal:host-gateway` + * 经常不生效,容器内无法解析 host.docker.internal,导致服务端请求宿主机上的 + * Alist/WebDAV 失败、内网歌曲无法播放。这里在发起出站请求前把 + * host.docker.internal 自动替换为宿主机可达地址。 + */ + +let cachedHostAddr: string | null = null + +/** 从 /etc/hosts 内容中提取 host.docker.internal 的映射 IP */ +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] + } + return '' +} + +/** 从 /proc/net/route 内容中提取默认网关 IP(bridge 网络下即宿主机) */ +export const parseRouteFile = (content: string): string => { + for (const line of content.split('\n').slice(1)) { + const cols = line.trim().split(/\s+/) + if (cols.length >= 3 && cols[1] === '00000000') { + const gwHex = cols[2] + if (/^[0-9a-fA-F]{8}$/.test(gwHex) && gwHex !== '00000000') { + const ip = [ + gwHex.slice(6, 8), gwHex.slice(4, 6), gwHex.slice(2, 4), gwHex.slice(0, 2), + ].map(h => parseInt(h, 16)).join('.') + if (!ip.includes('NaN')) return ip + } + } + } + return '' +} + +/** 探测宿主机在容器网络中的可达 IP */ +export const getHostAddr = (): string => { + // 1. 环境变量显式指定(最高优先级,用户可在 compose 中配置;实时读取) + const envAddr = process.env.LX_HOST_ADDR || process.env.HOST_ADDR + if (envAddr && envAddr.trim()) return envAddr.trim() + + // 文件探测结果缓存(进程内固定) + if (cachedHostAddr !== null) return cachedHostAddr + + // 2. /etc/hosts 中 host.docker.internal 的映射(extra_hosts host-gateway 生效时存在) + try { + const hosts = fs.readFileSync('/etc/hosts', 'utf8') + const ip = parseHostsFile(hosts) + if (ip) { + cachedHostAddr = ip + return cachedHostAddr + } + } catch (e) { /* ignore */ } + + // 3. 默认网关 IP(bridge 网络下默认网关即宿主机) + try { + const routes = fs.readFileSync('/proc/net/route', 'utf8') + const ip = parseRouteFile(routes) + if (ip) { + cachedHostAddr = ip + return cachedHostAddr + } + } catch (e) { /* ignore */ } + + cachedHostAddr = '' + return cachedHostAddr +} + +/** 将 URL 中的 host.docker.internal 替换为宿主机可达地址;解析失败则原样返回 */ +export const resolveHost = (url: string): string => { + if (!url || !/host\.docker\.internal/i.test(url)) return url + const addr = getHostAddr() + if (!addr) return url + return url.replace(/host\.docker\.internal/gi, addr) +} diff --git a/src/server/openlist.ts b/src/server/openlist.ts new file mode 100644 index 00000000..182ed20f --- /dev/null +++ b/src/server/openlist.ts @@ -0,0 +1,633 @@ +import * as fs from 'fs' +import * as path from 'path' +import * as crypto from 'crypto' +import * as http from 'http' +import * as https from 'https' +import needle from 'needle' +import iconv from 'iconv-lite' +import { resolveHost } from './hostResolver' + +const CONFIG_FILE = 'openlist.json' + +interface OpenListServer { + id: string + name: string + baseUrl: string + username: string + password: string + token: string + rootPath: string + enabled: boolean + createdAt: number +} + +interface OpenListConfig { + servers: OpenListServer[] +} + +const defaultConfig: OpenListConfig = { + servers: [], +} + +let config: OpenListConfig = { servers: [] } + +const configPath = () => path.join(global.lx.dataPath, CONFIG_FILE) + +// token 缓存:服务器 id -> token +const tokenCache: Record = {} + +const now = () => Date.now() + +export const loadConfig = (): OpenListConfig => { + const p = configPath() + if (fs.existsSync(p)) { + try { + const parsed = JSON.parse(fs.readFileSync(p, 'utf8')) + config = { + servers: Array.isArray(parsed.servers) ? parsed.servers : [], + } + } catch (e) { + config = { servers: [] } + } + } + return config +} + +export const saveConfig = (): void => { + try { + fs.writeFileSync(configPath(), JSON.stringify(config, null, 2), 'utf8') + } catch (e) { + console.error('[OpenList] Failed to save config:', e) + } +} + +export const getConfig = (): OpenListConfig => config + +export const listServers = (): OpenListServer[] => { + loadConfig() + return config.servers.slice().sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)) +} + +export const getServer = (id: string): OpenListServer | null => { + loadConfig() + return config.servers.find(s => s.id === id) || null +} + +const normalizeServer = (data: any): OpenListServer => { + let baseUrl = String(data.baseUrl || '').trim().replace(/\/+$/, '') + // [Fix] 内网地址修复:无协议时默认补 http://(内网 OpenList 多为 HTTP 服务),保留用户已填写的协议 + if (baseUrl && !/^https?:\/\//i.test(baseUrl)) baseUrl = 'http://' + baseUrl + return { + id: data.id || crypto.randomBytes(8).toString('hex'), + name: String(data.name || 'OpenList').trim(), + baseUrl, + username: String(data.username || '').trim(), + password: String(data.password || ''), + token: String(data.token || '').trim(), + rootPath: String(data.rootPath || '/').trim() || '/', + enabled: data.enabled !== false, + createdAt: data.createdAt || now(), + } +} + +export const addServer = (data: any): OpenListServer => { + loadConfig() + if (!data.baseUrl) throw new Error('缺少 OpenList 地址') + const server = normalizeServer(data) + config.servers.push(server) + saveConfig() + return server +} + +export const updateServer = (id: string, data: any): OpenListServer | null => { + loadConfig() + const idx = config.servers.findIndex(s => s.id === id) + if (idx < 0) return null + const merged = normalizeServer({ ...config.servers[idx], ...data, id }) + config.servers[idx] = merged + delete tokenCache[id] + saveConfig() + return merged +} + +export const deleteServer = (id: string): boolean => { + loadConfig() + const before = config.servers.length + config.servers = config.servers.filter(s => s.id !== id) + delete tokenCache[id] + saveConfig() + return config.servers.length < before +} + +const encodePath = (p: string): string => { + const cleaned = p || '/' + const segments = cleaned.split('/').filter(Boolean) + return '/' + segments.map(s => encodeURIComponent(s)).join('/') +} + +const request = (server: OpenListServer, method: string, urlPath: string, data?: any, headers?: any, isDownload = false): Promise => { + return new Promise((resolve, reject) => { + const baseUrl = resolveHost(server.baseUrl || '') + const opts: any = { json: !isDownload, timeout: 30000, headers: {} } + if (headers) opts.headers = { ...headers } + needle.request(method as any, `${baseUrl}${urlPath}`, isDownload ? data : data, opts, (err: any, resp: any) => { + if (err) return reject(new Error(err.message || 'Network error')) + const body = resp.body + if (resp.statusCode && resp.statusCode >= 400) { + const msg = body && (body.message || body.error || body.error_description) || `HTTP ${resp.statusCode}` + const err2: any = new Error(msg) + err2.code = resp.statusCode + err2.body = body + return reject(err2) + } + if (isDownload) return resolve(resp) + if (body && typeof body === 'object' && body.code !== undefined) { + if (body.code === 200) return resolve(body.data !== undefined ? body.data : body) + const msg = body.message || body.error || `OpenList error ${body.code}` + const err3: any = new Error(msg) + err3.code = body.code + return reject(err3) + } + resolve(body) + }) + }) +} + +/** + * 登录获取 token(使用配置的用户名/密码) + */ +export const login = async (server: OpenListServer): Promise => { + if (!server.username || !server.password) throw new Error('未配置用户名/密码,无法登录') + const res = await request(server, 'POST', '/api/auth/login', { + username: server.username, + password: server.password, + }) + const token = res && (res.token || (res.data && res.data.token)) + if (!token) throw new Error('登录失败: ' + JSON.stringify(res || {})) + tokenCache[server.id] = { token, expireAt: now() + 3600 * 1000 } + return token +} + +/** + * 获取有效的 Authorization 值: + * 优先使用手动 token;否则尝试用户名/密码登录;都没有则返回空(guest 访问) + */ +export const ensureToken = async (server: OpenListServer): Promise => { + if (server.token) return server.token + const cached = tokenCache[server.id] + if (cached && cached.token && cached.expireAt > now()) return cached.token + if (server.username && server.password) { + try { + return await login(server) + } catch (e) { + console.error('[OpenList] login failed:', (e as any).message) + return '' + } + } + return '' +} + +/** + * 获取文件列表 + */ +export const listFiles = async (server: OpenListServer, dirPath: string, page = 1, perPage = 0): Promise => { + const token = await ensureToken(server) + const headers: Record = {} + if (token) headers['Authorization'] = token + const res = await request(server, 'POST', '/api/fs/list', { + path: dirPath || '/', + password: '', + page, + per_page: perPage, + refresh: false, + }, headers) + return res || { content: [], total: 0, write: false } +} + +/** + * 搜索文件(仅对当前服务器内搜索) + */ +export const searchFiles = async (server: OpenListServer, keyword: string, page = 1, perPage = 0): Promise => { + const token = await ensureToken(server) + const headers: Record = {} + if (token) headers['Authorization'] = token + try { + const res = await request(server, 'POST', '/api/fs/search', { + parent: server.rootPath || '/', + keywords: keyword, + page, + per_page: perPage, + scope: 0, + }, headers) + return res || { content: [], total: 0, write: false } + } catch (e: any) { + if (e && (e.code === 404 || e.code === 400)) { + return { content: [], total: 0, write: false } + } + throw e + } +} + +/** + * 获取文件的下载链接(/d/ 直链,带 sign) + */ +export const getDownloadUrl = async (server: OpenListServer, filePath: string, sign?: string): Promise => { + // 始终优先实时获取最新 sign:收藏/歌单中固化的 sign 可能已过期(Alist sign 有时效), + // 服务端有 token 能力时用最新 sign 直链,避免旧 sign 导致播放失败。 + let s = '' + try { + const token = await ensureToken(server) + const headers: Record = {} + if (token) headers['Authorization'] = token + const info = await request(server, 'POST', '/api/fs/get', { + path: filePath, + password: '', + }, headers) + s = info && info.sign ? info.sign : '' + } catch (e) { + s = '' + } + // 实时获取失败(如未配置账号的 guest 场景)时回退到调用方传入的 sign + if (!s) s = sign || '' + const q = s ? `?sign=${encodeURIComponent(s)}` : '' + return `${resolveHost(server.baseUrl)}/d${encodePath(filePath)}${q}` +} + +/** + * 代理下载/流式播放(支持 Range)。 + * 注意:不使用 needle(其 3.x 在流式响应约 130KB 后会卡死),改用原生 http/https。 + * 返回的 ClientRequest 通过 'response'/'error' 事件暴露上游响应流。 + */ +export const stream = async (server: OpenListServer, filePath: string, sign: string | undefined, range?: string): Promise => { + const url = await getDownloadUrl(server, filePath, sign) + const targetUrl = new URL(url) + const headers: Record = { + 'User-Agent': 'lxserver/1.0', + } + const token = await ensureToken(server) + if (token) headers['Authorization'] = token + if (range) headers['Range'] = range + const lib = targetUrl.protocol === 'https:' ? https : http + const req = lib.request(targetUrl, { method: 'GET', headers } as any) + req.on('error', () => { /* 错误由调用方处理 */ }) + req.end() + return req +} + +/** + * 本地缓存目录:/openlist-cache// + */ +export const getCacheDir = (server: OpenListServer): string => { + const dir = path.join(global.lx.dataPath, 'openlist-cache', server.id) + try { fs.mkdirSync(dir, { recursive: true }) } catch (e) { /* ignore */ } + return dir +} + +/** + * 计算文件在本地缓存中的路径(hash 命名,保留扩展名) + */ +export const getCacheFilePath = (server: OpenListServer, filePath: string): string => { + const hash = crypto.createHash('md5').update(server.id + ':' + filePath).digest('hex') + const ext = path.extname(filePath || '').toLowerCase() || '.mp3' + return path.join(getCacheDir(server), hash + ext) +} + +/** + * 播放缓存进度:serverId:path -> { total, received, done } + */ +const cacheProgress = new Map() + +const cacheProgressKey = (serverId: string, filePath: string) => serverId + ':' + filePath + +export const getCacheProgress = (serverId: string, filePath: string) => { + return cacheProgress.get(cacheProgressKey(serverId, filePath)) || null +} + +/** + * 服务本地缓存文件(支持 Range),返回是否已完整缓存 + */ +export const serveCacheFile = (filePath: string, range: string | undefined, res: any): boolean => { + if (!fs.existsSync(filePath)) return false + const stat = fs.statSync(filePath) + const ext = path.extname(filePath).toLowerCase() + const mimeTypes: Record = { + '.mp3': 'audio/mpeg', '.flac': 'audio/flac', '.m4a': 'audio/mp4', '.ogg': 'audio/ogg', '.wav': 'audio/wav', + '.ape': 'audio/x-ape', '.opus': 'audio/ogg', '.aac': 'audio/aac', '.wma': 'audio/x-ms-wma', + } + const contentType = mimeTypes[ext] || 'application/octet-stream' + if (range) { + const parts = range.replace(/bytes=/, '').split('-') + const start = parseInt(parts[0], 10) + const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1 + if (!Number.isFinite(start) || start < 0 || start >= stat.size || (parts[1] && end < start)) { + res.writeHead(416, { 'Content-Range': `bytes */${stat.size}` }) + res.end() + return true + } + const chunksize = (end - start) + 1 + res.writeHead(206, { + 'Content-Range': `bytes ${start}-${end}/${stat.size}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': chunksize, + 'Content-Type': contentType, + 'Cache-Control': 'no-cache', + }) + fs.createReadStream(filePath, { start, end }).pipe(res) + return true + } + res.writeHead(200, { + 'Content-Length': stat.size, + 'Content-Type': contentType, + 'Accept-Ranges': 'bytes', + 'Cache-Control': 'no-cache', + }) + fs.createReadStream(filePath).pipe(res) + return true +} + +/** + * 更新缓存进度(写入 .tmp 时调用) + */ +export const trackCacheProgress = (serverId: string, filePath: string, total: number, received: number) => { + cacheProgress.set(cacheProgressKey(serverId, filePath), { total, received, done: false }) +} + +export const markCacheDone = (serverId: string, filePath: string) => { + const key = cacheProgressKey(serverId, filePath) + const prev = cacheProgress.get(key) + cacheProgress.set(key, { total: prev?.total || 0, received: prev?.received || 0, done: true }) +} + +export const clearCacheProgress = (serverId: string, filePath: string) => { + cacheProgress.delete(cacheProgressKey(serverId, filePath)) +} + +/** + * 判断指定文件是否已完整缓存到本地 + */ +export const isFileCached = (server: OpenListServer, filePath: string): boolean => { + return fs.existsSync(getCacheFilePath(server, filePath)) +} + +// ===== OpenList 本地音乐整合:递归扫描目录树收集音频文件 ===== + +const AUDIO_EXT_RE = /\.(mp3|flac|wav|ogg|aac|m4a|ape|wma|opus|alac)$/i + +// 本地音乐索引缓存:serverId -> { files, at },避免每次请求都全量递归扫描远程目录树 +const localIndexCache: Record }> = {} +const LOCAL_INDEX_TTL = 120 * 1000 // 2 分钟 +const MAX_SCAN_DEPTH = 20 // 防止深层目录爆炸 +const MAX_SCAN_FILES = 5000 // 单服务器最多收集文件数,防止超大目录 +const MAX_SCAN_DIRS = 800 // 单服务器最多访问目录数,防止远程挂载网盘爆炸 +const MAX_SCAN_MS = 60 * 1000 // 单次扫描总时长上限 +const SCAN_CONCURRENCY = 6 // 目录列表并发数,控制远程压力 + +/** + * 带超时的 listFiles:避免 needle 对超大目录/远程网盘永久挂起 + */ +const listFilesWithTimeout = async (server: OpenListServer, dirPath: string, timeoutMs = 20000): Promise => { + return Promise.race([ + listFiles(server, dirPath, 1, 0), + new Promise((resolve) => setTimeout(() => resolve({ content: [], total: 0 }), timeoutMs)), + ]) +} + +/** + * 递归收集目录树下的所有音频文件(映射为本地音乐 CacheItem 兼容结构) + */ +const collectAudioFiles = async (server: OpenListServer, dirPath: string, depth = 0, result: any[] = [], ctx: { dirCount: number; deadline: number } = { dirCount: 0, deadline: Date.now() + MAX_SCAN_MS }): Promise => { + if (depth > MAX_SCAN_DEPTH || result.length >= MAX_SCAN_FILES || ctx.dirCount >= MAX_SCAN_DIRS || Date.now() > ctx.deadline) return result + let list: any + try { + list = await listFilesWithTimeout(server, dirPath) + } catch (e: any) { + return result + } + ctx.dirCount++ + const content: any[] = (list && list.content) || [] + const subDirs: string[] = [] + // 跳过 WebDAV 同步目录:其中的缓存/备份文件会被误索引为远程音乐(自我污染) + const skipDirs = new Set(['lx-sync', 'lx-sync-backups']) + for (const it of content) { + if (result.length >= MAX_SCAN_FILES || ctx.dirCount >= MAX_SCAN_DIRS || Date.now() > ctx.deadline) break + if (it.is_dir) { + if (skipDirs.has(it.name)) continue + const childPath = (dirPath === '/' ? '' : dirPath) + '/' + it.name + subDirs.push(childPath) + continue + } + if (!it.name || !AUDIO_EXT_RE.test(it.name)) continue + const ext = (path.extname(it.name) || '.mp3').toLowerCase().slice(1) + const fullPath = (dirPath === '/' ? '' : dirPath) + '/' + it.name + const id = `openlist_${encodeURIComponent(fullPath)}` + const subPath = (dirPath === '/' ? '' : dirPath) + const modified = typeof it.modified === 'number' ? it.modified : Date.parse(String(it.modified || '')) || 0 + result.push({ + id, + songmid: id, + songId: id, + name: it.name.replace(/\.[^.]+$/, ''), + singer: '', + album: '', + albumId: '', + source: 'openlist', + downloadSource: 'openlist', + sourceName: server.name, + quality: ext === 'flac' ? 'flac' : ext, + filename: fullPath, + folder: 'openlist', + subPath, + mtime: modified || Date.now(), + size: it.size || 0, + ext, + hasCover: false, + coverType: 'none', + hasLyric: false, + serverId: server.id, + path: fullPath, + sign: it.sign || '', + isLocal: true, + openlist: true, + interval: 0, + url: `/api/openlist/stream?server=${encodeURIComponent(server.id)}&path=${encodeURIComponent(fullPath)}${it.sign ? `&sign=${encodeURIComponent(it.sign)}` : ''}`, + }) + } + // 并发遍历子目录(限流),避免远程挂载网盘造成的串行长耗时 + let idx = 0 + while (idx < subDirs.length) { + const batch = subDirs.slice(idx, idx + SCAN_CONCURRENCY) + idx += SCAN_CONCURRENCY + await Promise.all(batch.map(dir => collectAudioFiles(server, dir, depth + 1, result, ctx))) + if (Date.now() > ctx.deadline || ctx.dirCount >= MAX_SCAN_DIRS || result.length >= MAX_SCAN_FILES) break + } + return result +} + +/** + * 获取某服务器的本地音乐索引(带缓存,forceRefresh 强制重新扫描) + */ +export const getLocalIndex = (serverId: string, forceRefresh = false): Promise => { + const server = getServer(serverId) + if (!server || !server.enabled || !server.baseUrl) return Promise.resolve([]) + const cached = localIndexCache[serverId] + if (!forceRefresh && cached && cached.files && Date.now() - cached.at < LOCAL_INDEX_TTL) { + return Promise.resolve(cached.files) + } + if (!forceRefresh && cached && cached.pending) { + return cached.pending + } + const pending = collectAudioFiles(server, server.rootPath || '/').then(files => { + localIndexCache[serverId] = { files, at: Date.now() } + return files + }) + if (!cached) localIndexCache[serverId] = { files: [], at: 0, pending } + else localIndexCache[serverId] = { ...cached, pending } + return pending +} + +/** + * 获取所有启用 OpenList 服务器的本地音乐索引(合并) + */ +export const getAllLocalIndex = (forceRefresh = false): Promise => { + const servers = listServers().filter(s => s.enabled && s.baseUrl) + return Promise.all(servers.map(s => getLocalIndex(s.id, forceRefresh))).then(groups => { + const merged: any[] = [] + groups.forEach(group => merged.push(...group)) + return merged + }) +} + +export const clearLocalIndex = (serverId?: string): void => { + if (serverId) delete localIndexCache[serverId] + else Object.keys(localIndexCache).forEach(k => delete localIndexCache[k]) +} + +/** + * 智能解码歌词文本:优先按 UTF-8 严格解码,失败回退 GB18030(远端常见 GBK 编码 .lrc)。 + * 不使用 TextDecoder('gb18030'):Alpine/Docker 的 node 仅 small-icu,不支持该编码。 + */ +const decodeText = (buf: Buffer): string => { + if (!buf || !buf.length) return '' + try { + const utf8 = new TextDecoder('utf-8', { fatal: true }).decode(buf) + if (!utf8.includes('\uFFFD')) return utf8 + } catch (e) { /* 非合法 UTF-8,回退 GB18030 */ } + try { + return iconv.decode(buf, 'gb18030') + } catch (e) { + return buf.toString('utf-8') + } +} + +/** + * 原生 HTTP GET 获取原始字节(needle 始终将 body 解码为 string,GBK 歌词会丢失原始字节) + */ +const httpGetBuffer = (url: string, headers: Record, timeoutMs = 20000): Promise => new Promise((resolve, reject) => { + let u: URL + try { u = new URL(url) } catch (e) { return reject(e as Error) } + const lib = u.protocol === 'https:' ? https : http + const req = lib.get(u, { headers } as any, (res: any) => { + const chunks: Buffer[] = [] + res.on('data', (c: any) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c))) + res.on('end', () => resolve(Buffer.concat(chunks))) + res.on('error', reject) + }) + req.on('error', reject) + req.setTimeout(timeoutMs, () => req.destroy(new Error('fetch timeout'))) +}) + +/** + * 获取同目录歌词(path 形如 /dir/song.mp3,找 /dir/song.lrc) + */ +export const getLyric = async (server: OpenListServer, filePath: string, sign?: string): Promise => { + const dir = path.posix.dirname(filePath === '/' ? '/' : filePath) + const baseName = path.posix.basename(filePath || '').replace(/\.[^.]+$/, '') + const lyricName = baseName + '.lrc' + const token = await ensureToken(server) + const headers: Record = {} + if (token) headers['Authorization'] = token + const list = await request(server, 'POST', '/api/fs/list', { + path: dir || '/', + password: '', + page: 1, + per_page: 0, + refresh: false, + }, headers) + const content: any[] = (list && list.content) || [] + const match = content.find((it: any) => !it.is_dir && it.name && it.name.toLowerCase() === lyricName.toLowerCase()) + if (!match) return '' + const lyricUrl = await getDownloadUrl(server, path.posix.join(dir, match.name).replace(/\/{2,}/g, '/'), match.sign) + const buf = await httpGetBuffer(lyricUrl, headers) + if (buf && buf.length) return decodeText(buf) + return '' +} + +/** + * 上传歌曲到 OpenList:将 sourceUrl 下载到临时文件,再 PUT 到目标目录 + */ +export const uploadFromUrl = async (server: OpenListServer, sourceUrl: string, fileName: string, dirPath: string, tmpDir: string): Promise => { + const token = await ensureToken(server) + if (!token) throw new Error('OpenList 需要登录或配置 token 才能上传') + const safeName = String(fileName || 'song.mp3').replace(/[\\/:*?"<>|]/g, '_') + const tmpFile = path.join(tmpDir, `${now()}_${Math.random().toString(36).slice(2, 8)}_${safeName}`) + try { + await new Promise((resolve, reject) => { + const fileStream = fs.createWriteStream(tmpFile) + const downloadReq = needle.get(sourceUrl, { timeout: 0, follow_max: 5 }) + downloadReq.on('error', (e: any) => reject(e)) + downloadReq.pipe(fileStream) + fileStream.on('finish', () => resolve()) + fileStream.on('error', (e: any) => reject(e)) + }) + const targetDir = (dirPath || server.rootPath || '/music').replace(/\/{2,}/g, '/') + const targetPath = path.posix.join(targetDir, safeName).replace(/\/{2,}/g, '/') + const resp = await new Promise((resolve, reject) => { + const fileStream = fs.createReadStream(tmpFile) + const opts: any = { + timeout: 0, + headers: { + 'Content-Type': 'application/octet-stream', + 'Authorization': token, + }, + } + const putReq = needle.put(`${resolveHost(server.baseUrl)}/api/fs/put?path=${encodeURIComponent(targetPath)}`, fileStream, opts, (err: any, r: any) => { + if (err) return reject(new Error(err.message || 'Upload failed')) + if (r.statusCode && r.statusCode >= 400) { + const msg = r.body && (r.body.message || r.body.error) || `HTTP ${r.statusCode}` + return reject(new Error(msg)) + } + resolve(r.body) + }) + putReq.on('error', (e: any) => reject(e)) + }) + try { fs.unlinkSync(tmpFile) } catch (e) { /* ignore */ } + return resp + } catch (e: any) { + try { fs.unlinkSync(tmpFile) } catch (e2) { /* ignore */ } + throw e + } +} + +/** + * 测试连接:验证 baseUrl 可达且能列出根目录 + */ +export const testConnection = async (id: string): Promise<{ ok: boolean; message: string }> => { + const server = getServer(id) + if (!server) return { ok: false, message: '服务器不存在' } + try { + const token = await ensureToken(server) + const headers: Record = {} + if (token) headers['Authorization'] = token + const res = await request(server, 'POST', '/api/fs/list', { + path: server.rootPath || '/', + password: '', + page: 1, + per_page: 1, + refresh: false, + }, headers) + const content: any[] = (res && res.content) || [] + return { ok: true, message: `连接成功,共 ${res && res.total !== undefined ? res.total : content.length} 项` } + } catch (e: any) { + return { ok: false, message: e.message || '连接失败' } + } +} diff --git a/src/server/server.ts b/src/server/server.ts index 1390ffe0..1a660e60 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -28,11 +28,15 @@ import { getDownloadQualityCandidates } from './downloadQuality' import crypto from 'node:crypto' import needle from 'needle' const { MusicTagger, MetaPicture } = require('music-tag-native') +import * as cards from './cards' +import * as openlist from './openlist' +import * as webdavMount from './webdavMount' // ===== Player Session Store ===== const playerSessions = new Map() const SESSION_TTL = 24 * 60 * 60 * 1000 // 24小时 const SESSION_COOKIE_NAME = 'lx_player_session' +const USER_TOKEN_COOKIE_NAME = 'lx_player_user_token' /** 生成随机 sessionId */ const generateSessionId = () => crypto.randomBytes(32).toString('hex') @@ -50,17 +54,27 @@ const parseCookies = (cookieHeader: string | undefined): Record /** 检查请求是否携带有效的 Player Session Cookie */ const checkPlayerAuth = (req: IncomingMessage): boolean => { - if (!global.lx.config['player.enableAuth']) return true // 未开启认证,直接放行 + if (!global.lx.config['player.enableAuth'] && !global.lx.config['player.forceLogin']) return true // 未开启认证,直接放行 const cookies = parseCookies(req.headers['cookie']) const sessionId = cookies[SESSION_COOKIE_NAME] - if (!sessionId) return false - const session = playerSessions.get(sessionId) - if (!session) return false - if (Date.now() - session.createdAt > SESSION_TTL) { - playerSessions.delete(sessionId) - return false + if (sessionId) { + const session = playerSessions.get(sessionId) + if (session) { + if (Date.now() - session.createdAt > SESSION_TTL) { + playerSessions.delete(sessionId) + } else { + return true + } + } } - return true + // 兼容:播放器账号登录后同时校验 user token cookie + const userTokenCookie = cookies[USER_TOKEN_COOKIE_NAME] + if (userTokenCookie) { + const session = userSessions.get(userTokenCookie) + if (session && Date.now() - session.createdAt <= USER_SESSION_TTL) return true + if (persistentTokens.get(userTokenCookie)) return true + } + return false } /** 定期清理过期 Session(每小时) */ @@ -974,8 +988,8 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro pathname === `${normalizedPrefix}/sw.js` || isLegacyPlayerAsset - // 认证检查 - if (!isLoginPage && !isPublicAsset && global.lx.config['player.enableAuth']) { + // 认证检查(密码认证 或 强制登录) + if (!isLoginPage && !isPublicAsset && (global.lx.config['player.enableAuth'] || global.lx.config['player.forceLogin'])) { if (!checkPlayerAuth(req)) { res.writeHead(302, { 'Location': `${normalizedPrefix}/login` }) res.end() @@ -1846,7 +1860,16 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro const token = generateSessionId() userSessions.set(token, { username, createdAt: Date.now() }) loginLog.info(`User token issued: ${username} from ${ip}`) - res.writeHead(200, { 'Content-Type': 'application/json' }) + const loginHeaders: Record = { '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) res.end(JSON.stringify({ success: true, token, username })) } else { loginLog.warn(`User login failed: ${username} from ${ip}`) @@ -3126,6 +3149,22 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro username = verified } void fileCache.getCacheList(username).then(list => { + // [新增] 整合 OpenList:若服务器配置了 OpenList 服务器,把目录树扫描的音频文件合并进本地音乐列表 + const mergeOpenList = global.lx.config['user.enableOpenListInLocalMusic'] !== false + if (mergeOpenList) { + return openlist.getAllLocalIndex().then(openListFiles => { + const withOpenList = openListFiles.length ? [...list, ...openListFiles] : list + // [新增] 整合 WebDAV 挂载:把挂载源扫描的音频文件合并进本地音乐列表 + return webdavMount.getAllLocalIndex().then(webdavFiles => { + const merged = webdavFiles.length ? [...withOpenList, ...webdavFiles] : withOpenList + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + }) + res.end(JSON.stringify({ success: true, data: merged })) + }) + }) + } res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache, no-store, must-revalidate', @@ -4223,6 +4262,8 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro }) res.end(JSON.stringify({ 'player.enableAuth': global.lx.config['player.enableAuth'] || false, + 'player.forceLogin': global.lx.config['player.forceLogin'] ?? true, + 'player.enableRegister': global.lx.config['player.enableRegister'] ?? true, 'user.enablePublicRestriction': global.lx.config['user.enablePublicRestriction'] || false, 'user.enablePublicFavorites': global.lx.config['user.enablePublicFavorites'] || false, 'user.enablePublicNonAdminAccess': global.lx.config['user.enablePublicNonAdminAccess'] || false, @@ -4280,192 +4321,1374 @@ const handleStartServer = async (port = 9527, ip = '127.0.0.1') => await new Pro return } - // [新增] 音乐搜索 API - if (pathname === '/api/music/search' && req.method === 'GET') { - const name = urlObj.searchParams.get('name') || '' - const singer = urlObj.searchParams.get('singer') || '' - const source = urlObj.searchParams.get('source') || 'kw' - const type = urlObj.searchParams.get('type') || 'song' // 新增 type 参数: song, singer, album, playlist - const limit = parseInt(urlObj.searchParams.get('limit') || '20') - const page = parseInt(urlObj.searchParams.get('page') || '1') - const fetchPages = parseInt(urlObj.searchParams.get('pages') || '1') // 新增:一次请求多少页 - - if (!name) { - res.writeHead(400); res.end('Missing name'); return - } - - try { - if (!musicSdk[source]) { - throw new Error(`Source ${source} is not supported`) - } - - let result - if (type === 'song') { - const PAGE_SIZE = 20 - let allSongs: any[] = [] - // 根据前端给定的起始页 (page) 和 请求量 (pages) 进行拉取 - const startPage = page - const endPage = page + fetchPages - 1 - - for (let p = startPage; p <= endPage; p++) { - const searchData = await musicSdk[source].musicSearch.search(name, p, PAGE_SIZE) - const pageList: any[] = searchData.list || [] - allSongs = allSongs.concat(pageList) - // 如果本页返回数量小于 PAGE_SIZE,说明已经是最后页 - if (pageList.length < PAGE_SIZE) break + // ================= 卡密注册 ================= + // [新增] 用户注册(需卡密) + if (pathname === '/api/auth/register' && req.method === 'POST') { + void readBody(req).then(async body => { + try { + const { username, password, cardCode } = JSON.parse(body) + if (!username || !password || !cardCode) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少用户名、密码或卡密' })) + return } - result = allSongs - } else if (type === 'singer') { - if (!musicSdk[source].extendSearch || !musicSdk[source].extendSearch.searchSinger) { - throw new Error(`Source ${source} does not support singer search`) + if (typeof username !== 'string' || !/^[a-zA-Z0-9_\-]{2,32}$/.test(username)) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '用户名仅支持字母/数字/下划线/短横线,长度2-32' })) + return } - const searchData = await musicSdk[source].extendSearch.searchSinger(name, page, limit) - result = searchData.list || [] - } else if (type === 'album') { - if (!musicSdk[source].extendSearch || !musicSdk[source].extendSearch.searchAlbum) { - throw new Error(`Source ${source} does not support album search`) + if (typeof password !== 'string' || password.length < 6) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '密码长度至少6位' })) + return } - const searchData = await musicSdk[source].extendSearch.searchAlbum(name, page, limit) - result = searchData.list || [] - } else if (type === 'playlist') { - if (!musicSdk[source].extendSearch || !musicSdk[source].extendSearch.searchPlaylist) { - throw new Error(`Source ${source} does not support playlist search`) + if (global.lx.config.users.some(u => u.name === username)) { + res.writeHead(409, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '用户名已存在' })) + return } - const searchData = await musicSdk[source].extendSearch.searchPlaylist(name, page, limit) - result = searchData.list || [] - } else { - throw new Error(`Invalid search type: ${type}`) + if (global.lx.config['player.enableRegister'] === false) { + res.writeHead(403, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '注册功能已关闭' })) + return + } + try { + cards.consumeCard(cardCode, username) + } catch (err: any) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + return + } + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { getUserDirname } = require('@/user') + const dataPath = path.join(global.lx.userPath, getUserDirname(username)) + checkAndCreateDir(dataPath) + + global.lx.config.users.push({ name: username, password, dataPath }) + saveUsers() + + const token = generateSessionId() + userSessions.set(token, { username, createdAt: Date.now() }) + loginLog.info(`New user registered: ${username} from ${ip}`) + const regHeaders: Record = { '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}`) + regHeaders['Set-Cookie'] = cookies.join(', ') + } + res.writeHead(200, regHeaders) + res.end(JSON.stringify({ success: true, token, username })) + } catch (e: any) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: e.message || 'Bad Request' })) } + }) + return + } - 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) - res.writeHead(500, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ error: err.message, code: 500 })) + // ================= 卡密管理(管理员) ================= + const requireAdminAuth = () => (req.headers['x-frontend-auth'] as string) === global.lx.config['frontend.password'] + + if (pathname === '/api/card/list' && req.method === 'GET') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return } + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, cards: cards.listCards() })) return } - // [新增] 搜索提示 (TipSearch) API - if (pathname === '/api/music/tipSearch' && req.method === 'GET') { - const name = urlObj.searchParams.get('name') || '' - const source = urlObj.searchParams.get('source') || 'kw' - if (!name) { - res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('[]'); return + if (pathname === '/api/card/generate' && req.method === 'POST') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return } - try { - if (!musicSdk[source] || !musicSdk[source].tipSearch) { - res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('[]'); return + void readBody(req).then(body => { + try { + const { count, expireDays, remark } = JSON.parse(body) + const num = Math.min(Math.max(parseInt(count) || 1, 1), 500) + const created = cards.generateCards(num, expireDays ? parseInt(expireDays) : null, remark || null) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, cards: created })) + } catch (e: any) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: e.message || 'Bad Request' })) } - const tips = await musicSdk[source].tipSearch.search(name) - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify(tips || [])) - } catch (err: any) { - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end('[]') - } + }) return } - // [新增] 获取歌手详情 API - if (pathname === '/api/music/artistDetail' && req.method === 'GET') { - const id = urlObj.searchParams.get('id') - const source = urlObj.searchParams.get('source') || 'wy' - if (!id) { - res.writeHead(400); res.end('Missing id'); return - } - try { - const data = await musicSdk[source].extendDetail.getArtistDetail(id) - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify(data)) - } catch (err: any) { - res.writeHead(500); res.end(err.message) + if (pathname === '/api/card/delete' && req.method === 'POST') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return } + void readBody(req).then(body => { + try { + const { ids } = JSON.parse(body) + const deleted = cards.deleteCards(Array.isArray(ids) ? ids : []) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, deleted })) + } catch (e: any) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: e.message || 'Bad Request' })) + } + }) return } - // [新增] 获取歌手专辑列表 API - if (pathname === '/api/music/artistAlbums' && req.method === 'GET') { - const id = urlObj.searchParams.get('id') - const source = urlObj.searchParams.get('source') || 'wy' - const page = parseInt(urlObj.searchParams.get('page') || '1') - if (!id) { - res.writeHead(400); res.end('Missing id'); return - } - try { - const data = await musicSdk[source].extendDetail.getArtistAlbums(id, page) - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify(data)) - } catch (err: any) { - res.writeHead(500); res.end(err.message) + const requirePlayerOrAdmin = (): string | null => { + if (requireAdminAuth()) return 'admin' + const user = verifyUserAuth(req) + if (user) return user + if (checkPlayerAuth(req)) return 'player' + return null + } + + // Subsonic 内部流令牌校验:Subsonic 客户端仅用 u/p 参数认证,无 session cookie。 + // Subsonic stream 302 到 /api/openlist/stream、/api/webdav-mounts/stream 时携带 sst 令牌, + // 这里对合法且未过期的令牌放行,使 Subsonic 客户端可正常播放挂载音乐。 + const verifyInternalStreamToken = (u: URL): string | null => { + const token = u.searchParams.get('sst') + if (!token) return null + const sep = token.lastIndexOf('.') + if (sep <= 0) return null + const sig = token.slice(0, sep) + const expStr = token.slice(sep + 1) + const expiry = Number(expStr) + if (!Number.isFinite(expiry) || expiry < Date.now()) return null + const secret = global.lx.config['frontend.password'] || 'lxserver-internal' + const payload = `${u.searchParams.get('server') || ''}|${u.searchParams.get('path') || ''}|${expStr}` + const expect = crypto.createHmac('sha256', secret).update(payload).digest('base64url') + const a = Buffer.from(sig) + const b = Buffer.from(expect) + if (a.length !== b.length) return null + return crypto.timingSafeEqual(a, b) ? 'subsonic' : null + } + + // ================= OpenList 存储 ================= + // [新增] OpenList 服务器列表(管理员) + if (pathname === '/api/openlist/servers' && req.method === 'GET') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return } + const servers = openlist.listServers().map((s: any) => ({ + id: s.id, + name: s.name, + baseUrl: s.baseUrl, + username: s.username, + hasPassword: !!s.password, + token: s.token, + rootPath: s.rootPath, + enabled: s.enabled, + createdAt: s.createdAt, + })) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, servers })) return } - // [新增] 获取歌手歌曲 API(循环拉取全部,前端分页) - if (pathname === '/api/music/artistSongs' && req.method === 'GET') { - const id = urlObj.searchParams.get('id') - const source = urlObj.searchParams.get('source') || 'wy' - const order = urlObj.searchParams.get('order') || 'hot' - if (!id) { - res.writeHead(400); res.end('Missing id'); return + // [新增] 添加 OpenList 服务器(管理员) + if (pathname === '/api/openlist/servers' && req.method === 'POST') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return } - try { - const PAGE_SIZE = 100 - const configuredMaxPages = Number((global.lx.config as any)?.['artist.maxFetchPages']) - const MAX_PAGES = Number.isFinite(configuredMaxPages) && configuredMaxPages > 0 - ? Math.min(Math.floor(configuredMaxPages), 100) - : 20 - let allSongs: any[] = [] - for (let p = 1; p <= MAX_PAGES; p++) { - const data = await musicSdk[source].extendDetail.getArtistSongs(id, p, PAGE_SIZE, order) - const pageList: any[] = data.list || [] - allSongs = allSongs.concat(pageList) - const total = Number(data.total) || 0 - if (pageList.length < PAGE_SIZE || (total > 0 && allSongs.length >= total)) break + void readBody(req).then(body => { + try { + const data = JSON.parse(body) + if (!data.baseUrl) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少 OpenList 地址' })) + return + } + const server = openlist.addServer(data) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, server })) + } catch (e: any) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: e.message || 'Bad Request' })) } - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify(allSongs)) - } catch (err: any) { - res.writeHead(500); res.end(err.message) - } + }) return } - // [新增] 获取专辑歌曲 API - if (pathname === '/api/music/albumSongs' && req.method === 'GET') { - const id = urlObj.searchParams.get('id') - const source = urlObj.searchParams.get('source') || 'wy' - if (!id) { - res.writeHead(400); res.end('Missing id'); return - } - try { - const data = await musicSdk[source].extendDetail.getAlbumSongs(id) - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify(data)) - } catch (err: any) { - res.writeHead(500); res.end(err.message) + // [新增] 更新 OpenList 服务器(管理员) + if (pathname === '/api/openlist/servers' && req.method === 'PUT') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return } + void readBody(req).then(body => { + try { + const data = JSON.parse(body) + if (!data.id) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少服务器 ID' })) + return + } + const server = openlist.updateServer(data.id, data) + if (!server) { + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '服务器不存在' })) + return + } + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, server })) + } catch (e: any) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: e.message || 'Bad Request' })) + } + }) return } - // [新增] 音乐解析进度 SSE 端点 (无需登录, 用 requestId 区分) - if (pathname === '/api/music/progress' && req.method === 'GET') { - const reqId = urlObj.searchParams.get('reqId') - if (!reqId) { - res.writeHead(400) - res.end('Missing reqId') + // [新增] 删除 OpenList 服务器(管理员) + if (pathname === '/api/openlist/servers' && req.method === 'DELETE') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) return } - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache, no-transform', - 'Connection': 'keep-alive', - 'Access-Control-Allow-Origin': '*', - 'X-Accel-Buffering': 'no', // 关键:禁用 Nginx 等代理的缓冲 + void readBody(req).then(body => { + try { + const { id } = JSON.parse(body) + if (!id) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少服务器 ID' })) + return + } + const ok = openlist.deleteServer(id) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, deleted: ok })) + } catch (e: any) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: e.message || 'Bad Request' })) + } + }) + return + } + + // [新增] 测试 OpenList 连接(管理员) + if (pathname === '/api/openlist/test' && req.method === 'POST') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + void readBody(req).then(body => { + try { + const { id } = JSON.parse(body) + if (!id) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少服务器 ID' })) + return + } + openlist.testConnection(id).then(result => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: result.ok, message: result.message })) + }).catch((err: any) => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + }) + } catch (e: any) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: e.message || 'Bad Request' })) + } + }) + return + } + + // [新增] OpenList 可用的服务器列表(登录用户/管理员,用于播放器选择) + if (pathname === '/api/openlist/available' && req.method === 'GET') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const servers = openlist.listServers() + .filter((s: any) => s.enabled) + .map((s: any) => ({ + id: s.id, + name: s.name, + baseUrl: s.baseUrl, + rootPath: s.rootPath, + hasAuth: !!s.token || !!(s.username && s.password), + })) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, servers })) + return + } + + // [新增] OpenList 文件列表(登录用户/管理员) + if (pathname === '/api/openlist/list' && req.method === 'GET') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const serverId = urlObj.searchParams.get('server') || '' + const dirPath = urlObj.searchParams.get('path') || '/' + const page = parseInt(urlObj.searchParams.get('page') || '1') + const perPage = parseInt(urlObj.searchParams.get('perPage') || '0') + const server = openlist.getServer(serverId) + if (!server) { + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '服务器不存在' })) + return + } + openlist.listFiles(server, dirPath, page, perPage).then((data) => { + const items = (data && data.content) || [] + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ + success: true, + items, + total: data && data.total !== undefined ? data.total : items.length, + write: !!(data && data.write), + provider: data && data.provider || '', + })) + }).catch((err: any) => { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + }) + return + } + + // [新增] OpenList 文件搜索(登录用户/管理员) + if (pathname === '/api/openlist/search' && req.method === 'GET') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const serverId = urlObj.searchParams.get('server') || '' + const keyword = urlObj.searchParams.get('keyword') || '' + const page = parseInt(urlObj.searchParams.get('page') || '1') + if (!keyword) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少关键词' })) + return + } + const server = openlist.getServer(serverId) + if (!server) { + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '服务器不存在' })) + return + } + openlist.searchFiles(server, keyword, page).then((data) => { + const items = (data && data.content) || [] + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ + success: true, + items, + total: data && data.total !== undefined ? data.total : items.length, + })) + }).catch((err: any) => { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + }) + return + } + + // [新增] OpenList 音频流式播放(代理,支持 Range,登录用户/管理员) + if (pathname === '/api/openlist/stream' && (req.method === 'GET' || req.method === 'HEAD')) { + const username = requirePlayerOrAdmin() || verifyInternalStreamToken(urlObj) + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const serverId = urlObj.searchParams.get('server') || '' + const filePath = urlObj.searchParams.get('path') || '' + const sign = urlObj.searchParams.get('sign') || undefined + if (!serverId || !filePath) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少 server 或 path 参数' })) + return + } + const server = openlist.getServer(serverId) + if (!server) { + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '服务器不存在' })) + return + } + // [缓存播放] 本地缓存文件优先:完整缓存存在时直接服务本地文件(秒开 + 支持拖拽) + const cacheFilePath = openlist.getCacheFilePath(server, filePath) + if (openlist.serveCacheFile(cacheFilePath, req.headers.range as string | undefined, res)) { + console.log(`[OpenList] Cache hit: ${filePath}`) + return + } + openlist.stream(server, filePath, sign, req.headers.range as string | undefined).then((proxyReq: any) => { + const httpMod = require('http') + const httpsMod = require('https') + const handleStreamError = (err: any) => { + if (!res.headersSent) { + res.writeHead(502, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + } else { + res.end() + } + } + // 递归跟随 3xx 重定向(上游 /d/ 直链会 302 到对象存储/CDN),最多 5 跳 + const followRedirect = (currentReq: any, hop = 0) => { + currentReq.on('error', handleStreamError) + currentReq.on('response', (resp: any) => { + const statusCode = resp.statusCode || 200 + const location = resp.headers['location'] + if (hop < 5 && statusCode >= 300 && statusCode < 400 && location) { + resp.resume() + let targetUrl: URL + try { + targetUrl = new URL(location) + } catch (e) { + handleStreamError(new Error('非法重定向地址')) + return + } + const lib = targetUrl.protocol === 'https:' ? httpsMod : httpMod + const redirectHeaders: Record = {} + const rangeHeader = String(req.headers.range || '') + if (rangeHeader) redirectHeaders['Range'] = rangeHeader + const nextReq = lib.request(targetUrl, { method: 'GET', headers: redirectHeaders } as any) + nextReq.on('error', () => { /* 下一跳处理 */ }) + nextReq.end() + followRedirect(nextReq, hop + 1) + return + } + const outHeaders: Record = {} + if (resp.headers['content-type']) outHeaders['Content-Type'] = String(resp.headers['content-type']).split(';')[0] || 'audio/mpeg' + if (resp.headers['content-length']) outHeaders['Content-Length'] = resp.headers['content-length'] + if (resp.headers['accept-ranges']) outHeaders['Accept-Ranges'] = resp.headers['accept-ranges'] + if (resp.headers['content-range']) outHeaders['Content-Range'] = resp.headers['content-range'] + outHeaders['Cache-Control'] = 'no-cache' + res.writeHead(statusCode, outHeaders) + + // [边播边缓存] 仅当请求从头开始(无 Range 或 bytes=0-)时缓存,避免缓存部分分片 + const rangeHeader = String(req.headers.range || '') + const isFullRange = !rangeHeader || rangeHeader === 'bytes=0-' || rangeHeader === 'bytes=0' + if (isFullRange) { + const tmpPath = cacheFilePath + '.tmp' + const cacheWs = fs.createWriteStream(tmpPath, { flags: 'w' }) + let cacheReceived = 0 + const total = parseInt(resp.headers['content-length'] || '0', 10) + openlist.trackCacheProgress(server.id, filePath, total, 0) + resp.on('data', (chunk: any) => { + cacheReceived += chunk.length + cacheWs.write(chunk) + openlist.trackCacheProgress(server.id, filePath, total, cacheReceived) + }) + resp.on('end', () => { + cacheWs.end(() => { + // 下载完整则正式落盘,否则丢弃临时文件 + if (total === 0 || cacheReceived >= total) { + fs.rename(tmpPath, cacheFilePath, (err: any) => { + if (err) fs.unlink(tmpPath, () => { }) + openlist.markCacheDone(server.id, filePath) + }) + } else { + fs.unlink(tmpPath, () => { }) + openlist.clearCacheProgress(server.id, filePath) + } + }) + }) + resp.on('error', () => { + cacheWs.destroy() + fs.unlink(tmpPath, () => { }) + openlist.clearCacheProgress(server.id, filePath) + }) + res.on('close', () => { + // 客户端中断:停止缓存写入并清理临时文件(下次播放重新缓存) + if (!resp.complete) { + cacheWs.destroy() + fs.unlink(tmpPath, () => { }) + openlist.clearCacheProgress(server.id, filePath) + } + }) + } + resp.pipe(res) + }) + } + followRedirect(proxyReq) + req.on('close', () => { + if (!proxyReq.destroyed) proxyReq.destroy() + }) + }).catch((err: any) => { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + }) + return + } + + // [新增] OpenList 单个文件缓存状态查询(登录用户/管理员,供前端播放器显示"已缓存"标识) + if (pathname === '/api/openlist/cache/check' && req.method === 'GET') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const serverId = urlObj.searchParams.get('server') || '' + const filePath = urlObj.searchParams.get('path') || '' + if (!serverId || !filePath) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少 server 或 path 参数' })) + return + } + const server = openlist.getServer(serverId) + if (!server) { + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '服务器不存在' })) + return + } + const cached = openlist.isFileCached(server, filePath) + const progress = openlist.getCacheProgress(serverId, filePath) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, cached, progress })) + return + } + + // [新增] OpenList 本地缓存状态查询(登录用户/管理员) + if (pathname === '/api/openlist/cache/status' && req.method === 'GET') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + try { + const cacheDir = path.join(global.lx.dataPath, 'openlist-cache') + let totalSize = 0 + let fileCount = 0 + const byServer: Record = {} + if (fs.existsSync(cacheDir)) { + for (const serverDir of fs.readdirSync(cacheDir)) { + const full = path.join(cacheDir, serverDir) + if (!fs.statSync(full).isDirectory()) continue + let size = 0 + let count = 0 + for (const f of fs.readdirSync(full)) { + const fp = path.join(full, f) + try { + size += fs.statSync(fp).size + count++ + } catch (e) { /* ignore */ } + } + totalSize += size + fileCount += count + byServer[serverDir] = { size, count } + } + } + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, totalSize, fileCount, byServer })) + } catch (e: any) { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: e.message })) + } + return + } + + // [新增] OpenList 本地音乐索引(扫描目录树收集音频文件,供本地音乐整合) + if (pathname === '/api/openlist/local-list' && req.method === 'GET') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const serverId = urlObj.searchParams.get('server') || '' + const refresh = urlObj.searchParams.get('refresh') === '1' || urlObj.searchParams.get('refresh') === 'true' + try { + const files = serverId + ? await openlist.getLocalIndex(serverId, refresh) + : await openlist.getAllLocalIndex(refresh) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, total: files.length, items: files })) + } catch (e: any) { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: e.message })) + } + return + } + + // [新增] OpenList 清空本地缓存(管理员) + if (pathname === '/api/openlist/cache/clear' && req.method === 'POST') { + const username = requirePlayerOrAdmin() + if (!username || username !== 'admin') { + res.writeHead(403, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '需要管理员权限' })) + return + } + try { + const cacheDir = path.join(global.lx.dataPath, 'openlist-cache') + let removed = 0 + if (fs.existsSync(cacheDir)) { + for (const serverDir of fs.readdirSync(cacheDir)) { + const full = path.join(cacheDir, serverDir) + if (!fs.statSync(full).isDirectory()) continue + for (const f of fs.readdirSync(full)) { + try { + fs.unlinkSync(path.join(full, f)) + removed++ + } catch (e) { /* ignore */ } + } + } + } + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, removed })) + } catch (e: any) { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: e.message })) + } + return + } + + // ===== WebDAV 音乐挂载:管理路由 ===== + + // 挂载源列表(播放器用户/管理员,密码脱敏) + if (pathname === '/api/webdav-mounts' && req.method === 'GET') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const mounts = webdavMount.listMounts().map((m: any) => ({ + id: m.id, + name: m.name, + baseUrl: m.baseUrl, + username: m.username, + hasPassword: !!m.password, + rootPath: m.rootPath, + enabled: m.enabled, + createdAt: m.createdAt, + })) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, mounts })) + return + } + + // 新增挂载源(管理员) + if (pathname === '/api/webdav-mounts' && req.method === 'POST') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + void readBody(req).then(body => { + try { + const data = JSON.parse(body) + if (!data.baseUrl) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少 WebDAV 地址' })) + return + } + const mount = webdavMount.addMount(data) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, mount })) + } catch (e: any) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: e.message || 'Bad Request' })) + } + }) + return + } + + // 更新挂载源(管理员) + if (pathname === '/api/webdav-mounts' && req.method === 'PUT') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + void readBody(req).then(body => { + try { + const data = JSON.parse(body) + if (!data.id) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少挂载源 ID' })) + return + } + const mount = webdavMount.updateMount(data.id, data) + if (!mount) { + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '挂载源不存在' })) + return + } + webdavMount.clearLocalIndex(data.id) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, mount })) + } catch (e: any) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: e.message || 'Bad Request' })) + } + }) + return + } + + // 删除挂载源(管理员,同时清理缓存) + if (pathname === '/api/webdav-mounts' && req.method === 'DELETE') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + void readBody(req).then(body => { + try { + const { id } = JSON.parse(body) + if (!id) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少挂载源 ID' })) + return + } + const ok = webdavMount.deleteMount(id) + webdavMount.clearLocalIndex(id) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, deleted: ok })) + } catch (e: any) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: e.message || 'Bad Request' })) + } + }) + return + } + + // 测试挂载源连接(管理员) + if (pathname === '/api/webdav-mounts/test' && req.method === 'POST') { + if (!requireAdminAuth()) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + void readBody(req).then(body => { + try { + const { id } = JSON.parse(body) + if (!id) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少挂载源 ID' })) + return + } + webdavMount.testConnection(id).then(result => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: result.ok, message: result.message })) + }).catch((err: any) => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + }) + } catch (e: any) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: e.message || 'Bad Request' })) + } + }) + return + } + + // 可用的挂载源列表(登录用户/管理员,用于播放器选择) + if (pathname === '/api/webdav-mounts/available' && req.method === 'GET') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const mounts = webdavMount.listMounts() + .filter((m: any) => m.enabled) + .map((m: any) => ({ + id: m.id, + name: m.name, + baseUrl: m.baseUrl, + rootPath: m.rootPath, + hasAuth: !!(m.username && m.password), + })) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, mounts })) + return + } + + // 目录浏览(登录用户/管理员) + if (pathname === '/api/webdav-mounts/browse' && req.method === 'GET') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const mountId = urlObj.searchParams.get('server') || urlObj.searchParams.get('id') || '' + const dirPath = urlObj.searchParams.get('path') || '/' + webdavMount.browse(mountId, dirPath).then(result => { + res.writeHead(result.success ? 200 : 404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(result)) + }) + return + } + + // [新增] WebDAV 同目录歌词 + if (pathname === '/api/webdav-mounts/lyric' && req.method === 'GET') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const mountId = urlObj.searchParams.get('server') || urlObj.searchParams.get('id') || '' + const filePath = urlObj.searchParams.get('path') || '' + if (!mountId || !filePath) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少 server 或 path 参数' })) + return + } + const mount = webdavMount.getMount(mountId) + if (!mount) { + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '挂载源不存在' })) + return + } + webdavMount.getLyric(mount, filePath).then((lyric) => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, lyric: lyric || '' })) + }).catch(() => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, lyric: '' })) + }) + return + } + + // 音频索引(单挂载或全部合并) + if (pathname === '/api/webdav-mounts/local-list' && req.method === 'GET') { + const username = requirePlayerOrAdmin() + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const mountId = urlObj.searchParams.get('server') || '' + const refresh = urlObj.searchParams.get('refresh') === '1' + webdavMount.getLocalIndex(mountId, refresh) + .then(files => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, total: files.length, items: files })) + }) + .catch((e: any) => { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: e.message })) + }) + return + } + + // 播放/流式代理(本地缓存优先 + 边播边写) + if (pathname === '/api/webdav-mounts/stream' && (req.method === 'GET' || req.method === 'HEAD')) { + const username = requirePlayerOrAdmin() || verifyInternalStreamToken(urlObj) + if (!username) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + const serverId = urlObj.searchParams.get('server') || '' + const filePath = urlObj.searchParams.get('path') || '' + if (!serverId || !filePath) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '缺少 server 或 path 参数' })) + return + } + const mount = webdavMount.getMount(serverId) + if (!mount) { + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: '挂载源不存在' })) + return + } + // 本地缓存优先(nocache=1 强制绕过缓存,供前端重试时跳过损坏缓存) + const noCache = urlObj.searchParams.get('nocache') === '1' + const cacheFilePath = webdavMount.getCacheFilePath(mount, filePath) + if (!noCache && webdavMount.serveCacheFile(cacheFilePath, req.headers.range as string | undefined, res)) { + return + } + try { + const proxyReq = webdavMount.stream(mount, filePath, req.headers.range as string | undefined) + const httpMod = require('http') + const httpsMod = require('https') + const handleStreamError = (err: any) => { + if (!res.headersSent) { + res.writeHead(502, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: err.message })) + } else { + res.end() + } + } + // 递归跟随 3xx 重定向,最多 5 跳 + const followRedirect = (currentReq: any, hop = 0) => { + currentReq.on('error', handleStreamError) + currentReq.on('response', (resp: any) => { + const statusCode = resp.statusCode || 200 + const location = resp.headers['location'] + if (hop < 5 && statusCode >= 300 && statusCode < 400 && location) { + resp.resume() + let targetUrl: URL + try { + targetUrl = new URL(location) + } catch (e) { + handleStreamError(new Error('非法重定向地址')) + return + } + const lib = targetUrl.protocol === 'https:' ? httpsMod : httpMod + const redirectHeaders: Record = {} + const rangeHeader = String(req.headers.range || '') + if (rangeHeader) redirectHeaders['Range'] = rangeHeader + const nextReq = lib.request(targetUrl, { method: 'GET', headers: redirectHeaders } as any) + nextReq.on('error', () => { /* 下一跳处理 */ }) + nextReq.end() + followRedirect(nextReq, hop + 1) + return + } + const outHeaders: Record = {} + let contentType = resp.headers['content-type'] ? String(resp.headers['content-type']).split(';')[0] : '' + // 上游返回泛型类型或缺省时,按文件扩展名兜底为正确音频 MIME, + // 否则浏览器