From 21945cc54f646c4a34df5342ae02805f2a37732a Mon Sep 17 00:00:00 2001 From: Jocay <1579649885@qq.com> Date: Sat, 10 Oct 2026 22:05:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A3=B0=E5=8C=A3=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20=E7=BD=91=E6=98=93=E4=BA=91=20/=20QQ=20=E9=9F=B3=E4=B9=90?= =?UTF-8?q?=E8=87=AA=E6=89=98=E7=AE=A1=E4=B8=8B=E8=BD=BD=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 由 163-music-dl CLI 重构为 Web 服务: - 下载流水线、歌词与标签逻辑沿用旧版(node-id3 / ffmpeg) - 网易云(chksz 接口 + music.163.com 元数据)与 QQ 音乐(VIP Cookie)provider - QQ 扫码登录与 musickey 自动续期 - SQLite 持久化的下载队列,SSE 实时进度 - 曲库工具(M3U / ZIP / 回收站 / 补全标签)、历史统计与导出、访问密码 - Vite 8 + Vue 3 前端,浅色 / 深色 - Docker 部署配置(飞牛 NAS) Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 7 + .env.example | 8 + .gitattributes | 3 + .gitignore | 7 + CLAUDE.md | 42 + Dockerfile | 36 + README.md | 75 + config.json | 29 + data/.gitkeep | 0 docker-compose.yml | 25 + docker-entrypoint.sh | 15 + package-lock.json | 2999 ++++++++++++++++++++ package.json | 40 + scripts/dev.mjs | 20 + server/scripts/smoke.ts | 33 + server/src/accounts.ts | 280 ++ server/src/app.ts | 43 + server/src/auth.ts | 59 + server/src/config.ts | 127 + server/src/core/layout.ts | 37 + server/src/core/lyrics.ts | 58 + server/src/core/net/http.ts | 103 + server/src/core/pipeline.ts | 201 ++ server/src/core/providers/index.ts | 54 + server/src/core/providers/netease/index.ts | 314 ++ server/src/core/providers/qq/client.ts | 93 + server/src/core/providers/qq/index.ts | 355 +++ server/src/core/providers/qq/login.ts | 260 ++ server/src/core/quality.ts | 25 + server/src/core/tagging.ts | 134 + server/src/core/types.ts | 140 + server/src/core/util.ts | 62 + server/src/db.ts | 209 ++ server/src/events.ts | 5 + server/src/library.ts | 264 ++ server/src/main.ts | 20 + server/src/queue.ts | 314 ++ server/src/routes/api.ts | 356 +++ server/src/secret.ts | 51 + server/src/settings.ts | 40 + server/src/stream.ts | 34 + tsconfig.json | 18 + vite.config.ts | 19 + web/index.html | 20 + web/public/favicon.svg | 1 + web/src/App.vue | 411 +++ web/src/components/CoverArt.vue | 43 + web/src/components/DetailHeader.vue | 192 ++ web/src/components/Icon.vue | 57 + web/src/components/PlayerBar.vue | 240 ++ web/src/components/QRLoginDialog.vue | 181 ++ web/src/components/QualityPicker.vue | 52 + web/src/components/ToastHost.vue | 80 + web/src/components/TrackList.vue | 464 +++ web/src/lib/api.ts | 323 +++ web/src/lib/format.ts | 89 + web/src/lib/player.ts | 128 + web/src/lib/store.ts | 156 + web/src/lib/theme.ts | 29 + web/src/main.ts | 7 + web/src/router.ts | 26 + web/src/styles/base.css | 289 ++ web/src/styles/tokens.css | 73 + web/src/views/AlbumView.vue | 67 + web/src/views/ArtistView.vue | 111 + web/src/views/HistoryView.vue | 463 +++ web/src/views/LibraryView.vue | 566 ++++ web/src/views/LoginView.vue | 86 + web/src/views/PlaylistView.vue | 63 + web/src/views/QueueView.vue | 393 +++ web/src/views/SearchView.vue | 285 ++ web/src/views/SettingsView.vue | 589 ++++ web/tsconfig.json | 9 + 73 files changed, 12507 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 config.json create mode 100644 data/.gitkeep create mode 100644 docker-compose.yml create mode 100644 docker-entrypoint.sh create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/dev.mjs create mode 100644 server/scripts/smoke.ts create mode 100644 server/src/accounts.ts create mode 100644 server/src/app.ts create mode 100644 server/src/auth.ts create mode 100644 server/src/config.ts create mode 100644 server/src/core/layout.ts create mode 100644 server/src/core/lyrics.ts create mode 100644 server/src/core/net/http.ts create mode 100644 server/src/core/pipeline.ts create mode 100644 server/src/core/providers/index.ts create mode 100644 server/src/core/providers/netease/index.ts create mode 100644 server/src/core/providers/qq/client.ts create mode 100644 server/src/core/providers/qq/index.ts create mode 100644 server/src/core/providers/qq/login.ts create mode 100644 server/src/core/quality.ts create mode 100644 server/src/core/tagging.ts create mode 100644 server/src/core/types.ts create mode 100644 server/src/core/util.ts create mode 100644 server/src/db.ts create mode 100644 server/src/events.ts create mode 100644 server/src/library.ts create mode 100644 server/src/main.ts create mode 100644 server/src/queue.ts create mode 100644 server/src/routes/api.ts create mode 100644 server/src/secret.ts create mode 100644 server/src/settings.ts create mode 100644 server/src/stream.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts create mode 100644 web/index.html create mode 100644 web/public/favicon.svg create mode 100644 web/src/App.vue create mode 100644 web/src/components/CoverArt.vue create mode 100644 web/src/components/DetailHeader.vue create mode 100644 web/src/components/Icon.vue create mode 100644 web/src/components/PlayerBar.vue create mode 100644 web/src/components/QRLoginDialog.vue create mode 100644 web/src/components/QualityPicker.vue create mode 100644 web/src/components/ToastHost.vue create mode 100644 web/src/components/TrackList.vue create mode 100644 web/src/lib/api.ts create mode 100644 web/src/lib/format.ts create mode 100644 web/src/lib/player.ts create mode 100644 web/src/lib/store.ts create mode 100644 web/src/lib/theme.ts create mode 100644 web/src/main.ts create mode 100644 web/src/router.ts create mode 100644 web/src/styles/base.css create mode 100644 web/src/styles/tokens.css create mode 100644 web/src/views/AlbumView.vue create mode 100644 web/src/views/ArtistView.vue create mode 100644 web/src/views/HistoryView.vue create mode 100644 web/src/views/LibraryView.vue create mode 100644 web/src/views/LoginView.vue create mode 100644 web/src/views/PlaylistView.vue create mode 100644 web/src/views/QueueView.vue create mode 100644 web/src/views/SearchView.vue create mode 100644 web/src/views/SettingsView.vue create mode 100644 web/tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a061c27 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +data +music +.git +.env +*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b704fed --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# 复制为 .env 后按需修改;不建 .env 时使用下列默认值 +PORT=8066 +MUSIC_DIR=/vol2/1000/Music +PUID=1000 +PGID=1001 +LEGACY_HISTORY_DIR=/vol1/1000/Apps/163-music-dl +NPM_REGISTRY=https://registry.npmmirror.com +ALPINE_MIRROR=mirrors.aliyun.com diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..46526f6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf +*.png binary +*.jpg binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..03e1661 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +music/ +data/* +!data/.gitkeep +*.log +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e35b840 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,42 @@ +# CLAUDE.md + +声匣:自托管音乐下载 Web 服务(网易云 + QQ 音乐),本机 `npm start` 或 NAS Docker 运行。UI、注释、日志均为中文。 + +## 命令 + +- `npm run dev` — 后端 `node --watch server/src/main.ts`(:8066)+ Vite(:5173,代理 `/api`) +- `npm start` — 生产模式(需先 `npm run build`) +- `npm run typecheck` — `tsc`(server)+ `vue-tsc`(web)。改动后必须通过 +- `npm run smoke -- [quality] [album|flat]` — 端到端跑一次下载流水线(会导入 QQ Cookie) + +没有测试框架;用 smoke、curl `/api/*`,以及无头浏览器截图验证。测试下载请用 `MUSIC_DIR` / `DATA_DIR` 指向临时目录,不要污染 `./music` 与 `./data`。 + +## 架构 + +单个 package.json: + +- `server/src/` — Koa 3,**Node 原生 TypeScript 类型剥离直接运行,无构建步骤** + - 只能用可擦除语法(`erasableSyntaxOnly`):不能用 enum / namespace / 参数属性 + - 相对导入必须写 `.ts`;类型导入用 `import type`(`verbatimModuleSyntax`) + - `core/` 与平台无关:`pipeline.ts`(下载流水线)、`tagging.ts`、`lyrics.ts`、`layout.ts`、`quality.ts`、`net/http.ts` + - `core/providers/{netease,qq}/` 实现 `MusicProvider`(`core/types.ts`),在 `providers/index.ts` 注册;`resolveLink` 处理分享短链 + - QQ:`qq/client.ts` 的 `musicu()` 在 comm 中带 `uin` + `authst` 并附 Cookie —— 缺这一步拿不到 VIP 音源 + - `queue.ts` — 任务存 SQLite `tasks` 表,进度只在内存,经 `events.ts` 的 bus 推送;`routes/api.ts` 的 `/api/events` 是 SSE + - `accounts.ts`(Cookie 用 `secret.ts` AES-GCM 加密存储)、`settings.ts`(运行时设置)、`auth.ts`(可选访问密码)、`library.ts`(曲库工具)、`stream.ts`(试听代理) + - `db.ts` — `node:sqlite`;history / tasks / settings 表 +- `web/` — Vite 8 + Vue 3.5 + Vue Router 5,无 UI 库 + - `lib/store.ts` 全局状态 + SSE;`lib/player.ts` 试听;`lib/api.ts` 类型与请求 + - `components/TrackList.vue` 是所有曲目列表(搜索 / 专辑 / 歌单 / 歌手)的核心 + - 样式只用 `styles/tokens.css` 的 CSS 变量;`base.css` 有 `.page` `.btn` `.seg` `.badge` 等通用类 + +## 约定 + +- 统一音质键 `standard|high|lossless|hires|master|spatial|effect`;provider 映射到平台参数,不可用时按 `fallbackChain` 降级。QQ 只声明到 hires +- 目录:`album` 布局 = `{第一位专辑艺人}/{专辑}/{NN}. {歌名}.ext`(与 NAS 曲库一致);`flat` = `{歌手} - {歌名}.ext` +- 下载先写 `.part` 再改名;已存在判定:≥ 音源 99% 且 ≤ 音源 + 8MB(内嵌封面会变大) +- `FatalError` 表示重试无意义;队列间隔 `gapMs`(默认 400ms)防止打爆上游 +- 服务端永远不把 Cookie 原文返回前端;`relFile` 等路径一律用 `/` +- 传给 TrackList 的数组必须稳定(用 computed),否则每次渲染都会清空选择 +- 前端 `ref` 的接口要返回对象包一层(Koa 对 `null` body 返回 204) +- TypeScript 固定 6.x:TS 7 没有 JS API,vue-tsc 无法使用 +- 网易云红 / QQ 绿已用 dataviz 校验器验证(深色 QQ 绿 `#42a07d`);二者 CVD 区分度在 6–8,必须配合文字标签使用 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..52e6c79 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# 声匣 · 多阶段构建:前端用 Vite 打包,运行时只保留生产依赖 + ffmpeg +ARG NODE_IMAGE=node:24-alpine + +FROM ${NODE_IMAGE} AS build +ARG NPM_REGISTRY=https://registry.npmmirror.com +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --registry=${NPM_REGISTRY} +COPY . . +RUN npm run build + +FROM ${NODE_IMAGE} +ARG NPM_REGISTRY=https://registry.npmmirror.com +ARG ALPINE_MIRROR=mirrors.aliyun.com +RUN if [ -n "${ALPINE_MIRROR}" ]; then sed -i "s#dl-cdn.alpinelinux.org#${ALPINE_MIRROR}#g" /etc/apk/repositories; fi \ + && apk add --no-cache ffmpeg su-exec tzdata + +WORKDIR /app +ENV NODE_ENV=production \ + PORT=8066 \ + MUSIC_DIR=/music \ + DATA_DIR=/data \ + TZ=Asia/Shanghai + +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev --registry=${NPM_REGISTRY} && npm cache clean --force +COPY config.json ./ +COPY server ./server +COPY --from=build /app/dist ./dist +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +EXPOSE 8066 +HEALTHCHECK --interval=60s --timeout=5s --start-period=10s CMD wget -qO- "http://127.0.0.1:${PORT}/api/health" >/dev/null || exit 1 +ENTRYPOINT ["docker-entrypoint.sh"] +CMD ["node", "--disable-warning=ExperimentalWarning", "server/src/main.ts"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..803896c --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# 声匣 · Sound Case + +自托管的音乐下载 Web 服务:搜索、试听、批量下载网易云音乐与 QQ 音乐(VIP Cookie),自动写入歌词、封面与完整标签,按「歌手 / 专辑 / 序号. 歌名」归档,可直接被 Navidrome 等播放器识别。 + +> 由 `163-music-dl` CLI 重构而来,不再保留命令行模式。仅供个人对已购买 / 已订阅内容的备份使用。 + +## 功能 + +- **搜索**:网易云 / QQ 音乐切换;粘贴任意分享链接(歌曲、专辑、歌单、歌手,含 QQ 短链)直接跳转 +- **详情页**:专辑、歌单(可筛选)、歌手(热门歌曲 + 专辑墙);多选 / Shift 区间选 / 只选未下载 +- **下载队列**:并发、全局暂停、单曲暂停 / 继续 / 取消 / 重试,SSE 实时进度;重启后自动恢复 +- **音质**:标准 / 极高 / 无损 / Hi-Res / 超清母带 / 空间音频 / 高清臻品;不可用时自动降级 + - QQ 音乐支持到 Hi-Res(RS01);母带 / 全景声为加密格式,暂不支持 +- **试听**:服务端代理 320K 音源,底部播放条带滚动歌词;曲库与队列中的本地文件也可直接播放 +- **曲库**:浏览音乐目录(封面墙)、生成 M3U、打包 ZIP、移入回收站、批量补全标签与歌词 +- **历史**:统计(累计、占用、近 30 天、来源与音质分布)、搜索筛选、导出 CSV / JSON +- **设置**:QQ 账号(导入 Cookie、VIP 状态校验)、下载默认值、并发与间隔、访问密码、浅色 / 深色 + +## 本机运行 + +需要 Node.js ≥ 22.18(服务端直接以 TypeScript 运行,无需编译)。FLAC 写标签需要 ffmpeg(Windows:`winget install Gyan.FFmpeg`)。 + +```bash +npm install +npm run build # 打包前端到 dist/web +npm start # http://localhost:8066 +``` + +开发模式:`npm run dev`(后端 :8066 --watch + Vite :5173 热更新,代理 /api)。 + +默认下载到 `./music`、数据在 `./data`。QQ 音乐 Cookie 会从 `config.json` 的 `qq.cookieFile`(默认 `../cookie.txt`)自动导入,文件更新后重启即重新导入;也可在设置页粘贴。 + +## 配置 + +`config.json`(私有仓库,已包含 chksz apikey),环境变量优先: + +| 环境变量 | 对应 | +| --- | --- | +| `PORT` / `HOST` | `server.port`;监听地址默认 `0.0.0.0` | +| `MUSIC_DIR` / `DATA_DIR` | `paths.*` | +| `CHKSZ_API_KEY` | `netease.apikey` | +| `QQ_COOKIE_FILE` | `qq.cookieFile` | +| `DEFAULT_QUALITY` | `defaults.quality`:standard / high / lossless / hires / master / spatial / effect | +| `DEFAULT_LAYOUT` | `defaults.layout`:`album`(歌手/专辑/NN. 歌名)或 `flat`(歌手 - 歌名) | +| `LEGACY_HISTORY_FILE` | 旧版 CLI 的 `download_history.json`,首次启动导入 | + +`config.json` 中的 `defaults` 只是初始值,网页设置页修改后保存在数据库中。 + +数据目录内容:`shengxia.db`(历史、队列、设置、加密后的 Cookie)、`secret.key`(本机加密密钥,勿外传)、`.trash/`(曲库回收站)。 + +## 标签规则(沿用旧版) + +- 标题、歌手、专辑取自音源;曲序 `N/总数`、碟号、专辑艺人、合辑标记、发行日期、流派、唱片公司取自平台专辑详情 +- 作词 / 作曲 / 编曲 / 制作人 / 混音从歌词头部解析;编曲、制作人、混音写入 TXXX(MP3)或同名字段(FLAC) +- 歌词保存为 `.lrc`,有翻译时额外生成 `.合并翻译.lrc`,并内嵌到音频 +- 专辑目录写入 `cover.jpg`;人名统一使用 ` / ` 分隔 + +## Docker 部署(NAS) + +```bash +cd /vol1/1000/dockercompose && git clone <私有仓库地址> shengxia && cd shengxia +docker compose up -d --build # http://NAS:8066 +``` + +挂载与端口见 `docker-compose.yml` / `.env.example`。容器以 `PUID:PGID`(默认 `1000:1001`)运行。QQ Cookie 可在设置页导入,或设置 `QQ_COOKIE_FILE` 并挂载该文件。 + +## 进度 + +- [x] P0 工程骨架、设计 token、Docker 配置 +- [x] P1 下载核心与网易云 provider +- [x] P2 QQ 音乐 provider(VIP Cookie) +- [x] P3 下载队列、SSE 实时进度、设置、访问密码、曲库与历史接口 +- [x] P4 完整前端 +- [ ] P5 QQ 扫码登录与 Cookie 自动续期 +- [ ] P6 NAS 部署验证 diff --git a/config.json b/config.json new file mode 100644 index 0000000..3e63aee --- /dev/null +++ b/config.json @@ -0,0 +1,29 @@ +{ + "server": { + "port": 8066 + }, + "netease": { + "apikey": "chksz_RGXIby7J6ok-GPa6nMrFgHgQtwEGD-hILAhZXpIa72Q", + "music": "https://api.chksz.com/api/163_music", + "lyric": "https://api.chksz.com/api/163_lyric", + "search": "https://api.chksz.com/api/163_search", + "playlist": "https://api.chksz.com/api/163_playlist", + "baseUrl": "https://music.163.com" + }, + "qq": { + "cookieFile": "../cookie.txt" + }, + "paths": { + "musicDir": "music", + "dataDir": "data" + }, + "defaults": { + "quality": "master", + "layout": "album", + "retries": 3, + "lyric": true, + "translatedLyric": true, + "embedTags": true, + "albumCover": true + } +} diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..da26842 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +# 部署:git clone 到 /vol1/1000/dockercompose/shengxia 后执行 +# docker compose up -d --build +# 路径与端口可在同目录 .env 中覆盖(见 .env.example) +services: + shengxia: + build: + context: . + args: + NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmmirror.com} + ALPINE_MIRROR: ${ALPINE_MIRROR:-mirrors.aliyun.com} + image: shengxia:latest + container_name: shengxia + restart: unless-stopped + ports: + - "${PORT:-8066}:8066" + environment: + PUID: ${PUID:-1000} + PGID: ${PGID:-1001} + TZ: Asia/Shanghai + # 首次启动时导入旧版 CLI 的下载历史(只导入一次) + LEGACY_HISTORY_FILE: /legacy/download_history.json + volumes: + - ${MUSIC_DIR:-/vol2/1000/Music}:/music + - ./data:/data + - ${LEGACY_HISTORY_DIR:-/vol1/1000/Apps/163-music-dl}:/legacy:ro diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..fb7510b --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# 以 PUID:PGID 运行服务,保证写入音乐目录的文件属于 NAS 用户(而不是 root) +set -e + +PUID="${PUID:-1000}" +PGID="${PGID:-1000}" + +mkdir -p "$DATA_DIR" "$MUSIC_DIR" +# 数据目录可能由 root 创建(git clone / docker 自动建目录),这里修正属主;音乐目录不递归改动 +chown -R "$PUID:$PGID" "$DATA_DIR" + +if [ "$(id -u)" = "0" ]; then + exec su-exec "$PUID:$PGID" "$@" +fi +exec "$@" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9c5c047 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2999 @@ +{ + "name": "shengxia", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "shengxia", + "version": "0.1.0", + "dependencies": { + "@koa/bodyparser": "^6.1.0", + "@koa/router": "^15.7.0", + "koa": "^3.2.1", + "koa-static": "^5.0.0", + "node-id3": "^0.2.9", + "yazl": "^3.3.1" + }, + "devDependencies": { + "@types/koa": "^3.0.3", + "@types/koa-static": "^4.0.4", + "@types/node": "^24.19.2", + "@types/yazl": "^3.3.1", + "@vitejs/plugin-vue": "^6.0.10", + "@vue/tsconfig": "^0.9.1", + "typescript": "^6.0.3", + "vite": "^8.3.4", + "vue": "^3.5.43", + "vue-router": "^5.4.0", + "vue-tsc": "^3.3.12" + }, + "engines": { + "node": ">=22.18" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@hapi/bourne": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/@hapi/bourne/-/bourne-3.0.0.tgz", + "integrity": "sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==", + "license": "BSD-3-Clause" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@koa/bodyparser": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/@koa/bodyparser/-/bodyparser-6.1.0.tgz", + "integrity": "sha512-thVG/Utbz9+dB4Nl8EBJKoaOI1DzO74dJqeDFILbAVhUq6C+4rmmrKFwxiDE63ScywOs0CHypx1IUrSRMueFTw==", + "license": "MIT", + "dependencies": { + "@types/co-body": "^6.1.3", + "co-body": "^6.2.0", + "lodash.merge": "^4.6.2", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "koa": ">=2" + } + }, + "node_modules/@koa/router": { + "version": "15.7.0", + "resolved": "https://registry.npmmirror.com/@koa/router/-/router-15.7.0.tgz", + "integrity": "sha512-WaAlk4TOl/O0rhTpOR0l052gz03syPMmI6Pe2gd7v3ubjfv5UcSGcnb0Y/J5NNC/ln+5FiUqPJTc/a15I+XqAA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "koa-compose": "^4.1.0", + "path-to-regexp": "^8.4.2" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "koa": "^2.0.0 || ^3.0.0" + }, + "peerDependenciesMeta": { + "koa": { + "optional": false + } + } + }, + "node_modules/@oxc-project/types": { + "version": "0.153.0", + "resolved": "https://registry.npmmirror.com/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.13", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmmirror.com/@types/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmmirror.com/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/co-body": { + "version": "6.1.3", + "resolved": "https://registry.npmmirror.com/@types/co-body/-/co-body-6.1.3.tgz", + "integrity": "sha512-UhuhrQ5hclX6UJctv5m4Rfp52AfG9o9+d9/HwjxhVB5NjXxr5t9oKgJxN8xRHgr35oo8meUEHUPFWiKg6y71aA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmmirror.com/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/content-disposition": { + "version": "0.5.9", + "resolved": "https://registry.npmmirror.com/@types/content-disposition/-/content-disposition-0.5.9.tgz", + "integrity": "sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/cookies": { + "version": "0.9.2", + "resolved": "https://registry.npmmirror.com/@types/cookies/-/cookies-0.9.2.tgz", + "integrity": "sha512-1AvkDdZM2dbyFybL4fxpuNCaWyv//0AwsuUk2DWeXyM1/5ZKm6W3z6mQi24RZ4l2ucY+bkSHzbDVpySqPGuV8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/express": "*", + "@types/keygrip": "*", + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmmirror.com/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://registry.npmmirror.com/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-assert": { + "version": "1.5.6", + "resolved": "https://registry.npmmirror.com/@types/http-assert/-/http-assert-1.5.6.tgz", + "integrity": "sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keygrip": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/@types/keygrip/-/keygrip-1.0.6.tgz", + "integrity": "sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/koa": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/koa/-/koa-3.0.3.tgz", + "integrity": "sha512-TdtNEJ7sYSrFQcVuS2ySsVqnq5EyE3oJbnfFJvkC9UtGP4Kpem5KE7r+ivHIbIAQAofSqnlB5D3vkfYO69TQpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/accepts": "*", + "@types/content-disposition": "*", + "@types/cookies": "*", + "@types/http-assert": "*", + "@types/http-errors": "^2", + "@types/keygrip": "*", + "@types/koa-compose": "*", + "@types/node": "*" + } + }, + "node_modules/@types/koa-compose": { + "version": "3.2.9", + "resolved": "https://registry.npmmirror.com/@types/koa-compose/-/koa-compose-3.2.9.tgz", + "integrity": "sha512-BroAZ9FTvPiCy0Pi8tjD1OfJ7bgU1gQf0eR6e1Vm+JJATy9eKOG3hQMFtMciMawiSOVnLMdmUOC46s7HBhSTsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/koa": "*" + } + }, + "node_modules/@types/koa-send": { + "version": "4.1.6", + "resolved": "https://registry.npmmirror.com/@types/koa-send/-/koa-send-4.1.6.tgz", + "integrity": "sha512-vgnNGoOJkx7FrF0Jl6rbK1f8bBecqAchKpXtKuXzqIEdXTDO6dsSTjr+eZ5m7ltSjH4K/E7auNJEQCAd0McUPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/koa": "*" + } + }, + "node_modules/@types/koa-static": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/@types/koa-static/-/koa-static-4.0.4.tgz", + "integrity": "sha512-j1AUzzl7eJYEk9g01hNTlhmipFh8RFbOQmaMNLvLcNNAkPw0bdTs3XTa3V045XFlrWN0QYnblbDJv2RzawTn6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/koa": "*", + "@types/koa-send": "*" + } + }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmmirror.com/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmmirror.com/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/yazl": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/@types/yazl/-/yazl-3.3.1.tgz", + "integrity": "sha512-DIWfCKpsTp6hE5BDBHV3+fIL/bLUF9Bv13iDrWnMlmhQpH67buNvI291ZauQ1xcccxK3FqQ9honnXpq4R8NMuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.10", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-6.0.10.tgz", + "integrity": "sha512-b80aUQh3n+qEzzZm5oQp3rT2QImeCx58K6eFl/RkJ3d65KW0kTuv5lNsLRVAjbW9YRUpo/wzmvzMgV34bTAItA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25 || ^3.6.0-rc.10" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue-macros/common": { + "version": "3.1.4", + "resolved": "https://registry.npmmirror.com/@vue-macros/common/-/common-3.1.4.tgz", + "integrity": "sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-sfc": "^3.5.22", + "ast-kit": "^2.1.2", + "local-pkg": "^1.1.2", + "magic-string-ast": "^1.0.2", + "unplugin-utils": "^0.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/vue-macros" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.2.25" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.43", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.43.tgz", + "integrity": "sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.43", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.43.tgz", + "integrity": "sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.43", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.43.tgz", + "integrity": "sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.43", + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-ssr": "3.5.43", + "@vue/shared": "3.5.43", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.28", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.43", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.43.tgz", + "integrity": "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/devtools-api": { + "version": "8.2.1", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-8.2.1.tgz", + "integrity": "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.2.1" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "8.2.1", + "resolved": "https://registry.npmmirror.com/@vue/devtools-kit/-/devtools-kit-8.2.1.tgz", + "integrity": "sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.2.1", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "8.2.1", + "resolved": "https://registry.npmmirror.com/@vue/devtools-shared/-/devtools-shared-8.2.1.tgz", + "integrity": "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "3.3.12", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-3.3.12.tgz", + "integrity": "sha512-k7uIKR0n4CBidk233y7cac9osnDQ5WRidTl6nsrNvpZ3nf8cx9iJxRVsRzVsoPRHbowT/iKgGMTS9RGov3jeIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.2.1", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.4" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.43", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.43.tgz", + "integrity": "sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.43", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.43.tgz", + "integrity": "sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.43.tgz", + "integrity": "sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/runtime-core": "3.5.43", + "@vue/shared": "3.5.43", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.43", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.43.tgz", + "integrity": "sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.43", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.43.tgz", + "integrity": "sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.9.1", + "resolved": "https://registry.npmmirror.com/@vue/tsconfig/-/tsconfig-0.9.1.tgz", + "integrity": "sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">= 5.8", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/alien-signals": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-3.2.1.tgz", + "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/ast-kit": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/ast-kit/-/ast-kit-2.2.0.tgz", + "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "pathe": "^2.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-walker-scope": { + "version": "0.10.0", + "resolved": "https://registry.npmmirror.com/ast-walker-scope/-/ast-walker-scope-0.10.0.tgz", + "integrity": "sha512-KzvoxDfXNz2Gm8KYLh47K3qcY3Zza3WKOuh2xoqa7LBu6MDKWAZLovJtU9DuAd/Er+rhQHCRn619lHS5U+qmhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0", + "ast-kit": "^3.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-walker-scope/node_modules/@babel/helper-string-parser": { + "version": "8.0.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-8.0.6.tgz", + "integrity": "sha512-+6HYEBnsDRZHJtUTAEfMQd0zMDqdFHS7C+5vAsEZqBUr3vEMWOWlHJ0Jz1gS8jJIlCvLsuA6ivmeoqzH936YcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/ast-walker-scope/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.6.tgz", + "integrity": "sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/ast-walker-scope/node_modules/@babel/parser": { + "version": "8.0.7", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-8.0.7.tgz", + "integrity": "sha512-UkDwEbPO1px2JQSrdh4qIgaH1JgG7GO/pFeJkpxhDxIETkT/QvGpnigXIw75ViHYPtIgKITmVUJqLjiO+JuWjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/ast-walker-scope/node_modules/@babel/types": { + "version": "8.0.6", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-8.0.6.tgz", + "integrity": "sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.6", + "@babel/helper-validator-identifier": "^8.0.6" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/ast-walker-scope/node_modules/ast-kit": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/ast-kit/-/ast-kit-3.0.0.tgz", + "integrity": "sha512-8OG92q3R35qjC/4i6BLBMg8IB+fClWu/1PEwg2Z9Rn+BuNaiEgJzpzn+pxWOdHJWDCAwu2JP0wCDTozAM4QirQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0", + "estree-walker": "^3.0.3", + "pathe": "^2.0.3" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-walker-scope/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/co-body": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/co-body/-/co-body-6.2.0.tgz", + "integrity": "sha512-Kbpv2Yd1NdL1V/V4cwLVxraHDV6K8ayohr2rmH0J87Er8+zJjcTa6dAn9QMPC9CRgU8+aNajKbSf1TzDB1yKPA==", + "license": "MIT", + "dependencies": { + "@hapi/bourne": "^3.0.0", + "inflation": "^2.0.0", + "qs": "^6.5.2", + "raw-body": "^2.3.3", + "type-is": "^1.6.16" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/co-body/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/co-body/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/co-body/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/co-body/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/confbox": { + "version": "0.3.1", + "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.3.1.tgz", + "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookies": { + "version": "0.9.2", + "resolved": "https://registry.npmmirror.com/cookies/-/cookies-0.9.2.tgz", + "integrity": "sha512-8BIbcC6tPZMv2/PD4PrB3CtKks7LmAWhA7FRZK/6/b/WJ2RUz00KCga7gq50S4/RhZ22kxefRFGgsuts0v3eoA==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==", + "license": "MIT" + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/exsolve": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.1.3.tgz", + "integrity": "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmmirror.com/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-assert": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/http-assert/-/http-assert-1.5.0.tgz", + "integrity": "sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==", + "license": "MIT", + "dependencies": { + "deep-equal": "~1.0.1", + "http-errors": "~1.8.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-assert/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-assert/node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-assert/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.2", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.2.tgz", + "integrity": "sha512-2y91h5OpQlolefMPmUlivelittSWy0rP+oYVpn6A7GwVHNE8AWzoYOBNmlwks3LobaJxgHCYZAnyNo2GgpNRNQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inflation": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/inflation/-/inflation-2.1.0.tgz", + "integrity": "sha512-t54PPJHG1Pp7VQvxyVCJ9mBbjG3Hqryges9bXoOO6GExCPa+//i/d5GSuFtpx3ALLd7lgIAur6zrIlBQyJuMlQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "license": "MIT", + "dependencies": { + "tsscmp": "1.0.6" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/koa/-/koa-3.2.1.tgz", + "integrity": "sha512-e7IpWJrnanNUroVK2taAgMxoEZvHLXdQiNjeExSu/DEIWm83jaKGBgb7tLmu2rMYpA027qFB3iLR/k3AVpFRnA==", + "license": "MIT", + "dependencies": { + "accepts": "^1.3.8", + "content-disposition": "~1.0.1", + "content-type": "^1.0.5", + "cookies": "~0.9.1", + "delegates": "^1.0.0", + "destroy": "^1.2.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "fresh": "~0.5.2", + "http-assert": "^1.5.0", + "http-errors": "^2.0.0", + "koa-compose": "^4.1.0", + "mime-types": "^3.0.1", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/koa-compose": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/koa-compose/-/koa-compose-4.1.0.tgz", + "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==", + "license": "MIT" + }, + "node_modules/koa-send": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/koa-send/-/koa-send-5.0.1.tgz", + "integrity": "sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "http-errors": "^1.7.3", + "resolve-path": "^1.4.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/koa-send/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa-send/node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa-send/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa-static": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/koa-static/-/koa-static-5.0.0.tgz", + "integrity": "sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ==", + "license": "MIT", + "dependencies": { + "debug": "^3.1.0", + "koa-send": "^5.0.0" + }, + "engines": { + "node": ">= 7.6.0" + } + }, + "node_modules/koa-static/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/local-pkg": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-1.2.1.tgz", + "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magic-string-ast": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/magic-string-ast/-/magic-string-ast-1.0.3.tgz", + "integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.19" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmmirror.com/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-id3": { + "version": "0.2.9", + "resolved": "https://registry.npmmirror.com/node-id3/-/node-id3-0.2.9.tgz", + "integrity": "sha512-dSxhuxrkkGVRgUhDHFxdY0pilzOREcodO01HcZWfaRkCaPWGmo0dOgD8ygyL6ln4Iv4cmfRxAWn1WD9bIB9Bhw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.2" + } + }, + "node_modules/nostics": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/nostics/-/nostics-1.3.0.tgz", + "integrity": "sha512-wzRsrJwNlFbqIRQt8aCeL2WE8USip/uuvGSOZV1D02CxX8ABTMbw8IywKjH7yWHETMXbWJI4OBpihg9rmz2Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-2.3.3.tgz", + "integrity": "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.3.1", + "exsolve": "^1.1.1", + "pathe": "^2.0.3" + } + }, + "node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmmirror.com/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-path": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/resolve-path/-/resolve-path-1.4.0.tgz", + "integrity": "sha512-i1xevIst/Qa+nA9olDxLWnLk8YZbi8R/7JPbCMcgyWaFR6bKWaexgJgEB5oc2PKMjYdrHynyz0NY+if+H98t1w==", + "license": "MIT", + "dependencies": { + "http-errors": "~1.6.2", + "path-is-absolute": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/resolve-path/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/resolve-path/node_modules/http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/resolve-path/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "license": "ISC" + }, + "node_modules/resolve-path/node_modules/setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", + "license": "ISC" + }, + "node_modules/resolve-path/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/rolldown": { + "version": "1.2.13", + "resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.153.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "dev": true, + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unplugin": { + "version": "3.4.0", + "resolved": "https://registry.npmmirror.com/unplugin/-/unplugin-3.4.0.tgz", + "integrity": "sha512-9skdIFlCsPdFV7wUfZxNsFInlW+7nJmGu2gkTu0OUhF56aXGsHab9x52/QhdJ4lC7ZDPWTxbiC4ANqVlsuaW3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.7", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@farmfe/core": "*", + "@rsbuild/core": "*", + "@rspack/core": "*", + "bun-types-no-globals": "*", + "esbuild": "*", + "rolldown": "*", + "rollup": "*", + "unloader": "*", + "vite": "*", + "webpack": "*" + }, + "peerDependenciesMeta": { + "@farmfe/core": { + "optional": true + }, + "@rsbuild/core": { + "optional": true + }, + "@rspack/core": { + "optional": true + }, + "bun-types-no-globals": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + }, + "unloader": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/unplugin-utils": { + "version": "0.3.2", + "resolved": "https://registry.npmmirror.com/unplugin-utils/-/unplugin-utils-0.3.2.tgz", + "integrity": "sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmmirror.com/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.2.0.tgz", + "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.43", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.43.tgz", + "integrity": "sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-sfc": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/server-renderer": "3.5.43", + "@vue/shared": "3.5.43" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "5.4.0", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-5.4.0.tgz", + "integrity": "sha512-pFvcYcxinDHjZUlf7AzFBrT8r8Xqvv4uMHoJnjc+EqPawXQvHTkF8S4tfSmQLpriVHUGfkaKlI7Et0KRjTMTdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue-macros/common": "^3.1.3", + "@vue/devtools-api": "^8.1.5", + "ast-walker-scope": "^0.10.0", + "chokidar": "^5.0.0", + "confbox": "^0.3.1", + "local-pkg": "^1.2.1", + "magic-string": "^1.4.3", + "mlly": "^1.8.2", + "muggle-string": "^0.4.1", + "nostics": "^1.3.0", + "pathe": "^2.0.3", + "picomatch": "^4.0.7", + "scule": "^1.3.0", + "tinyglobby": "^0.2.17", + "unplugin": "^3.4.0", + "unplugin-utils": "^0.3.2" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@pinia/colada": ">=0.21.2", + "@vue/compiler-sfc": "^3.5.34 || ^4.0.0", + "pinia": "^3.0.4 || ^4.0.2", + "vite": "^7.3.0 || ^8.0.0", + "vue": "^3.5.34 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@pinia/colada": { + "optional": true + }, + "@vue/compiler-sfc": { + "optional": true + }, + "pinia": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vue-router/node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/vue-tsc": { + "version": "3.3.12", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.3.12.tgz", + "integrity": "sha512-kbpaz18O9jPVkXVHD1BEjpUXM6WhXSu+62L1sGGr6Wbzd8GbvkBQWjoj3KZ/C0aq2MZllce7UdtWzwCIYsBMGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.3.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/yazl": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/yazl/-/yazl-3.3.1.tgz", + "integrity": "sha512-BbETDVWG+VcMUle37k5Fqp//7SDOK2/1+T7X8TD96M3D9G8jK5VLUdQVdVjGi8im7FGkazX7kk5hkU8X4L5Bng==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^1.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..488438b --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "shengxia", + "version": "0.1.0", + "private": true, + "description": "声匣 · 网易云 / QQ 音乐自托管下载服务", + "type": "module", + "engines": { + "node": ">=22.18" + }, + "scripts": { + "dev": "node scripts/dev.mjs", + "dev:server": "node --watch --disable-warning=ExperimentalWarning server/src/main.ts", + "dev:web": "vite", + "build": "vite build", + "start": "node --disable-warning=ExperimentalWarning server/src/main.ts", + "typecheck": "tsc -p tsconfig.json && vue-tsc -p web/tsconfig.json", + "smoke": "node --disable-warning=ExperimentalWarning server/scripts/smoke.ts" + }, + "dependencies": { + "@koa/bodyparser": "^6.1.0", + "@koa/router": "^15.7.0", + "koa": "^3.2.1", + "koa-static": "^5.0.0", + "node-id3": "^0.2.9", + "yazl": "^3.3.1" + }, + "devDependencies": { + "@types/koa": "^3.0.3", + "@types/koa-static": "^4.0.4", + "@types/node": "^24.19.2", + "@types/yazl": "^3.3.1", + "@vitejs/plugin-vue": "^6.0.10", + "@vue/tsconfig": "^0.9.1", + "typescript": "^6.0.3", + "vite": "^8.3.4", + "vue": "^3.5.43", + "vue-router": "^5.4.0", + "vue-tsc": "^3.3.12" + } +} diff --git a/scripts/dev.mjs b/scripts/dev.mjs new file mode 100644 index 0000000..269bf05 --- /dev/null +++ b/scripts/dev.mjs @@ -0,0 +1,20 @@ +// 同时启动后端(node --watch)与前端(vite),Ctrl+C 一起退出 +import { spawn } from 'node:child_process'; + +const procs = [ + ['server', 'npm run dev:server', '\x1b[36m'], + ['web', 'npm run dev:web', '\x1b[35m'], +].map(([name, cmd, color]) => { + const child = spawn(cmd, { shell: true, stdio: ['ignore', 'pipe', 'pipe'] }); + const prefix = `${color}[${name}]\x1b[0m `; + const relay = (stream, out) => stream.on('data', (buf) => { + for (const line of buf.toString().split(/\r?\n/)) if (line) out.write(prefix + line + '\n'); + }); + relay(child.stdout, process.stdout); + relay(child.stderr, process.stderr); + return child; +}); + +const stop = () => { for (const p of procs) p.kill(); process.exit(0); }; +process.on('SIGINT', stop); +process.on('SIGTERM', stop); diff --git a/server/scripts/smoke.ts b/server/scripts/smoke.ts new file mode 100644 index 0000000..f371b8f --- /dev/null +++ b/server/scripts/smoke.ts @@ -0,0 +1,33 @@ +// 冒烟测试:用流水线下载一首歌并打印过程事件 +// npm run smoke -- <歌曲ID或链接> [音质] [album|flat] +// MUSIC_DIR=/tmp/x npm run smoke -- 287398 lossless +import { importQQCookieFile } from '../src/accounts.ts'; +import { config } from '../src/config.ts'; +import { downloadTrack } from '../src/core/pipeline.ts'; +import { getProvider, resolveLink } from '../src/core/providers/index.ts'; +import { isQualityKey } from '../src/core/quality.ts'; + +importQQCookieFile(); + +const [input, quality = config.defaults.quality, layout = config.defaults.layout] = process.argv.slice(2); +const ref = input ? await resolveLink(input) : null; +if (!ref || ref.kind !== 'track' || !isQualityKey(quality)) { + console.error('用法: npm run smoke -- <歌曲ID或链接> [standard|high|lossless|hires|master|spatial|effect] [album|flat]'); + process.exit(1); +} + +const mb = (n: number) => (n / 1048576).toFixed(1); +const result = await downloadTrack( + getProvider(ref.source), + ref.id, + { ...config.defaults, quality, layout: layout === 'flat' ? 'flat' : 'album' }, + { + musicDir: config.musicDir, + reporter: { + stage: (stage, msg) => console.log(`▸ [${stage}] ${msg}`), + progress: (p) => process.stdout.write(`\r ${mb(p.received)}/${mb(p.total)} MB ${(p.speed / 1024).toFixed(0)} KB/s `), + log: (level, msg) => console.log(`${process.stdout.isTTY ? '\n' : ''} ${level === 'warn' ? '⚠' : '·'} ${msg}`), + }, + }, +); +console.log('\n✔', JSON.stringify(result, null, 2)); diff --git a/server/src/accounts.ts b/server/src/accounts.ts new file mode 100644 index 0000000..6db11e4 --- /dev/null +++ b/server/src/accounts.ts @@ -0,0 +1,280 @@ +// 平台账号(QQ 音乐)。凭据加密存储,永不返回给前端。 +// 支持:粘贴 Cookie / Cookie 文件导入 / QQ 扫码登录,以及后台自动续期 musickey。 +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import { config } from './config.ts'; +import { authFromCookie, parseCookie, type QQCredentials } from './core/providers/qq/client.ts'; +import type { QQProvider } from './core/providers/qq/index.ts'; +import { + cookieFromCredential, + createQR, + credentialFromCookie, + pollQR, + refreshCredential, + type QQCredential, + type QRState, +} from './core/providers/qq/login.ts'; +import { errorMessage } from './core/util.ts'; +import { getSetting, setSetting } from './db.ts'; +import { bus } from './events.ts'; +import { decrypt, encrypt } from './secret.ts'; + +interface Secret { + cookie: string; + refreshKey?: string; + keyExpiresIn?: number; // 秒 +} + +interface StoredQQ { + cookie: string; // 加密后的 Secret JSON(旧数据为加密后的 Cookie 字符串) + savedAt: string; + via?: 'cookie' | 'file' | 'qr' | 'refresh'; + fileMtime?: number; +} + +interface RefreshRecord { + at: string; + ok: boolean; + error?: string; +} + +let secret: Secret | null = load(); + +function load(): Secret | null { + try { + const stored = getSetting('qq.account', null); + if (!stored) return null; + const plain = decrypt(stored.cookie); + return plain.startsWith('{') ? (JSON.parse(plain) as Secret) : { cookie: plain }; + } catch { + return null; + } +} + +export const qqCredentials: QQCredentials = { getCookie: () => secret?.cookie ?? null }; + +function persist(next: Secret, via: StoredQQ['via'], fileMtime?: number) { + const prev = getSetting('qq.account', null); + secret = next; + setSetting('qq.account', { + cookie: encrypt(JSON.stringify(next)), + savedAt: new Date().toISOString(), + via, + // 续期 / 扫码不应让旧的 cookie.txt 在下次启动时覆盖新凭据 + fileMtime: fileMtime ?? prev?.fileMtime, + } satisfies StoredQQ); + statusCache = null; + bus.emit('account', 'qq'); +} + +function normalizeCookie(raw: string): string { + return raw + .replace(/^cookie:\s*/i, '') + .split(/;\s*|\r?\n/) + .map((s) => s.trim()) + .filter((s) => s.includes('=')) + .join('; '); +} + +export function saveQQCookie(raw: string, via: 'cookie' | 'file' = 'cookie', fileMtime?: number): void { + const cookie = normalizeCookie(raw); + if (!authFromCookie(cookie)) { + throw Object.assign(new Error('Cookie 中缺少 uin 或 qm_keyst / qqmusic_key'), { status: 400 }); + } + persist({ cookie }, via, fileMtime); + setSetting('qq.refresh', null); +} + +function saveCredential(c: QQCredential, via: 'qr' | 'refresh') { + // 扫码登录换了账号时不保留旧 Cookie 的其他字段 + const base = via === 'refresh' ? (secret?.cookie ?? '') : ''; + persist({ cookie: cookieFromCredential(c, base), refreshKey: c.refreshKey ?? secret?.refreshKey, keyExpiresIn: c.keyExpiresIn }, via); +} + +export function clearQQCookie(): void { + secret = null; + setSetting('qq.account', null); + setSetting('qq.refresh', null); + statusCache = null; + bus.emit('account', 'qq'); +} + +/** config.qqCookieFile 指向的文件比上次导入新时自动导入(本地开发 / NAS 上放一个 cookie.txt 即可) */ +export function importQQCookieFile(): boolean { + const file = config.qqCookieFile; + if (!file || !fs.existsSync(file)) return false; + const mtime = fs.statSync(file).mtimeMs; + const stored = getSetting('qq.account', null); + if (stored?.fileMtime && stored.fileMtime >= mtime) return false; + try { + saveQQCookie(fs.readFileSync(file, 'utf-8'), 'file', mtime); + return true; + } catch (e) { + console.warn(`⚠️ QQ Cookie 文件导入失败: ${errorMessage(e)}`); + return false; + } +} + +// ---------- 续期 ---------- + +let refreshing: Promise | null = null; + +/** 续期 musickey。成功后新凭据立即持久化 */ +export function refreshQQ(provider: QQProvider): Promise { + refreshing ??= (async () => { + const cred = secret ? credentialFromCookie(secret.cookie, secret.refreshKey) : null; + if (!cred) return false; + try { + if (!cred.refreshToken) throw new Error('凭据中没有 refresh_token,无法自动续期(请扫码登录)'); + const next = await refreshCredential(provider.client, cred); + saveCredential(next, 'refresh'); + setSetting('qq.refresh', { at: new Date().toISOString(), ok: true } satisfies RefreshRecord); + console.log('🔑 QQ 音乐 musickey 已续期'); + return true; + } catch (e) { + setSetting('qq.refresh', { at: new Date().toISOString(), ok: false, error: errorMessage(e) } satisfies RefreshRecord); + statusCache = null; + bus.emit('account', 'qq'); + console.warn(`⚠️ QQ 音乐续期失败: ${errorMessage(e)}`); + return false; + } finally { + refreshing = null; + } + })(); + return refreshing; +} + +const DEFAULT_KEY_LIFETIME = 24 * 3600; // 未知有效期时按 1 天估算,提前续期 + +function keyAge(): { age: number; lifetime: number } | null { + if (!secret) return null; + const created = Number(parseCookie(secret.cookie).psrf_musickey_createtime); + if (!created) return null; + return { age: Date.now() / 1000 - created, lifetime: secret.keyExpiresIn || DEFAULT_KEY_LIFETIME }; +} + +/** 后台维护:启动后与每 3 小时检查一次,凭据过半寿命或已失效时续期 */ +export function startQQKeeper(provider: QQProvider) { + const tick = async () => { + if (!secret) return; + const k = keyAge(); + const stale = k ? k.age > k.lifetime * 0.5 : false; + const status = await getQQStatus(provider, true); + if (stale || status.valid === false) await refreshQQ(provider); + }; + setTimeout(() => void tick(), 60 * 1000).unref(); + setInterval(() => void tick(), 3 * 3600 * 1000).unref(); +} + +// ---------- 扫码登录 ---------- + +interface QRSession { + qrsig: string; + createdAt: number; + state: QRState | 'error'; + message?: string; +} + +const qrSessions = new Map(); + +export async function startQRLogin(): Promise<{ id: string; image: string }> { + for (const [id, s] of qrSessions) if (Date.now() - s.createdAt > 5 * 60 * 1000) qrSessions.delete(id); + const qr = await createQR(); + const id = crypto.randomUUID(); + qrSessions.set(id, { qrsig: qr.qrsig, createdAt: Date.now(), state: 'waiting' }); + return { id, image: qr.image }; +} + +export async function pollQRLogin(provider: QQProvider, id: string): Promise<{ state: QRSession['state']; message?: string; qq?: QQStatus }> { + const session = qrSessions.get(id); + if (!session) return { state: 'expired', message: '二维码已失效' }; + if (session.state === 'done' || session.state === 'expired' || session.state === 'refused' || session.state === 'error') { + return { state: session.state, message: session.message }; + } + try { + const result = await pollQR(provider.client, session.qrsig); + session.state = result.state; + session.message = result.message; + if (result.credential) { + saveCredential(result.credential, 'qr'); + setSetting('qq.refresh', null); + qrSessions.delete(id); + return { state: 'done', qq: await getQQStatus(provider, true) }; + } + } catch (e) { + session.state = 'error'; + session.message = errorMessage(e); + } + return { state: session.state, message: session.message }; +} + +// ---------- 状态 ---------- + +export interface QQStatus { + loggedIn: boolean; + valid?: boolean; + uin?: string; // 打码 + nick?: string; + avatar?: string; + vip?: { active: boolean; label: string; expires?: string }; + keyCreatedAt?: string; + keyExpiresAt?: string; + savedAt?: string; + via?: StoredQQ['via']; + canRefresh?: boolean; + lastRefresh?: RefreshRecord | null; + error?: string; +} + +let statusCache: { at: number; value: QQStatus } | null = null; + +function maskUin(uin: string): string { + return uin.length > 4 ? `${uin.slice(0, 2)}${'*'.repeat(uin.length - 4)}${uin.slice(-2)}` : uin; +} + +export async function getQQStatus(provider: QQProvider, force = false): Promise { + if (!force && statusCache && Date.now() - statusCache.at < 5 * 60 * 1000) return statusCache.value; + const auth = authFromCookie(secret?.cookie ?? null); + if (!auth || !secret) return { loggedIn: false }; + + const ck = parseCookie(auth.cookie); + const stored = getSetting('qq.account', null); + const created = Number(ck.psrf_musickey_createtime); + const base: QQStatus = { + loggedIn: true, + uin: maskUin(auth.uin), + keyCreatedAt: created ? new Date(created * 1000).toISOString() : undefined, + keyExpiresAt: created && secret.keyExpiresIn ? new Date((created + secret.keyExpiresIn) * 1000).toISOString() : undefined, + savedAt: stored?.savedAt, + via: stored?.via, + canRefresh: !!ck.psrf_qqrefresh_token, + lastRefresh: getSetting('qq.refresh', null), + }; + try { + const res = await provider.client.musicu({ + vip: { module: 'VipLogin.VipLoginInter', method: 'vip_login_base', param: {} }, + user: { module: 'userInfo.BaseUserInfoServer', method: 'get_user_baseinfo_v2', param: { vec_uin: [auth.uin] } }, + login: { module: 'music.UserInfo.userInfoServer', method: 'GetLoginUserInfo', param: {} }, + }); + const id = res.vip.data?.identity || {}; + const user = res.user.data?.map_userinfo?.[auth.uin]; + // GetLoginUserInfo 在 musickey 失效时返回 1000 / 104400 等 + const valid = res.login.code === 0; + const svip = Number(id.HugeVipFlag || id.SuperVipFlag || 0) > 0; + const value: QQStatus = { + ...base, + valid, + nick: user?.nick, + avatar: user?.headurl?.replace(/^http:/, 'https:'), + vip: { + active: Number(id.vip || 0) > 0 || svip, + label: svip ? '豪华绿钻' : Number(id.vip || 0) > 0 ? '绿钻' : '普通用户', + expires: id.eightEnd || id.HugeVipEnd || id.VipEnd || undefined, + }, + }; + statusCache = { at: Date.now(), value }; + return value; + } catch (e) { + return { ...base, valid: false, error: errorMessage(e) }; + } +} diff --git a/server/src/app.ts b/server/src/app.ts new file mode 100644 index 0000000..68e3f28 --- /dev/null +++ b/server/src/app.ts @@ -0,0 +1,43 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { bodyParser } from '@koa/bodyparser'; +import Koa from 'koa'; +import serve from 'koa-static'; +import { ROOT_DIR } from './config.ts'; +import { errorMessage } from './core/util.ts'; +import { authGuard } from './auth.ts'; +import { api } from './routes/api.ts'; + +const WEB_DIR = path.join(ROOT_DIR, 'dist/web'); + +export function createApp(): Koa { + const app = new Koa(); + + app.use(async (ctx, next) => { + try { + await next(); + } catch (e) { + const status = (e as { status?: number }).status ?? 500; + ctx.status = status; + ctx.body = { error: errorMessage(e) }; + if (status >= 500) console.error(`✖ ${ctx.method} ${ctx.path}:`, e); + } + }); + + app.use(authGuard); + app.use(bodyParser()); + app.use(api.routes()).use(api.allowedMethods()); + + // 前端静态资源 + SPA 回退 + if (fs.existsSync(WEB_DIR)) { + app.use(serve(WEB_DIR, { maxage: 7 * 24 * 3600 * 1000, index: false })); + app.use(async (ctx) => { + if (ctx.method !== 'GET' || ctx.path.startsWith('/api')) return; + ctx.type = 'html'; + ctx.set('Cache-Control', 'no-cache'); + ctx.body = fs.createReadStream(path.join(WEB_DIR, 'index.html')); + }); + } + + return app; +} diff --git a/server/src/auth.ts b/server/src/auth.ts new file mode 100644 index 0000000..ade555b --- /dev/null +++ b/server/src/auth.ts @@ -0,0 +1,59 @@ +// 可选的访问密码(单用户)。未设置密码时不做任何拦截,方便本机使用。 +import type { Context, Next } from 'koa'; +import { getSetting, setSetting } from './db.ts'; +import { hashPassword, sign, verifyPassword } from './secret.ts'; + +const COOKIE = 'sx_session'; +const SESSION_DAYS = 30; +const PUBLIC_PATHS = ['/api/auth/', '/api/health']; + +export function passwordEnabled(): boolean { + return !!getSetting('auth.password', null); +} + +function issue(ctx: Context) { + const expires = Date.now() + SESSION_DAYS * 86400000; + const value = `${expires}.${sign(String(expires))}`; + ctx.cookies.set(COOKIE, value, { httpOnly: true, sameSite: 'lax', expires: new Date(expires), overwrite: true }); +} + +export function isAuthenticated(ctx: Context): boolean { + if (!passwordEnabled()) return true; + const [expires, sig] = (ctx.cookies.get(COOKIE) || '').split('.'); + return !!expires && !!sig && Number(expires) > Date.now() && sign(expires) === sig; +} + +export async function authGuard(ctx: Context, next: Next) { + if (ctx.path.startsWith('/api/') && !PUBLIC_PATHS.some((p) => ctx.path.startsWith(p)) && !isAuthenticated(ctx)) { + ctx.status = 401; + ctx.body = { error: '需要登录' }; + return; + } + await next(); +} + +export function login(ctx: Context, password: string): boolean { + const stored = getSetting('auth.password', null); + if (!stored || !verifyPassword(password, stored)) return false; + issue(ctx); + return true; +} + +export function logout(ctx: Context) { + ctx.cookies.set(COOKIE, '', { expires: new Date(0) }); +} + +/** 设置或清除访问密码;已有密码时需要提供当前密码 */ +export function changePassword(ctx: Context, current: string | undefined, next: string | undefined) { + const stored = getSetting('auth.password', null); + if (stored && !verifyPassword(current || '', stored)) { + throw Object.assign(new Error('当前密码不正确'), { status: 403 }); + } + if (next) { + if (next.length < 4) throw Object.assign(new Error('密码至少 4 位'), { status: 400 }); + setSetting('auth.password', hashPassword(next)); + issue(ctx); + } else { + setSetting('auth.password', null); + } +} diff --git a/server/src/config.ts b/server/src/config.ts new file mode 100644 index 0000000..79dc4d8 --- /dev/null +++ b/server/src/config.ts @@ -0,0 +1,127 @@ +// 应用配置:读取根目录 config.json,再用环境变量覆盖(Docker 部署时主要靠环境变量) +import fs from 'node:fs'; +import path from 'node:path'; +import { isQualityKey, type QualityKey } from './core/quality.ts'; + +export const ROOT_DIR = path.resolve(import.meta.dirname, '../..'); + +export type Layout = 'album' | 'flat'; + +export interface DownloadDefaults { + quality: QualityKey; + layout: Layout; + retries: number; + lyric: boolean; + translatedLyric: boolean; + embedTags: boolean; + albumCover: boolean; +} + +export interface AppConfig { + port: number; + musicDir: string; + dataDir: string; + legacyHistoryFile: string | null; + /** QQ 音乐 Cookie 文件;比已保存的新时自动导入 */ + qqCookieFile: string | null; + netease: { + apikey: string; + music: string; + lyric: string; + search: string; + playlist: string; + baseUrl: string; + }; + defaults: DownloadDefaults; +} + +const DEFAULTS: AppConfig = { + port: 8066, + musicDir: 'music', + dataDir: 'data', + legacyHistoryFile: null, + qqCookieFile: null, + netease: { + apikey: 'chksz_RGXIby7J6ok-GPa6nMrFgHgQtwEGD-hILAhZXpIa72Q', + music: 'https://api.chksz.com/api/163_music', + lyric: 'https://api.chksz.com/api/163_lyric', + search: 'https://api.chksz.com/api/163_search', + playlist: 'https://api.chksz.com/api/163_playlist', + baseUrl: 'https://music.163.com', + }, + defaults: { + quality: 'master', + layout: 'album', + retries: 3, + lyric: true, + translatedLyric: true, + embedTags: true, + albumCover: true, + }, +}; + +function readJson(file: string): Record { + try { + return JSON.parse(fs.readFileSync(file, 'utf-8')); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { + console.warn(`⚠️ 配置文件读取失败,使用默认配置: ${(e as Error).message}`); + } + return {}; + } +} + +function str(value: unknown, fallback: string): string { + return typeof value === 'string' && value.trim() ? value.trim() : fallback; +} + +function bool(value: unknown, fallback: boolean): boolean { + if (typeof value === 'boolean') return value; + if (value === 'true' || value === '1') return true; + if (value === 'false' || value === '0') return false; + return fallback; +} + +function resolvePath(value: string): string { + return path.isAbsolute(value) ? value : path.join(ROOT_DIR, value); +} + +function loadConfig(): AppConfig { + const file = readJson(path.join(ROOT_DIR, 'config.json')); + const env = process.env; + const ne = { ...DEFAULTS.netease, ...(file.netease || {}) }; + const df = { ...DEFAULTS.defaults, ...(file.defaults || {}) }; + const quality = str(env.DEFAULT_QUALITY, df.quality); + const layout = str(env.DEFAULT_LAYOUT, df.layout); + const retries = Number(env.DEFAULT_RETRIES ?? df.retries); + + return { + port: Number(env.PORT || file.server?.port || DEFAULTS.port), + musicDir: resolvePath(str(env.MUSIC_DIR, str(file.paths?.musicDir, DEFAULTS.musicDir))), + dataDir: resolvePath(str(env.DATA_DIR, str(file.paths?.dataDir, DEFAULTS.dataDir))), + legacyHistoryFile: env.LEGACY_HISTORY_FILE ? resolvePath(env.LEGACY_HISTORY_FILE) : null, + qqCookieFile: env.QQ_COOKIE_FILE || file.qq?.cookieFile ? resolvePath(str(env.QQ_COOKIE_FILE, file.qq?.cookieFile)) : null, + netease: { + apikey: str(env.CHKSZ_API_KEY, str(ne.apikey, DEFAULTS.netease.apikey)), + music: str(ne.music, DEFAULTS.netease.music), + lyric: str(ne.lyric, DEFAULTS.netease.lyric), + search: str(ne.search, DEFAULTS.netease.search), + playlist: str(ne.playlist, DEFAULTS.netease.playlist), + baseUrl: str(ne.baseUrl, DEFAULTS.netease.baseUrl).replace(/\/$/, ''), + }, + defaults: { + quality: isQualityKey(quality) ? quality : DEFAULTS.defaults.quality, + layout: layout === 'flat' ? 'flat' : 'album', + retries: Number.isInteger(retries) && retries > 0 ? retries : DEFAULTS.defaults.retries, + lyric: bool(df.lyric, true), + translatedLyric: bool(df.translatedLyric, true), + embedTags: bool(df.embedTags, true), + albumCover: bool(df.albumCover, true), + }, + }; +} + +export const config = loadConfig(); + +fs.mkdirSync(config.musicDir, { recursive: true }); +fs.mkdirSync(config.dataDir, { recursive: true }); diff --git a/server/src/core/layout.ts b/server/src/core/layout.ts new file mode 100644 index 0000000..872b4af --- /dev/null +++ b/server/src/core/layout.ts @@ -0,0 +1,37 @@ +// 输出目录与文件名规则 +// album: {音乐目录}/{第一位专辑艺人}/{专辑}/{NN}. {歌名}.ext —— 与 NAS 现有曲库结构一致 +// flat : {音乐目录}/{歌手} - {歌名}.ext —— 旧版单曲下载格式 +import path from 'node:path'; +import type { Layout } from '../config.ts'; +import { sanitize, sanitizePathSegment } from './util.ts'; + +export interface LayoutInput { + name: string; + artists: string; + album: string; + albumArtistPrimary?: string; + trackNo?: number; + trackTotal?: number; +} + +export interface Target { + dir: string; + baseName: string; // 不含扩展名 + isAlbumDir: boolean; +} + +export function resolveTarget(musicDir: string, layout: Layout, input: LayoutInput): Target { + const title = input.name || '未知歌曲'; + if (layout === 'flat') { + return { dir: musicDir, baseName: sanitize(`${input.artists} - ${title}`), isAlbumDir: false }; + } + const firstArtist = input.artists.split(' / ')[0]; + const artistDir = sanitizePathSegment(input.albumArtistPrimary || firstArtist, '未知歌手'); + const albumDir = sanitizePathSegment(input.album, '未知专辑'); + let baseName = title; + if (input.trackNo) { + const width = Math.max(2, String(input.trackTotal || 0).length); + baseName = `${String(input.trackNo).padStart(width, '0')}. ${title}`; + } + return { dir: path.join(musicDir, artistDir, albumDir), baseName: sanitize(baseName), isAlbumDir: true }; +} diff --git a/server/src/core/lyrics.ts b/server/src/core/lyrics.ts new file mode 100644 index 0000000..7c82707 --- /dev/null +++ b/server/src/core/lyrics.ts @@ -0,0 +1,58 @@ +import { formatPersonNames } from './util.ts'; + +/** 按时间轴把翻译歌词插到原文对应行之后 */ +export function mergeLrc(lrc: string | undefined, tlyric: string | undefined): string { + if (!lrc) return ''; + if (!tlyric) return lrc; + + const transMap = new Map(); + for (const line of tlyric.split('\n')) { + const match = line.match(/^\[(\d+:\d+[.:]\d+)\](.*)/); + if (match && match[2]!.trim()) transMap.set(match[1]!, match[2]!.trim()); + } + + const result: string[] = []; + for (const line of lrc.split('\n')) { + result.push(line); + const match = line.match(/^\[(\d+:\d+[.:]\d+)\]/); + if (match && transMap.has(match[1]!)) result.push(`[${match[1]}]${transMap.get(match[1]!)}`); + } + return result.join('\n'); +} + +export interface Credits { + lyricist?: string; + composer?: string; + arranger?: string; + producer?: string; + mixer?: string; +} + +const CREDIT_LABELS: Record = { + lyricist: /^(作词|作詞|词|填词|Lyricist|Lyrics? by|Written by)$/i, + composer: /^(作曲|曲|Composer|Composed by|Music by)$/i, + arranger: /^(编曲|編曲|Arranger|Arranged by)$/i, + producer: /^(制作人|製作人|出品人|监制|Producer|Produced by)$/i, + mixer: /^(混音|Mix(ing)?|Mixed by)$/i, +}; + +/** 从歌词开头的 "作词 : xxx" 等行解析制作人员 */ +export function parseCredits(lrcText: string | undefined): Credits { + const credits: Credits = {}; + if (!lrcText) return credits; + for (const raw of lrcText.split('\n')) { + const line = raw.replace(/^(\s*\[[^\]]*\]\s*)+/, '').trim(); + const match = line.match(/^([^::]{1,12})\s*[::]\s*(.+)$/); + if (!match) continue; + const label = match[1]!.trim(); + const value = formatPersonNames(match[2]!.trim()); + if (!value) continue; + for (const [key, pattern] of Object.entries(CREDIT_LABELS) as Array<[keyof Credits, RegExp]>) { + if (pattern.test(label) && !credits[key]) { + credits[key] = value; + break; + } + } + } + return credits; +} diff --git a/server/src/core/net/http.ts b/server/src/core/net/http.ts new file mode 100644 index 0000000..9460840 --- /dev/null +++ b/server/src/core/net/http.ts @@ -0,0 +1,103 @@ +// 基于全局 fetch 的网络工具:JSON 请求、二进制下载、带进度的流式落盘 +import fs from 'node:fs'; +import { Readable, Transform } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; + +export const UA = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0 Safari/537.36'; + +interface RequestOpts { + headers?: Record; + timeoutMs?: number; + signal?: AbortSignal; +} + +function withTimeout(timeoutMs: number, signal?: AbortSignal): AbortSignal { + const timeout = AbortSignal.timeout(timeoutMs); + return signal ? AbortSignal.any([signal, timeout]) : timeout; +} + +export async function fetchJson(url: string, opts: RequestOpts = {}): Promise { + const res = await fetch(url, { + headers: { 'User-Agent': UA, Accept: 'application/json', ...opts.headers }, + signal: withTimeout(opts.timeoutMs ?? 15000, opts.signal), + }); + const text = await res.text(); + try { + return JSON.parse(text) as T; + } catch { + throw new Error(`JSON 解析失败 (HTTP ${res.status}): ${text.slice(0, 200)}`); + } +} + +export async function fetchBuffer(url: string, opts: RequestOpts = {}): Promise { + const res = await fetch(url, { + headers: { 'User-Agent': UA, ...opts.headers }, + signal: withTimeout(opts.timeoutMs ?? 30000, opts.signal), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return Buffer.from(await res.arrayBuffer()); +} + +export interface Progress { + received: number; + total: number; // 0 表示未知 + speed: number; // bytes/s +} + +/** + * 流式下载到文件。先写入 dest.part,完成后再改名,避免半截文件被当作已下载。 + * idleTimeoutMs 内没有收到数据则中止。 + */ +export async function downloadToFile( + url: string, + dest: string, + opts: { signal?: AbortSignal; onProgress?: (p: Progress) => void; idleTimeoutMs?: number; headers?: Record } = {}, +): Promise { + const idle = new AbortController(); + const signal = opts.signal ? AbortSignal.any([opts.signal, idle.signal]) : idle.signal; + const idleMs = opts.idleTimeoutMs ?? 30000; + let idleTimer = setTimeout(() => idle.abort(new Error('下载超时:长时间未收到数据')), idleMs); + const resetIdle = () => { + clearTimeout(idleTimer); + idleTimer = setTimeout(() => idle.abort(new Error('下载超时:长时间未收到数据')), idleMs); + }; + + const tmp = `${dest}.part`; + try { + const res = await fetch(url, { headers: { 'User-Agent': UA, ...opts.headers }, signal }); + if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`); + + const total = Number(res.headers.get('content-length')) || 0; + let received = 0; + let lastBytes = 0; + let lastTime = Date.now(); + let speed = 0; + + const meter = new Transform({ + transform(chunk: Buffer, _enc, cb) { + received += chunk.length; + resetIdle(); + const now = Date.now(); + const elapsed = now - lastTime; + if (elapsed >= 300) { + speed = ((received - lastBytes) * 1000) / elapsed; + lastBytes = received; + lastTime = now; + opts.onProgress?.({ received, total, speed }); + } + cb(null, chunk); + }, + }); + + await pipeline(Readable.fromWeb(res.body as any), meter, fs.createWriteStream(tmp), { signal }); + opts.onProgress?.({ received, total: total || received, speed }); + await fs.promises.rename(tmp, dest); + return received; + } catch (e) { + await fs.promises.rm(tmp, { force: true }); + throw signal.aborted && signal.reason instanceof Error ? signal.reason : e; + } finally { + clearTimeout(idleTimer); + } +} diff --git a/server/src/core/pipeline.ts b/server/src/core/pipeline.ts new file mode 100644 index 0000000..3b0d0d6 --- /dev/null +++ b/server/src/core/pipeline.ts @@ -0,0 +1,201 @@ +// 单曲下载流水线(由旧版 lib/downloader.js 泛化而来,与平台无关): +// 解析音源 → 元数据 → 同名文件检查 → 流式下载 → 歌词 → 专辑封面 → 写标签 +import fs from 'node:fs'; +import path from 'node:path'; +import type { Layout } from '../config.ts'; +import { resolveTarget } from './layout.ts'; +import { mergeLrc, parseCredits } from './lyrics.ts'; +import { downloadToFile, fetchBuffer, type Progress } from './net/http.ts'; +import type { QualityKey } from './quality.ts'; +import { embedTags, type TagMeta } from './tagging.ts'; +import { FatalError, type MusicProvider, type TrackMeta } from './types.ts'; +import { errorMessage, sleep } from './util.ts'; + +export interface DownloadOptions { + quality: QualityKey; + layout: Layout; + retries: number; + lyric: boolean; + translatedLyric: boolean; + embedTags: boolean; + albumCover: boolean; +} + +const TAG_OVERHEAD_MAX = 8 * 1024 * 1024; + +export type Stage ='resolve' | 'metadata' | 'download' | 'lyric' | 'cover' | 'tag'; + +export interface Reporter { + stage?(stage: Stage, message: string): void; + progress?(p: Progress): void; + log?(level: 'info' | 'warn', message: string): void; +} + +export interface DownloadResult { + source: string; + id: string; + name: string; + artists: string; + album: string; + quality: QualityKey; + bitrate?: number; + size: number; + file: string; // 绝对路径 + relFile: string; // 相对音乐目录 + skipped: boolean; + warnings: string[]; +} + +interface Context { + musicDir: string; + signal?: AbortSignal; + reporter?: Reporter; +} + +export async function downloadTrack( + provider: MusicProvider, + id: string, + opts: DownloadOptions, + ctx: Context, +): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= opts.retries; attempt++) { + try { + return await downloadOnce(provider, id, opts, ctx); + } catch (e) { + lastError = e; + if (e instanceof FatalError || ctx.signal?.aborted) throw e; + if (attempt < opts.retries) { + const waitSec = attempt * 3; + ctx.reporter?.log?.('warn', `第 ${attempt} 次失败:${errorMessage(e)},${waitSec} 秒后重试`); + await sleep(waitSec * 1000, ctx.signal); + } + } + } + throw lastError; +} + +async function downloadOnce(provider: MusicProvider, id: string, opts: DownloadOptions, ctx: Context): Promise { + const { reporter, signal } = ctx; + const warnings: string[] = []; + const warn = (msg: string) => { + warnings.push(msg); + reporter?.log?.('warn', msg); + }; + + reporter?.stage?.('resolve', '解析音源'); + const audio = await provider.resolveAudio(id, opts.quality, signal); + + reporter?.stage?.('metadata', '获取元数据'); + const meta = await provider.getTrackMeta(id).catch(() => null); + const name = audio.name || meta?.name || id; + const artists = audio.artists || meta?.artists || '未知歌手'; + const album = audio.album || meta?.album || ''; + + const target = resolveTarget(ctx.musicDir, opts.layout, { + name, + artists, + album, + albumArtistPrimary: meta?.albumArtistPrimary, + trackNo: meta?.trackNo, + trackTotal: meta?.trackTotal, + }); + await fs.promises.mkdir(target.dir, { recursive: true }); + const dest = path.join(target.dir, `${target.baseName}.${audio.ext}`); + + // 同名文件视为已下载的条件:不小于音源的 99%,且最多比音源大 8MB(嵌入的封面 + 歌词会让文件变大)。 + // 下载先写 .part 再改名,不会留下半截文件。 + let skipped = false; + const existing = await fs.promises.stat(dest).catch(() => null); + if (existing && (!audio.size || (existing.size >= audio.size * 0.99 && existing.size <= audio.size + TAG_OVERHEAD_MAX))) { + skipped = true; + reporter?.log?.('info', '文件已存在,跳过下载'); + } else { + if (existing) reporter?.log?.('info', '已有同名文件但大小不同,覆盖下载'); + reporter?.stage?.('download', '下载音频'); + await downloadToFile(audio.url, dest, { signal, onProgress: (p) => reporter?.progress?.(p) }); + } + + let lrc: string | undefined; + if (opts.lyric) { + reporter?.stage?.('lyric', '保存歌词'); + const lyric = await provider.getLyric(id); + if (lyric?.lrc) { + lrc = lyric.lrc; + await fs.promises.writeFile(path.join(target.dir, `${target.baseName}.lrc`), lyric.lrc, 'utf-8'); + if (opts.translatedLyric && lyric.tlyric?.trim()) { + await fs.promises.writeFile( + path.join(target.dir, `${target.baseName}.合并翻译.lrc`), + mergeLrc(lyric.lrc, lyric.tlyric), + 'utf-8', + ); + } + } else { + warn('未获取到歌词'); + } + } + + const coverUrl = meta?.cover || audio.cover; + const wantAlbumCover = opts.albumCover && target.isAlbumDir; + let cover: Buffer | null = null; + if (coverUrl && (opts.embedTags || wantAlbumCover)) { + reporter?.stage?.('cover', '获取封面'); + cover = await fetchBuffer(coverUrl, { signal }).catch((e) => { + warn(`封面下载失败:${errorMessage(e)}`); + return null; + }); + } + if (cover && wantAlbumCover) { + const coverPath = path.join(target.dir, 'cover.jpg'); + const stat = await fs.promises.stat(coverPath).catch(() => null); + if (!stat?.size) await fs.promises.writeFile(coverPath, cover); + } + + if (opts.embedTags) { + reporter?.stage?.('tag', '写入标签'); + try { + const method = await embedTags(dest, cover, buildTagMeta(name, artists, album, meta, lrc)); + reporter?.log?.('info', `封面+标签+歌词已嵌入 (${method})`); + } catch (e) { + warn(`标签写入失败:${errorMessage(e)}`); + } + } + + const size = (await fs.promises.stat(dest)).size; + return { + source: provider.source, + id, + name, + artists, + album, + quality: audio.quality, + bitrate: audio.bitrate, + size, + file: dest, + relFile: path.relative(ctx.musicDir, dest).split(path.sep).join('/'), + skipped, + warnings, + }; +} + +/** 组装标签字段,规则同旧版 downloader.js */ +export function buildTagMeta( + name: string, + artist: string, + album: string, + meta: TrackMeta | null, + lrc: string | undefined, +): TagMeta { + const tag: TagMeta = { name, artist, album, lyrics: lrc, ...parseCredits(lrc) }; + if (!meta) return tag; + if (meta.trackNo) tag.trackNo = meta.trackTotal ? `${meta.trackNo}/${meta.trackTotal}` : String(meta.trackNo); + if (meta.disc) tag.disc = meta.disc; + if (meta.albumArtist) tag.albumArtist = meta.albumArtist; + if (meta.compilation) tag.compilation = true; + if (meta.year) tag.year = meta.year; + if (meta.date) tag.date = meta.date; + if (meta.genre) tag.genre = meta.genre; + if (meta.publisher) tag.publisher = meta.publisher; + return tag; +} + diff --git a/server/src/core/providers/index.ts b/server/src/core/providers/index.ts new file mode 100644 index 0000000..c746b9c --- /dev/null +++ b/server/src/core/providers/index.ts @@ -0,0 +1,54 @@ +import { qqCredentials } from '../../accounts.ts'; +import { config } from '../../config.ts'; +import { UA } from '../net/http.ts'; +import type { LinkRef, MusicProvider, Source } from '../types.ts'; +import { NeteaseProvider } from './netease/index.ts'; +import { QQProvider } from './qq/index.ts'; + +export const netease = new NeteaseProvider(config.netease); +export const qq = new QQProvider(qqCredentials); + +const providers: Record = { netease, qq }; + +export function getProvider(source: string): MusicProvider { + const provider = providers[source as Source]; + if (!provider) throw Object.assign(new Error(`不支持的音乐来源: ${source}`), { status: 400 }); + return provider; +} + +export function availableSources(): Source[] { + return Object.keys(providers) as Source[]; +} + +/** 识别分享链接 / ID;纯数字按网易云歌曲 ID,14 位 00 开头按 QQ songmid */ +export function parseAnyLink(input: string): LinkRef | null { + for (const provider of Object.values(providers)) { + const ref = provider.parseLink(input); + if (ref) return ref; + } + return null; +} + +const SHORT_HOSTS = /^(?:c\d*\.y\.qq\.com|url\.cn|163cn\.tv|y\.music\.163\.com\/m\/s)/; + +/** 先同步识别;识别不了的短链跟随一次跳转后再识别 */ +export async function resolveLink(input: string): Promise { + const direct = parseAnyLink(input); + if (direct) return direct; + const url = input.trim().match(/https?:\/\/\S+/)?.[0]; + if (!url) return null; + try { + const host = new URL(url).host; + if (!SHORT_HOSTS.test(host) && !/qq\.com|163/.test(host)) return null; + let current = url; + for (let i = 0; i < 4; i++) { + const res = await fetch(current, { redirect: 'manual', headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(8000) }); + const location = res.headers.get('location'); + if (!location) break; + current = new URL(location, current).toString(); + const ref = parseAnyLink(current); + if (ref) return ref; + } + } catch {} + return null; +} diff --git a/server/src/core/providers/netease/index.ts b/server/src/core/providers/netease/index.ts new file mode 100644 index 0000000..b8c444a --- /dev/null +++ b/server/src/core/providers/netease/index.ts @@ -0,0 +1,314 @@ +// 网易云 provider:音频地址 / 搜索 / 歌词 / 歌单走 chksz 接口,丰富元数据直接请求 music.163.com +import { fetchJson, UA } from '../../net/http.ts'; +import { fallbackChain, type QualityKey } from '../../quality.ts'; +import { + FatalError, + type AlbumDetail, + type AlbumSummary, + type ArtistDetail, + type LinkRef, + type Lyric, + type MusicProvider, + type Paged, + type PlaylistDetail, + type ResolvedAudio, + type TrackMeta, + type TrackSummary, +} from '../../types.ts'; +import { formatPersonNames, joinNames, TtlCache } from '../../util.ts'; + +export interface NeteaseConfig { + apikey: string; + music: string; + lyric: string; + search: string; + playlist: string; + baseUrl: string; +} + +const LEVEL: Record = { + standard: 'standard', + high: 'exhigh', + lossless: 'lossless', + hires: 'hires', + master: 'jymaster', + spatial: 'sky', + effect: 'jyeffect', +}; +const LEVEL_TO_KEY = Object.fromEntries(Object.entries(LEVEL).map(([k, v]) => [v, k])) as Record; + +const COMPILATION_NAMES = ['群星', 'variousartists', 'variousartist', 'va']; + +/** 网易云歌曲对象中 l/m/h/sq/hr 字段声明的音质(chksz 的搜索结果没有这些字段) */ +function declaredQualities(t: any): QualityKey[] | undefined { + if (!t || !('h' in t || 'sq' in t || 'hr' in t)) return undefined; + const out: QualityKey[] = []; + if (t.l || t.m) out.push('standard'); + if (t.h) out.push('high'); + if (t.sq) out.push('lossless'); + if (t.hr) out.push('hires'); + return out; +} + +interface ChkszResponse { + code: number; + msg?: string; + data?: T; +} + +interface NeteaseAlbumRaw { + id: string; + name: string; + publishTime?: number; + company?: string; + description?: string; + tags: string[]; + size: number; + artists: Array<{ id: number; name: string }>; + albumArtist: string; + isCompilation: boolean; + picUrl?: string; + songs: any[]; +} + +export class NeteaseProvider implements MusicProvider { + readonly source = 'netease' as const; + readonly qualities = ['standard', 'high', 'lossless', 'hires', 'master', 'spatial', 'effect'] as const; + private readonly cfg: NeteaseConfig; + private readonly albumCache = new TtlCache(10 * 60 * 1000); + + constructor(cfg: NeteaseConfig) { + this.cfg = cfg; + } + + // ---- chksz ---- + + private chksz(endpoint: string, params: Record, signal?: AbortSignal) { + const url = new URL(endpoint); + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v)); + if (this.cfg.apikey) url.searchParams.set('apikey', this.cfg.apikey); + return fetchJson>(url.toString(), { signal }); + } + + async search(keyword: string, { page = 1, limit = 30 } = {}): Promise> { + const res = await this.chksz(this.cfg.search, { keyword, limit, offset: (page - 1) * limit }); + if (res.code !== 200) throw new Error(res.msg || '搜索失败'); + const songs: any[] = Array.isArray(res.data) ? res.data : res.data?.songs || []; + return { + items: songs.map((s) => ({ + source: this.source, + id: String(s.id), + name: s.name, + artists: formatPersonNames(s.artists || ''), + album: s.album || '', + cover: s.picUrl, + duration: s.duration, + })), + total: res.data?.total ?? songs.length, + page, + limit, + }; + } + + async getLyric(id: string): Promise { + try { + const res = await this.chksz(this.cfg.lyric, { id }); + return res.code === 200 && res.data ? res.data : null; + } catch { + return null; + } + } + + async getPlaylist(id: string): Promise { + const res = await this.chksz(this.cfg.playlist, { id }); + const data = res.data; + if (!data?.tracks?.length) return null; + return { + source: this.source, + id: String(data.id ?? id), + name: data.name, + cover: data.coverImgUrl, + creator: data.creator?.nickname, + trackCount: data.trackCount ?? data.tracks.length, + tracks: data.tracks.map((t: any) => this.toSummary(t)), + }; + } + + async resolveAudio(id: string, quality: QualityKey, signal?: AbortSignal): Promise { + let lastMsg = ''; + for (const q of fallbackChain(quality)) { + const res = await this.chksz(this.cfg.music, { id, level: LEVEL[q] }, signal); + const d = res.data; + if (res.code === 200 && d?.url) { + return { + url: d.url, + ext: (d.url.match(/\.(mp3|flac|m4a|wav|aac)(\?|$)/i)?.[1] || 'mp3').toLowerCase(), + quality: LEVEL_TO_KEY[d.level] ?? q, + bitrate: d.br ? Math.round(d.br / 1000) : undefined, + size: d.size, + md5: d.md5, + name: d.name, + artists: formatPersonNames(d.artist), + album: d.album, + cover: d.picUrl, + }; + } + lastMsg = res.msg || `code ${res.code}`; + // 404 = 该音质不可用,继续降级;其他错误直接抛出交给重试 + if (res.code !== 404) throw new Error(lastMsg); + } + throw new FatalError(`无可用音源:${lastMsg}`); + } + + // ---- music.163.com ---- + + private async web(pathname: string): Promise { + try { + return await fetchJson(new URL(pathname, `${this.cfg.baseUrl}/`).toString(), { + headers: { Referer: this.cfg.baseUrl, 'User-Agent': UA, Cookie: 'os=pc' }, + timeoutMs: 8000, + }); + } catch { + return null; + } + } + + private fetchAlbumRaw(albumId: string): Promise { + return this.albumCache.getOrSet(albumId, async () => { + const data = await this.web(`/api/v1/album/${albumId}`); + if (!data) return null; + const album = data.album || data; + if (!album?.name) return null; + const artists = (album.artists?.length ? album.artists : [album.artist]).filter((a: any) => a?.name); + const albumArtist = joinNames(artists); + return { + id: String(album.id), + name: album.name, + publishTime: album.publishTime || undefined, + company: album.company || '', + description: album.description || '', + tags: album.tags || [], + size: album.size || 0, + artists, + albumArtist, + isCompilation: COMPILATION_NAMES.includes(albumArtist.toLowerCase().replace(/[.\s]/g, '')), + picUrl: album.picUrl || '', + songs: data.songs || album.songs || [], + }; + }); + } + + async getAlbum(id: string): Promise { + const raw = await this.fetchAlbumRaw(id); + if (!raw) return null; + return { + source: this.source, + id: raw.id, + name: raw.name, + artist: raw.albumArtist, + artistRefs: raw.artists.map((a) => ({ id: String(a.id), name: a.name })), + cover: raw.picUrl, + publishTime: raw.publishTime, + size: raw.size || raw.songs.length, + company: raw.company, + description: raw.description, + tracks: raw.songs.map((s) => this.toSummary(s, raw)), + }; + } + + async getTrackMeta(id: string): Promise { + const data = await this.web(`/api/v1/song/detail?ids=[${id}]`); + const song = data?.songs?.[0]; + if (!song) return null; + + const meta: TrackMeta = { + source: this.source, + id: String(song.id), + name: song.name, + artists: joinNames(song.ar), + album: song.al?.name || '', + albumId: song.al?.id ? String(song.al.id) : undefined, + cover: song.al?.picUrl, + duration: song.dt, + trackNo: song.no || undefined, + disc: song.cd || undefined, + }; + + if (meta.albumId) { + const album = await this.fetchAlbumRaw(meta.albumId); + if (album) { + meta.albumArtist = album.albumArtist || undefined; + meta.albumArtistPrimary = album.artists[0]?.name; + meta.compilation = album.isCompilation || undefined; + meta.trackTotal = album.size || undefined; + meta.cover = album.picUrl || meta.cover; + if (album.publishTime) { + const date = new Date(album.publishTime); + meta.year = date.getFullYear(); + meta.date = date.toISOString().slice(0, 10); + } + if (album.tags.length) meta.genre = album.tags.join(', '); + if (album.company) meta.publisher = album.company; + } + } + if (!meta.albumArtist) meta.albumArtist = meta.artists || undefined; + return meta; + } + + async getArtist(id: string): Promise { + const [detail, albums] = await Promise.all([ + this.web(`/api/v1/artist/${id}`), + this.web(`/api/artist/albums/${id}?limit=100&offset=0`), + ]); + const artist = detail?.artist; + if (!artist?.name) return null; + return { + source: this.source, + id: String(artist.id), + name: artist.name, + avatar: artist.picUrl || artist.img1v1Url || undefined, + description: artist.briefDesc || undefined, + tracks: (detail.hotSongs || []).map((s: any) => this.toSummary(s)), + albums: (albums?.hotAlbums || []).map( + (a: any): AlbumSummary => ({ + source: this.source, + id: String(a.id), + name: a.name, + artist: joinNames(a.artists?.length ? a.artists : [a.artist]), + cover: a.picUrl || undefined, + publishTime: a.publishTime || undefined, + size: a.size || undefined, + type: a.subType || a.type || undefined, + }), + ), + }; + } + + parseLink(input: string): LinkRef | null { + const text = input.trim(); + if (/^\d+$/.test(text)) return { source: this.source, kind: 'track', id: text }; + if (!/163\.com|163cn\.tv/.test(text)) return null; + const m = text.match(/(song|album|playlist|artist)(?:\/|\?(?:.*&)?id=)(\d+)/); + if (!m) return null; + const kind = m[1] === 'song' ? 'track' : (m[1] as LinkRef['kind']); + return { source: this.source, kind, id: m[2]! }; + } + + private toSummary(t: any, album?: NeteaseAlbumRaw): TrackSummary { + const artists: any[] = t.ar || t.artists || []; + return { + source: this.source, + id: String(t.id), + name: t.name, + artists: joinNames(artists), + artistRefs: artists.filter((a) => a?.id && a?.name).map((a) => ({ id: String(a.id), name: a.name })), + album: t.al?.name || album?.name || '', + albumId: t.al?.id ? String(t.al.id) : album?.id, + cover: t.al?.picUrl || album?.picUrl, + duration: t.dt || t.duration, + trackNo: t.no || undefined, + qualities: declaredQualities(t), + vip: t.fee === 1 || undefined, + }; + } +} diff --git a/server/src/core/providers/qq/client.ts b/server/src/core/providers/qq/client.ts new file mode 100644 index 0000000..482e9e9 --- /dev/null +++ b/server/src/core/providers/qq/client.ts @@ -0,0 +1,93 @@ +// QQ 音乐 musicu.fcg 网关。已登录时在 comm 中带上 uin + authst,并附带完整 Cookie。 +// (旧 qq-music-api 的 getMusicPlay 没把 Cookie 发给上游,所以拿不到 VIP 音源) +import { UA } from '../../net/http.ts'; + +export interface QQCredentials { + /** 当前保存的完整 Cookie 字符串,未登录返回 null */ + getCookie(): string | null; +} + +export function parseCookie(raw: string): Record { + const out: Record = {}; + for (const part of raw.split(/;\s*/)) { + const i = part.indexOf('='); + if (i > 0) out[part.slice(0, i).trim()] = part.slice(i + 1).trim(); + } + return out; +} + +export interface QQAuth { + cookie: string; + uin: string; + musicKey: string; +} + +export function authFromCookie(raw: string | null): QQAuth | null { + if (!raw) return null; + const ck = parseCookie(raw); + const uin = (ck.uin || ck.wxuin || '').replace(/^o0*/, ''); + const musicKey = ck.qm_keyst || ck.qqmusic_key || ''; + return uin && musicKey ? { cookie: raw, uin, musicKey } : null; +} + +export type MusicuRequest = { module: string; method: string; param: Record }; +export type MusicuResult = { code: number; data?: any }; + +export class QQClient { + private readonly creds: QQCredentials; + + constructor(creds: QQCredentials) { + this.creds = creds; + } + + get auth(): QQAuth | null { + return authFromCookie(this.creds.getCookie()); + } + + async musicu( + reqs: Record, + opts: { signal?: AbortSignal; auth?: QQAuth | null; comm?: Record } = {}, + ): Promise> { + const auth = opts.auth === undefined ? this.auth : opts.auth; + const body = { + comm: { + cv: 4747474, + ct: 24, + format: 'json', + inCharset: 'utf-8', + outCharset: 'utf-8', + notice: 0, + platform: 'yqq.json', + needNewCode: 1, + g_tk: 5381, + g_tk_new_20200303: 5381, + uin: auth ? Number(auth.uin) : 0, + ...(auth ? { authst: auth.musicKey } : {}), + ...opts.comm, + }, + ...reqs, + }; + const res = await fetch('https://u.y.qq.com/cgi-bin/musicu.fcg', { + method: 'POST', + headers: { + 'User-Agent': UA, + Referer: 'https://y.qq.com/', + Origin: 'https://y.qq.com', + 'Content-Type': 'application/json', + ...(auth ? { Cookie: auth.cookie } : {}), + }, + body: JSON.stringify(body), + signal: opts.signal ? AbortSignal.any([opts.signal, AbortSignal.timeout(15000)]) : AbortSignal.timeout(15000), + }); + const json = (await res.json()) as Record; + if (json.code !== 0) throw new Error(`QQ 音乐接口错误 (code ${json.code})`); + return json as Record; + } + + /** 单个请求的便捷封装:code 非 0 时抛错 */ + async call(req: MusicuRequest, signal?: AbortSignal): Promise { + const { r } = await this.musicu({ r: req }, { signal }); + if (r.code !== 0) throw new Error(`QQ 音乐接口错误 ${req.method} (code ${r.code})`); + return r.data; + } +} diff --git a/server/src/core/providers/qq/index.ts b/server/src/core/providers/qq/index.ts new file mode 100644 index 0000000..7ed1e0e --- /dev/null +++ b/server/src/core/providers/qq/index.ts @@ -0,0 +1,355 @@ +// QQ 音乐 provider:全部走 musicu.fcg;VIP 音源依赖已登录 Cookie +import { fallbackChain, type QualityKey } from '../../quality.ts'; +import { + FatalError, + type AlbumDetail, + type AlbumSummary, + type ArtistDetail, + type LinkRef, + type Lyric, + type MusicProvider, + type Paged, + type PlaylistDetail, + type ResolvedAudio, + type TrackMeta, + type TrackSummary, +} from '../../types.ts'; +import { joinNames, TtlCache } from '../../util.ts'; +import { QQClient, type QQCredentials } from './client.ts'; + +/** 统一音质 → QQ 文件前缀。母带 / 全景声(AI00 / Q000)为加密格式,暂不支持 */ +const FILE_TYPES: Partial number; br: number }>> = { + hires: { prefix: 'RS01', ext: 'flac', size: (f) => f.size_hires, br: 0 }, + lossless: { prefix: 'F000', ext: 'flac', size: (f) => f.size_flac, br: 0 }, + high: { prefix: 'M800', ext: 'mp3', size: (f) => f.size_320mp3, br: 320 }, + standard: { prefix: 'M500', ext: 'mp3', size: (f) => f.size_128mp3, br: 128 }, +}; + +const COMPILATION_NAMES = ['群星', 'variousartists', 'variousartist', 'va']; + +export function qqCover(albumMid: string | undefined, size = 800): string | undefined { + return albumMid ? `https://y.gtimg.cn/music/photo_new/T002R${size}x${size}M000${albumMid}.jpg` : undefined; +} + +function singerCover(singerMid: string | undefined): string | undefined { + return singerMid ? `https://y.gtimg.cn/music/photo_new/T001R500x500M000${singerMid}.jpg` : undefined; +} + +function availableQualities(file: any): QualityKey[] { + if (!file) return []; + return (Object.keys(FILE_TYPES) as QualityKey[]).filter((q) => FILE_TYPES[q]!.size(file) > 0); +} + +function decodeLyric(value: string | undefined): string | undefined { + if (!value) return undefined; + const text = /^[A-Za-z0-9+/=\s]+$/.test(value) ? Buffer.from(value, 'base64').toString('utf-8') : value; + return text.trim() ? text : undefined; +} + +function parseDate(value: string | undefined): { year?: number; date?: string } { + const m = value?.match(/^(\d{4})(?:-(\d{2})-(\d{2}))?/); + if (!m) return {}; + return { year: Number(m[1]), date: m[2] ? `${m[1]}-${m[2]}-${m[3]}` : undefined }; +} + +interface AlbumInfo { + mid: string; + name: string; + singers: Array<{ mid: string; name: string }>; + publishDate?: string; + company?: string; + genre?: string; + type?: string; + description?: string; + total: number; + songs: any[]; +} + +export class QQProvider implements MusicProvider { + readonly source = 'qq' as const; + readonly qualities = ['standard', 'high', 'lossless', 'hires'] as const; + readonly client: QQClient; + private readonly albumCache = new TtlCache(10 * 60 * 1000); + + constructor(creds: QQCredentials) { + this.client = new QQClient(creds); + } + + // ---- 列表数据 ---- + + private toSummary(t: any): TrackSummary { + return { + source: this.source, + id: t.mid, + name: t.title || t.name, + artists: joinNames(t.singer), + artistRefs: (t.singer || []).map((s: any) => ({ id: s.mid, name: s.name })), + album: t.album?.title || t.album?.name || '', + albumId: t.album?.mid || undefined, + cover: qqCover(t.album?.mid, 300), + duration: t.interval ? t.interval * 1000 : undefined, + trackNo: t.index_album || undefined, + qualities: availableQualities(t.file), + vip: t.pay?.pay_play === 1, + }; + } + + async search(keyword: string, { page = 1, limit = 30 } = {}): Promise> { + const data = await this.client.call({ + module: 'music.search.SearchCgiService', + method: 'DoSearchForQQMusicDesktop', + param: { query: keyword, num_per_page: limit, page_num: page, search_type: 0 }, + }); + const list: any[] = data?.body?.song?.list || []; + return { items: list.map((t) => this.toSummary(t)), total: data?.meta?.sum ?? list.length, page, limit }; + } + + private async fetchSong(mid: string, signal?: AbortSignal): Promise { + const data = await this.client.call( + { module: 'music.pf_song_detail_svr', method: 'get_song_detail_yqq', param: { song_type: 0, song_mid: mid } }, + signal, + ); + return data?.track_info?.mid ? data : null; + } + + private fetchAlbumInfo(albumMid: string): Promise { + return this.albumCache.getOrSet(albumMid, async () => { + const page = 100; + const res = await this.client.musicu({ + a: { module: 'music.musichallAlbum.AlbumInfoServer', method: 'GetAlbumDetail', param: { albumMid } }, + s: { module: 'music.musichallAlbum.AlbumSongList', method: 'GetAlbumSongList', param: { albumMid, begin: 0, num: page, order: 2 } }, + }); + const basic = res.a.data?.basicInfo; + if (res.a.code !== 0 || !basic?.albumName) return null; + const songs: any[] = (res.s.data?.songList || []).map((it: any) => it.songInfo); + const total: number = res.s.data?.totalNum ?? songs.length; + for (let begin = songs.length; begin < total && begin < 2000; begin += page) { + const more = await this.client.call({ + module: 'music.musichallAlbum.AlbumSongList', + method: 'GetAlbumSongList', + param: { albumMid, begin, num: page, order: 2 }, + }); + const batch = (more?.songList || []).map((it: any) => it.songInfo); + if (!batch.length) break; + songs.push(...batch); + } + return { + mid: basic.albumMid, + name: basic.albumName, + singers: (res.a.data?.singer?.singerList || []).map((s: any) => ({ mid: s.mid, name: s.name })), + publishDate: basic.publishDate || undefined, + company: res.a.data?.company?.name || undefined, + genre: basic.genreNew || basic.genre || undefined, + type: basic.albumType || undefined, + description: basic.desc || undefined, + total, + songs, + }; + }); + } + + async getAlbum(id: string): Promise { + const a = await this.fetchAlbumInfo(id); + if (!a) return null; + return { + source: this.source, + id: a.mid, + name: a.name, + artist: joinNames(a.singers), + artistRefs: a.singers.map((s) => ({ id: s.mid, name: s.name })), + cover: qqCover(a.mid), + publishTime: a.publishDate ? Date.parse(a.publishDate) : undefined, + size: a.total, + type: a.type, + company: a.company, + description: a.description, + tracks: a.songs.map((t) => this.toSummary(t)), + }; + } + + async getPlaylist(id: string): Promise { + const page = 1000; + const songs: any[] = []; + let info: any = null; + let total = 0; + for (let begin = 0; begin === 0 || (begin < total && begin < 10000); begin += page) { + const data = await this.client.call({ + module: 'music.srfDissInfo.aiDissInfo', + method: 'uniform_get_Dissinfo', + param: { disstid: Number(id), userinfo: 1, tag: 1, orderlist: 1, song_begin: begin, song_num: page, onlysonglist: begin > 0 ? 1 : 0, enc_host_uin: '' }, + }); + if (begin === 0) { + info = data?.dirinfo; + total = data?.total_song_num ?? 0; + } + const batch: any[] = data?.songlist || []; + if (!batch.length) break; + songs.push(...batch); + } + if (!info?.title && !songs.length) return null; + return { + source: this.source, + id, + name: info?.title || `歌单 ${id}`, + cover: info?.picurl || undefined, + creator: info?.host_nick || undefined, + description: info?.desc || undefined, + trackCount: total || songs.length, + tracks: songs.map((t) => this.toSummary(t)), + }; + } + + async getArtist(id: string): Promise { + const res = await this.client.musicu({ + d: { module: 'music.musichallSinger.SingerInfoInter', method: 'GetSingerDetail', param: { singer_mids: [id], pic: 1, group_singer: 0, wiki_singer: 0, ex_singer: 1 } }, + s: { module: 'musichall.song_list_server', method: 'GetSingerSongList', param: { singerMid: id, begin: 0, num: 100, order: 1 } }, + a: { module: 'music.musichallAlbum.AlbumListServer', method: 'GetAlbumList', param: { singerMid: id, order: 0, begin: 0, num: 100, songNumTag: 0, singerID: 0 } }, + }); + const singer = res.d.data?.singer_list?.[0]; + const name = singer?.basic_info?.name; + if (!name) return null; + return { + source: this.source, + id, + name, + avatar: singerCover(id), + description: singer?.ex_info?.desc || undefined, + tracks: (res.s.data?.songList || []).map((it: any) => this.toSummary(it.songInfo)), + albums: (res.a.data?.albumList || []).map( + (a: any): AlbumSummary => ({ + source: this.source, + id: a.albumMid, + name: a.albumName, + artist: a.singerName || name, + cover: qqCover(a.albumMid, 300), + publishTime: a.publishDate ? Date.parse(a.publishDate) : undefined, + size: a.totalNum || undefined, + type: a.albumType || undefined, + }), + ), + }; + } + + async getLyric(id: string): Promise { + try { + const data = await this.client.call({ + module: 'music.musichallSong.PlayLyricInfo', + method: 'GetPlayLyricInfo', + param: { songMID: id, trans: 1, roma: 0, crypt: 0, ct: 19, cv: 0 }, + }); + const lrc = decodeLyric(data?.lyric); + return lrc ? { lrc, tlyric: decodeLyric(data?.trans) } : null; + } catch { + return null; + } + } + + async getTrackMeta(id: string): Promise { + const data = await this.fetchSong(id); + if (!data) return null; + const t = data.track_info; + const info = data.info || {}; + const albumMid: string | undefined = t.album?.mid || undefined; + const meta: TrackMeta = { + source: this.source, + id: t.mid, + name: t.title || t.name, + artists: joinNames(t.singer), + album: t.album?.title || t.album?.name || '', + albumId: albumMid, + cover: qqCover(albumMid), + duration: t.interval ? t.interval * 1000 : undefined, + trackNo: t.index_album || undefined, + disc: albumMid ? String((t.index_cd || 0) + 1) : undefined, + genre: info.genre?.content?.[0]?.value || undefined, + publisher: info.company?.content?.[0]?.value || undefined, + ...parseDate(info.pub_time?.content?.[0]?.value || t.time_public), + }; + if (albumMid) { + const album = await this.fetchAlbumInfo(albumMid).catch(() => null); + if (album) { + const albumArtist = joinNames(album.singers); + meta.albumArtist = albumArtist || undefined; + meta.albumArtistPrimary = album.singers[0]?.name; + meta.compilation = COMPILATION_NAMES.includes(albumArtist.toLowerCase().replace(/[.\s]/g, '')) || undefined; + meta.trackTotal = album.total || undefined; + meta.genre ||= album.genre; + meta.publisher ||= album.company; + if (!meta.date) Object.assign(meta, parseDate(album.publishDate)); + } + } + if (!meta.albumArtist) meta.albumArtist = meta.artists || undefined; + return meta; + } + + async resolveAudio(id: string, quality: QualityKey, signal?: AbortSignal): Promise { + const auth = this.client.auth; + const song = await this.fetchSong(id, signal); + if (!song) throw new FatalError('歌曲不存在'); + const t = song.track_info; + const mediaMid: string = t.file?.media_mid || t.mid; + + const candidates = fallbackChain(quality) + .map((q) => ({ q, type: FILE_TYPES[q] })) + .filter((c): c is { q: QualityKey; type: NonNullable<(typeof FILE_TYPES)[QualityKey]> } => !!c.type && c.type.size(t.file) > 0); + if (!candidates.length) throw new FatalError('该歌曲在 QQ 音乐没有可下载的音源'); + + const data = await this.client.call( + { + module: 'music.vkey.GetVkey', + method: 'UrlGetVkey', + param: { + filename: candidates.map((c) => `${c.type.prefix}${mediaMid}.${c.type.ext}`), + guid: '10000', + songmid: candidates.map(() => id), + songtype: candidates.map(() => 0), + uin: auth?.uin ?? '0', + loginflag: 1, + platform: '20', + }, + }, + signal, + ); + const sips: string[] = data?.sip || []; + const host = (sips.find((s) => !s.startsWith('http://ws')) || sips[0] || 'https://isure.stream.qqmusic.qq.com/').replace(/^http:/, 'https:'); + const infos: any[] = data?.midurlinfo || []; + + for (const c of candidates) { + const info = infos.find((i) => i.filename?.startsWith(c.type.prefix)); + if (!info?.purl) continue; + const size = c.type.size(t.file); + return { + url: host + info.purl, + ext: c.type.ext, + quality: c.q, + size, + bitrate: c.type.br || (t.interval ? Math.round((size * 8) / t.interval / 1000) : undefined), + name: t.title || t.name, + artists: joinNames(t.singer), + album: t.album?.title || t.album?.name || '', + cover: qqCover(t.album?.mid), + }; + } + throw new FatalError(auth ? '没有可用音源(可能需要更高等级会员或单独购买)' : '需要登录 QQ 音乐(在设置中导入 Cookie)'); + } + + parseLink(input: string): LinkRef | null { + const text = input.trim(); + if (/^[0-9A-Za-z]{14}$/.test(text) && /^00/.test(text)) return { source: this.source, kind: 'track', id: text }; + if (!/qq\.com/.test(text)) return null; + const patterns: Array<[RegExp, LinkRef['kind']]> = [ + [/songDetail\/([0-9A-Za-z]+)/, 'track'], + [/[?&]songmid=([0-9A-Za-z]+)/, 'track'], + [/albumDetail\/([0-9A-Za-z]+)/, 'album'], + [/[?&]albummid=([0-9A-Za-z]+)/, 'album'], + [/playlist\/(\d+)/, 'playlist'], + [/[?&](?:id|disstid)=(\d+)/, 'playlist'], + [/singer\/([0-9A-Za-z]+)/, 'artist'], + ]; + for (const [re, kind] of patterns) { + const m = text.match(re); + if (m) return { source: this.source, kind, id: m[1]! }; + } + return null; + } +} diff --git a/server/src/core/providers/qq/login.ts b/server/src/core/providers/qq/login.ts new file mode 100644 index 0000000..d648257 --- /dev/null +++ b/server/src/core/providers/qq/login.ts @@ -0,0 +1,260 @@ +// QQ 音乐登录:QQ 扫码(ptlogin2 → graph.qq.com OAuth → QQConnectLogin)与 musickey 续期 +// 协议参考 L-1124/QQMusicApi 的 modules/login.py +import { UA } from '../../net/http.ts'; +import { parseCookie, type QQClient } from './client.ts'; + +const APPID = '716027609'; +const DAID = '383'; +const THIRD_AID = '100497308'; +const PT_REFERER = 'https://xui.ptlogin2.qq.com/'; + +export function hash33(s: string, h = 0): number { + for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; + return h & 0x7fffffff; +} + +/** 登录得到的凭据(字段名同 QQ 音乐接口返回) */ +export interface QQCredential { + musicid: string; + musickey: string; + loginType: number; // 2 = QQ,1 = 微信 + openid?: string; + unionid?: string; + accessToken?: string; + refreshToken?: string; + refreshKey?: string; + expiredAt?: number; // access_token 过期时间(秒) + keyCreatedAt?: number; // musickey 生成时间(秒) + keyExpiresIn?: number; // musickey 有效期(秒) + encryptUin?: string; +} + +/** 从网页 Cookie 还原凭据(网页 Cookie 里没有 refresh_key) */ +export function credentialFromCookie(raw: string, refreshKey?: string): QQCredential | null { + const ck = parseCookie(raw); + const musicid = (ck.uin || ck.qqmusic_uin || ck.wxuin || '').replace(/^o0*/, ''); + const musickey = ck.qm_keyst || ck.qqmusic_key || ''; + if (!musicid || !musickey) return null; + return { + musicid, + musickey, + loginType: Number(ck.tmeLoginType || (ck.wxuin ? 1 : 2)), + openid: ck.psrf_qqopenid || ck.wxopenid || undefined, + unionid: ck.psrf_qqunionid || ck.wxunionid || undefined, + accessToken: ck.psrf_qqaccess_token || undefined, + refreshToken: ck.psrf_qqrefresh_token || ck.wxrefresh_token || undefined, + refreshKey, + expiredAt: Number(ck.psrf_access_token_expiresAt) || undefined, + keyCreatedAt: Number(ck.psrf_musickey_createtime) || undefined, + encryptUin: ck.euin || undefined, + }; +} + +/** 把凭据写回网页 Cookie 形式,保留原 Cookie 中其他字段 */ +export function cookieFromCredential(c: QQCredential, base = ''): string { + const ck = parseCookie(base); + const set = (k: string, v: string | number | undefined) => { + if (v !== undefined && v !== '') ck[k] = String(v); + }; + set('uin', c.musicid); + set('qqmusic_key', c.musickey); + set('qm_keyst', c.musickey); + set('tmeLoginType', c.loginType); + set('login_type', c.loginType === 1 ? 2 : 1); + set('euin', c.encryptUin); + set('psrf_qqopenid', c.openid); + set('psrf_qqunionid', c.unionid); + set('psrf_qqaccess_token', c.accessToken); + set('psrf_qqrefresh_token', c.refreshToken); + set('psrf_access_token_expiresAt', c.expiredAt); + set('psrf_musickey_createtime', c.keyCreatedAt); + return Object.entries(ck) + .map(([k, v]) => `${k}=${v}`) + .join('; '); +} + +function toCredential(data: any, loginType: number): QQCredential { + if (!data?.musickey || !(data.musicid || data.str_musicid)) throw new Error('登录接口未返回 musickey'); + return { + musicid: String(data.str_musicid || data.musicid), + musickey: data.musickey, + loginType: data.loginType || loginType, + openid: data.openid || undefined, + unionid: data.unionid || undefined, + accessToken: data.access_token || undefined, + refreshToken: data.refresh_token || undefined, + refreshKey: data.refresh_key || undefined, + expiredAt: data.expired_at || undefined, + keyCreatedAt: data.musickeyCreateTime || Math.floor(Date.now() / 1000), + keyExpiresIn: data.keyExpiresIn || undefined, + encryptUin: data.encryptUin || undefined, + }; +} + +const LOGIN_ERRORS: Record = { + 1000: '登录已过期', + 104400: '登录已过期', + 104401: '登录已过期', + 20271: '账号受限', + 20274: '设备数量超限', + 20277: '账号已被封禁', + 20278: '请求过于频繁', +}; + +function checkLoginResult(r: { code: number; data?: any }): any { + if (r.code !== 0) throw new Error(LOGIN_ERRORS[r.code] ?? `登录失败 (code ${r.code})`); + return r.data; +} + +/** 用 musicu 换取 / 续期 musickey */ +async function musicLogin(client: QQClient, module: string, method: string, param: Record, loginType: number, withAuth: boolean) { + const { r } = await client.musicu({ r: { module, method, param } }, { auth: withAuth ? undefined : null, comm: { tmeLoginType: loginType } }); + return toCredential(checkLoginResult(r), loginType); +} + +/** 续期 musickey;失败抛错(调用方决定是否标记失效) */ +export function refreshCredential(client: QQClient, c: QQCredential): Promise { + const param = + c.loginType === 1 + ? { openid: c.openid, refresh_token: c.refreshToken, str_musicid: c.musicid, musickey: c.musickey, unionid: c.unionid, refresh_key: c.refreshKey, loginMode: 2 } + : { + openid: c.openid, + access_token: c.accessToken, + refresh_token: c.refreshToken, + expired_in: c.expiredAt, + musicid: Number(c.musicid), + musickey: c.musickey, + refresh_key: c.refreshKey, + loginMode: 2, + }; + return musicLogin(client, 'music.login.LoginServer', 'Login', param, c.loginType, true); +} + +// ---------- QQ 扫码 ---------- + +function setCookies(res: Response): Record { + const out: Record = {}; + for (const line of res.headers.getSetCookie()) { + const [pair] = line.split(';'); + const i = pair!.indexOf('='); + const value = pair!.slice(i + 1); + if (i > 0 && value) out[pair!.slice(0, i).trim()] = value; + } + return out; +} + +const cookieHeader = (jar: Record) => + Object.entries(jar) + .map(([k, v]) => `${k}=${v}`) + .join('; '); + +export interface QRCode { + qrsig: string; + image: string; // data:image/png;base64,... +} + +export async function createQR(): Promise { + const url = new URL('https://ssl.ptlogin2.qq.com/ptqrshow'); + const params = { appid: APPID, e: '2', l: 'M', s: '3', d: '72', v: '4', t: String(Math.random()), daid: DAID, pt_3rd_aid: THIRD_AID }; + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + const res = await fetch(url, { headers: { Referer: PT_REFERER, 'User-Agent': UA }, signal: AbortSignal.timeout(10000) }); + const qrsig = setCookies(res).qrsig; + if (!res.ok || !qrsig) throw new Error('获取二维码失败'); + const image = Buffer.from(await res.arrayBuffer()).toString('base64'); + return { qrsig, image: `data:image/png;base64,${image}` }; +} + +export type QRState = 'waiting' | 'scanned' | 'expired' | 'refused' | 'done'; + +const QR_STATES: Record = { '66': 'waiting', '67': 'scanned', '65': 'expired', '68': 'refused', '0': 'done' }; + +export async function pollQR(client: QQClient, qrsig: string): Promise<{ state: QRState; message?: string; credential?: QQCredential }> { + const url = new URL('https://ssl.ptlogin2.qq.com/ptqrlogin'); + const params = { + u1: 'https://graph.qq.com/oauth2.0/login_jump', + ptqrtoken: String(hash33(qrsig)), + ptredirect: '0', + h: '1', + t: '1', + g: '1', + from_ui: '1', + ptlang: '2052', + action: `0-0-${Date.now()}`, + js_ver: '20102616', + js_type: '1', + pt_uistyle: '40', + aid: APPID, + daid: DAID, + pt_3rd_aid: THIRD_AID, + has_onekey: '1', + }; + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + const res = await fetch(url, { headers: { Referer: PT_REFERER, 'User-Agent': UA, Cookie: `qrsig=${qrsig}` }, signal: AbortSignal.timeout(10000) }); + const text = await res.text(); + const body = text.match(/ptuiCB\((.*?)\)/)?.[1]; + const args = body ? [...body.matchAll(/'((?:\\.|[^'])*)'/g)].map((m) => m[1]!) : []; + const state = QR_STATES[args[0] ?? '']; + if (!state) throw new Error(`二维码状态解析失败: ${text.slice(0, 120)}`); + if (state !== 'done') return { state, message: args[4] }; + + const redirect = args[2] ?? ''; + const sigx = redirect.match(/[?&]ptsigx=(.+?)&s_url/)?.[1]; + const uin = redirect.match(/[?&]uin=(.+?)&service/)?.[1]; + if (!sigx || !uin) throw new Error('扫码成功但未拿到登录参数'); + return { state, credential: await authorizeQQ(client, uin, sigx) }; +} + +async function authorizeQQ(client: QQClient, uin: string, sigx: string): Promise { + const sig = new URL('https://ssl.ptlogin2.graph.qq.com/check_sig'); + const sigParams = { + uin, + pttype: '1', + service: 'ptqrlogin', + nodirect: '0', + ptsigx: sigx, + s_url: 'https://graph.qq.com/oauth2.0/login_jump', + ptlang: '2052', + ptredirect: '100', + aid: APPID, + daid: DAID, + j_later: '0', + low_login_hour: '0', + regmaster: '0', + pt_login_type: '3', + pt_aid: '0', + pt_aaid: '16', + pt_light: '0', + pt_3rd_aid: THIRD_AID, + }; + for (const [k, v] of Object.entries(sigParams)) sig.searchParams.set(k, v); + const sigRes = await fetch(sig, { headers: { Referer: PT_REFERER, 'User-Agent': UA }, redirect: 'manual', signal: AbortSignal.timeout(10000) }); + const jar = setCookies(sigRes); + if (!jar.p_skey) throw new Error('获取 p_skey 失败'); + + const form = new URLSearchParams({ + response_type: 'code', + client_id: THIRD_AID, + redirect_uri: 'https://y.qq.com/portal/wx_redirect.html?login_type=1&surl=https://y.qq.com/', + scope: 'get_user_info,get_app_friends', + state: 'state', + switch: '', + from_ptlogin: '1', + src: '1', + update_auth: '1', + openapi: '1010_1030', + g_tk: String(hash33(jar.p_skey, 5381)), + auth_time: String(Date.now()), + ui: crypto.randomUUID(), + }); + const authRes = await fetch('https://graph.qq.com/oauth2.0/authorize', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': UA, Cookie: cookieHeader(jar) }, + body: form, + redirect: 'manual', + signal: AbortSignal.timeout(10000), + }); + const code = (authRes.headers.get('location') || '').match(/[?&]code=(.+?)(?:&|$)/)?.[1]; + if (!code) throw new Error('OAuth 授权失败,未拿到 code'); + + return musicLogin(client, 'QQConnectLogin.LoginServer', 'QQLogin', { code }, 2, false); +} diff --git a/server/src/core/quality.ts b/server/src/core/quality.ts new file mode 100644 index 0000000..f055585 --- /dev/null +++ b/server/src/core/quality.ts @@ -0,0 +1,25 @@ +// 统一音质档位。各平台在自己的 provider 里把它映射到平台参数。 +export const QUALITY_KEYS = ['standard', 'high', 'lossless', 'hires', 'master', 'spatial', 'effect'] as const; +export type QualityKey = (typeof QUALITY_KEYS)[number]; + +export const QUALITY_INFO: Record = { + standard: { label: '标准', badge: '128K' }, + high: { label: '极高', badge: '320K' }, + lossless: { label: '无损', badge: 'FLAC' }, + hires: { label: 'Hi-Res', badge: 'HI-RES' }, + master: { label: '超清母带', badge: 'MASTER' }, + spatial: { label: '空间音频', badge: 'SPATIAL' }, + effect: { label: '高清臻品', badge: 'EFFECT' }, +}; + +export function isQualityKey(value: unknown): value is QualityKey { + return typeof value === 'string' && (QUALITY_KEYS as readonly string[]).includes(value); +} + +// 请求的音质不可用时,按此顺序向下降级 +const LADDER: QualityKey[] = ['master', 'hires', 'lossless', 'high', 'standard']; + +export function fallbackChain(requested: QualityKey): QualityKey[] { + if (requested === 'spatial' || requested === 'effect') return [requested, ...LADDER]; + return LADDER.slice(LADDER.indexOf(requested)); +} diff --git a/server/src/core/tagging.ts b/server/src/core/tagging.ts new file mode 100644 index 0000000..06a6950 --- /dev/null +++ b/server/src/core/tagging.ts @@ -0,0 +1,134 @@ +// 音频标签写入:MP3 优先 node-id3,其余格式(FLAC 等)或 node-id3 失败时用 ffmpeg。 +// 字段与旧版 163-music-dl/lib/tagging.js 保持一致。 +import { execFile } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import NodeID3, { type Tags } from 'node-id3'; +import { formatPersonNames } from './util.ts'; + +const execFileAsync = promisify(execFile); + +export interface TagMeta { + name: string; + artist: string; + album: string; + albumArtist?: string; + compilation?: boolean; + year?: number; + date?: string; + trackNo?: string; // "3" 或 "3/12" + disc?: string; + genre?: string; + publisher?: string; + composer?: string; + lyricist?: string; + arranger?: string; + producer?: string; + mixer?: string; + lyrics?: string; +} + +let ffmpegAvailable: boolean | null = null; + +export async function hasFFmpeg(): Promise { + if (ffmpegAvailable === null) { + try { + await execFileAsync('ffmpeg', ['-version'], { windowsHide: true }); + ffmpegAvailable = true; + } catch { + ffmpegAvailable = false; + } + } + return ffmpegAvailable; +} + +async function writeId3(audioPath: string, cover: Buffer | null, meta: TagMeta): Promise { + const tags: Tags & Record = { + title: meta.name, + artist: formatPersonNames(meta.artist), + album: meta.album, + }; + if (cover) { + tags.image = { mime: 'image/jpeg', type: { id: 3 }, description: 'Cover (front)', imageBuffer: cover }; + } + if (meta.albumArtist) tags.performerInfo = formatPersonNames(meta.albumArtist); + if (meta.compilation) tags.TCMP = '1'; + if (meta.year) tags.year = String(meta.year); + if (meta.date) tags.date = String(meta.date); + if (meta.trackNo) tags.trackNumber = String(meta.trackNo); + if (meta.disc) tags.partOfSet = String(meta.disc); + if (meta.genre) tags.genre = meta.genre; + if (meta.composer) tags.composer = formatPersonNames(meta.composer); + if (meta.lyricist) tags.textWriter = formatPersonNames(meta.lyricist); + if (meta.publisher) tags.publisher = meta.publisher; + + const userTexts: Array<{ description: string; value: string }> = []; + if (meta.arranger) userTexts.push({ description: 'ARRANGER', value: formatPersonNames(meta.arranger) }); + if (meta.producer) userTexts.push({ description: 'PRODUCER', value: formatPersonNames(meta.producer) }); + if (meta.mixer) userTexts.push({ description: 'MIXER', value: formatPersonNames(meta.mixer) }); + if (userTexts.length) tags.userDefinedText = userTexts; + if (meta.lyrics) tags.unsynchronisedLyrics = { language: 'chi', text: meta.lyrics }; + + // 写文件模式下成功时 resolve(undefined),失败则 reject + await NodeID3.Promise.write(tags, audioPath); +} + +async function writeWithFFmpeg(audioPath: string, cover: Buffer | null, meta: TagMeta): Promise { + const ext = path.extname(audioPath).toLowerCase(); + const coverPath = `${audioPath}.cover.jpg`; + const tmpPath = `${audioPath}.tmp${ext}`; + try { + const args = ['-y', '-i', audioPath]; + if (cover) { + await fs.promises.writeFile(coverPath, cover); + args.push('-i', coverPath, '-map', '0:a', '-map', '1:0', '-c', 'copy', '-disposition:v:0', 'attached_pic'); + } else { + args.push('-map', '0', '-c', 'copy'); + } + const addMeta = (key: string, value: unknown) => { + if (value !== undefined && value !== null && value !== '') args.push('-metadata', `${key}=${value}`); + }; + addMeta('title', meta.name); + addMeta('artist', formatPersonNames(meta.artist)); + addMeta('album', meta.album); + addMeta('album_artist', formatPersonNames(meta.albumArtist || meta.artist)); + addMeta('compilation', meta.compilation ? '1' : undefined); + addMeta('date', meta.date || meta.year); + addMeta('track', meta.trackNo); + addMeta('disc', meta.disc); + addMeta('genre', meta.genre); + addMeta('composer', formatPersonNames(meta.composer)); + addMeta('publisher', meta.publisher); + addMeta('lyricist', formatPersonNames(meta.lyricist)); + addMeta('arranger', formatPersonNames(meta.arranger)); + addMeta('producer', formatPersonNames(meta.producer)); + addMeta('mixer', formatPersonNames(meta.mixer)); + if (meta.lyrics && meta.lyrics.length <= 8000) addMeta('lyrics', meta.lyrics); + if (cover) args.push('-metadata:s:v', 'title=Album cover', '-metadata:s:v', 'comment=Cover (front)'); + if (ext === '.mp3') args.push('-id3v2_version', '3'); + args.push(tmpPath); + + await execFileAsync('ffmpeg', args, { windowsHide: true, maxBuffer: 16 * 1024 * 1024 }); + await fs.promises.rename(tmpPath, audioPath); + } finally { + await fs.promises.rm(coverPath, { force: true }); + await fs.promises.rm(tmpPath, { force: true }); + } +} + +/** 写入封面、标签与内嵌歌词,返回所用的写入方式 */ +export async function embedTags(audioPath: string, cover: Buffer | null, meta: TagMeta): Promise<'node-id3' | 'ffmpeg'> { + const ext = path.extname(audioPath).toLowerCase(); + if (ext === '.mp3') { + try { + await writeId3(audioPath, cover, meta); + return 'node-id3'; + } catch { + // 落到 ffmpeg + } + } + if (!(await hasFFmpeg())) throw new Error('写入标签需要 ffmpeg(非 MP3 格式)'); + await writeWithFFmpeg(audioPath, cover, meta); + return 'ffmpeg'; +} diff --git a/server/src/core/types.ts b/server/src/core/types.ts new file mode 100644 index 0000000..29f17c6 --- /dev/null +++ b/server/src/core/types.ts @@ -0,0 +1,140 @@ +import type { QualityKey } from './quality.ts'; + +export type Source = 'netease' | 'qq'; + +export interface ArtistRef { + id: string; + name: string; +} + +/** 搜索结果 / 列表里的一首歌 */ +export interface TrackSummary { + source: Source; + id: string; + name: string; + artists: string; // 已格式化为 "A / B" + artistRefs?: ArtistRef[]; + album: string; + albumId?: string; + cover?: string; + duration?: number; // ms + trackNo?: number; + /** 平台声明可下载的音质(已知时) */ + qualities?: QualityKey[]; + vip?: boolean; +} + +export interface Paged { + items: T[]; + total: number; + page: number; + limit: number; +} + +/** 用于写标签与决定目录结构的完整元数据 */ +export interface TrackMeta { + source: Source; + id: string; + name: string; + artists: string; + album: string; + albumId?: string; + cover?: string; + duration?: number; + trackNo?: number; + trackTotal?: number; + disc?: string; + albumArtist?: string; // 全部专辑艺人,"A / B" + albumArtistPrimary?: string; // 第一位专辑艺人,用作目录名 + compilation?: boolean; + year?: number; + date?: string; // YYYY-MM-DD + genre?: string; + publisher?: string; +} + +export interface AlbumSummary { + source: Source; + id: string; + name: string; + artist: string; + cover?: string; + publishTime?: number; + size?: number; + type?: string; +} + +export interface AlbumDetail extends AlbumSummary { + artistRefs?: ArtistRef[]; + company?: string; + description?: string; + tracks: TrackSummary[]; +} + +export interface PlaylistDetail { + source: Source; + id: string; + name: string; + cover?: string; + creator?: string; + description?: string; + trackCount: number; + tracks: TrackSummary[]; +} + +export interface ArtistDetail { + source: Source; + id: string; + name: string; + avatar?: string; + description?: string; + tracks: TrackSummary[]; + albums: AlbumSummary[]; +} + +export interface Lyric { + lrc?: string; + tlyric?: string; +} + +export interface ResolvedAudio { + url: string; + ext: string; // 不含点,例如 flac + quality: QualityKey; // 实际拿到的音质 + bitrate?: number; // kbps + size?: number; // bytes + md5?: string; + /** 解析接口顺带返回的基础信息,作为元数据兜底 */ + name?: string; + artists?: string; + album?: string; + cover?: string; +} + +export type LinkKind = 'track' | 'album' | 'playlist' | 'artist'; + +export interface LinkRef { + source: Source; + kind: LinkKind; + id: string; +} + +export interface MusicProvider { + readonly source: Source; + readonly qualities: readonly QualityKey[]; + search(keyword: string, opts?: { page?: number; limit?: number }): Promise>; + getTrackMeta(id: string): Promise; + getAlbum(id: string): Promise; + getPlaylist(id: string): Promise; + getArtist(id: string): Promise; + getLyric(id: string): Promise; + /** 按降级链解析音频地址;全部失败时抛错 */ + resolveAudio(id: string, quality: QualityKey, signal?: AbortSignal): Promise; + /** 同步识别链接;短链等需要请求的情况返回 null,交给 expandLink */ + parseLink(input: string): LinkRef | null; +} + +/** 无法通过重试解决的错误(例如版权缺失),流水线遇到它不再重试 */ +export class FatalError extends Error { + override name = 'FatalError'; +} diff --git a/server/src/core/util.ts b/server/src/core/util.ts new file mode 100644 index 0000000..eb01d5d --- /dev/null +++ b/server/src/core/util.ts @@ -0,0 +1,62 @@ +export function sanitize(name: string): string { + return name.replace(/[/\\:*?"<>|]/g, '_').trim(); +} + +export function sanitizePathSegment(name: unknown, fallback: string): string { + const value = sanitize(String(name || '')).replace(/[. ]+$/g, ''); + return value && value !== '.' && value !== '..' ? value : fallback; +} + +// "A/B" "A /B" -> "A / B" +export function formatPersonNames(value: string): string; +export function formatPersonNames(value: string | undefined): string | undefined; +export function formatPersonNames(value: string | undefined): string | undefined { + return typeof value === 'string' ? value.replace(/\s*\/\s*/g, ' / ') : value; +} + +export function joinNames(names: Array<{ name?: string } | string | undefined> | undefined): string { + return (names || []) + .map((item) => (typeof item === 'string' ? item : item?.name)) + .filter(Boolean) + .join(' / '); +} + +export function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) return reject(signal.reason); + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + reject(signal!.reason); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} + +export function errorMessage(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +// 简单的带过期时间的内存缓存,用于专辑详情等批量下载时反复请求的数据 +export class TtlCache { + private map = new Map(); + private readonly ttlMs: number; + private readonly max: number; + + constructor(ttlMs: number, max = 500) { + this.ttlMs = ttlMs; + this.max = max; + } + + async getOrSet(key: string, load: () => Promise): Promise { + const hit = this.map.get(key); + if (hit && hit.expires > Date.now()) return hit.value; + const value = await load(); + if (this.map.size >= this.max) this.map.delete(this.map.keys().next().value!); + this.map.set(key, { value, expires: Date.now() + this.ttlMs }); + return value; + } +} diff --git a/server/src/db.ts b/server/src/db.ts new file mode 100644 index 0000000..738dc45 --- /dev/null +++ b/server/src/db.ts @@ -0,0 +1,209 @@ +// SQLite 持久化(Node 内置 node:sqlite,无需原生编译依赖) +import fs from 'node:fs'; +import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { config } from './config.ts'; + +export const db = new DatabaseSync(path.join(config.dataDir, 'shengxia.db')); + +db.exec(` + PRAGMA journal_mode = WAL; + CREATE TABLE IF NOT EXISTS history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, + track_id TEXT NOT NULL, + name TEXT NOT NULL, + artists TEXT NOT NULL DEFAULT '', + album TEXT NOT NULL DEFAULT '', + quality TEXT, + bitrate INTEGER, + size INTEGER, + file TEXT, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_history_track ON history(source, track_id); + CREATE INDEX IF NOT EXISTS idx_history_created ON history(created_at DESC); + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, + track_id TEXT NOT NULL, + name TEXT NOT NULL, + artists TEXT NOT NULL DEFAULT '', + album TEXT NOT NULL DEFAULT '', + cover TEXT, + quality TEXT NOT NULL, + group_title TEXT, + status TEXT NOT NULL, + stage TEXT, + error TEXT, + actual_quality TEXT, + bitrate INTEGER, + size INTEGER, + file TEXT, + skipped INTEGER NOT NULL DEFAULT 0, + warnings TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status, id); +`); + +export interface HistoryEntry { + id: number; + source: string; + trackId: string; + name: string; + artists: string; + album: string; + quality: string | null; + bitrate: number | null; + size: number | null; + file: string | null; + createdAt: string; +} + +const insertHistory = db.prepare(` + INSERT INTO history (source, track_id, name, artists, album, quality, bitrate, size, file, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +`); + +export function addHistory(e: Omit & { createdAt?: string }): void { + insertHistory.run( + e.source, + e.trackId, + e.name, + e.artists, + e.album, + e.quality, + e.bitrate, + e.size, + e.file, + e.createdAt ?? new Date().toISOString(), + ); +} + +const HISTORY_COLUMNS = + 'id, source, track_id AS trackId, name, artists, album, quality, bitrate, size, file, created_at AS createdAt'; + +function historyWhere(q?: string, source?: string): { sql: string; args: string[] } { + const parts: string[] = []; + const args: string[] = []; + if (q) { + parts.push('(name LIKE ? OR artists LIKE ? OR album LIKE ?)'); + args.push(`%${q}%`, `%${q}%`, `%${q}%`); + } + if (source) { + parts.push('source = ?'); + args.push(source); + } + return { sql: parts.length ? `WHERE ${parts.join(' AND ')}` : '', args }; +} + +export function listHistory( + limit = 50, + offset = 0, + filter: { q?: string; source?: string } = {}, +): { items: HistoryEntry[]; total: number } { + const where = historyWhere(filter.q, filter.source); + const rows = db + .prepare(`SELECT ${HISTORY_COLUMNS} FROM history ${where.sql} ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`) + .all(...where.args, limit, offset) as unknown as HistoryEntry[]; + const { total } = db.prepare(`SELECT COUNT(*) AS total FROM history ${where.sql}`).get(...where.args) as { total: number }; + return { items: rows, total }; +} + +export function allHistory(): HistoryEntry[] { + return db.prepare(`SELECT ${HISTORY_COLUMNS} FROM history ORDER BY created_at DESC, id DESC`).all() as unknown as HistoryEntry[]; +} + +export function historyStats() { + const totals = db.prepare('SELECT COUNT(*) AS count, COALESCE(SUM(size), 0) AS size FROM history').get() as { + count: number; + size: number; + }; + const bySource = db.prepare('SELECT source AS key, COUNT(*) AS count FROM history GROUP BY source ORDER BY count DESC').all(); + const byQuality = db + .prepare("SELECT COALESCE(quality, 'unknown') AS key, COUNT(*) AS count FROM history GROUP BY quality ORDER BY count DESC") + .all(); + const topArtists = db + .prepare("SELECT artists AS key, COUNT(*) AS count FROM history WHERE artists <> '' GROUP BY artists ORDER BY count DESC LIMIT 8") + .all(); + const daily = db + .prepare( + `SELECT substr(created_at, 1, 10) AS key, COUNT(*) AS count FROM history + WHERE created_at >= ? GROUP BY key ORDER BY key`, + ) + .all(new Date(Date.now() - 29 * 86400000).toISOString().slice(0, 10)); + return { ...totals, bySource, byQuality, topArtists, daily }; +} + +export function downloadedIds(source: string, ids: string[]): Set { + if (!ids.length) return new Set(); + const rows = db + .prepare(`SELECT DISTINCT track_id AS id FROM history WHERE source = ? AND track_id IN (${ids.map(() => '?').join(',')})`) + .all(source, ...ids) as Array<{ id: string }>; + return new Set(rows.map((r) => r.id)); +} + +export function getSetting(key: string, fallback: T): T { + const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined; + return row ? (JSON.parse(row.value) as T) : fallback; +} + +export function setSetting(key: string, value: unknown): void { + db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run( + key, + JSON.stringify(value), + ); +} + +// 旧版 CLI 的 download_history.json(网易云 level 命名)只导入一次 +const LEGACY_LEVEL: Record = { + standard: 'standard', + exhigh: 'high', + lossless: 'lossless', + hires: 'hires', + jymaster: 'master', + sky: 'spatial', + jyeffect: 'effect', +}; + +export function importLegacyHistory(file: string | null): number { + if (!file || getSetting('legacyHistoryImported', false)) return 0; + let entries: any[]; + try { + entries = JSON.parse(fs.readFileSync(file, 'utf-8')); + } catch (e) { + console.warn(`⚠️ 旧版历史读取失败 (${file}): ${(e as Error).message}`); + return 0; + } + // 旧文件是新→旧,倒序插入保持时间顺序 + db.exec('BEGIN'); + try { + for (const e of [...entries].reverse()) { + if (!e?.id || !e?.name) continue; + addHistory({ + source: 'netease', + trackId: String(e.id), + name: e.name, + artists: e.artist || '', + album: e.album || '', + quality: LEGACY_LEVEL[e.level] ?? e.level ?? null, + bitrate: e.bitrate ? Math.round(e.bitrate > 10000 ? e.bitrate / 1000 : e.bitrate) : null, + size: e.size ?? null, + file: e.file ?? null, + createdAt: e.timestamp, + }); + } + setSetting('legacyHistoryImported', true); + db.exec('COMMIT'); + } catch (e) { + db.exec('ROLLBACK'); + throw e; + } + return entries.length; +} diff --git a/server/src/events.ts b/server/src/events.ts new file mode 100644 index 0000000..2f2831b --- /dev/null +++ b/server/src/events.ts @@ -0,0 +1,5 @@ +// 进程内事件总线:队列 / 设置 / 账号变化通过它推送给 SSE 客户端 +import { EventEmitter } from 'node:events'; + +export const bus = new EventEmitter(); +bus.setMaxListeners(100); diff --git a/server/src/library.ts b/server/src/library.ts new file mode 100644 index 0000000..a800595 --- /dev/null +++ b/server/src/library.ts @@ -0,0 +1,264 @@ +// 本地曲库工具:浏览目录、生成 M3U、打包 ZIP、移入回收站、按文件补全标签 +import fs from 'node:fs'; +import path from 'node:path'; +import { PassThrough } from 'node:stream'; +import yazl from 'yazl'; +import { config } from './config.ts'; +import { buildTagMeta } from './core/pipeline.ts'; +import { mergeLrc } from './core/lyrics.ts'; +import { fetchBuffer } from './core/net/http.ts'; +import { getProvider } from './core/providers/index.ts'; +import { embedTags } from './core/tagging.ts'; +import type { Source, TrackSummary } from './core/types.ts'; +import { errorMessage, sleep } from './core/util.ts'; +import { bus } from './events.ts'; + +export const AUDIO_EXTS = new Set(['.mp3', '.flac', '.m4a', '.ogg', '.wav', '.aac', '.ape', '.opus']); +const TRASH_DIR = path.join(config.dataDir, '.trash'); + +function httpError(status: number, message: string): Error { + return Object.assign(new Error(message), { status }); +} + +/** 把前端传来的相对路径解析到音乐目录内,禁止越界 */ +export function resolveInLibrary(rel: string | undefined): string { + const target = path.resolve(config.musicDir, (rel || '').replace(/^[/\\]+/, '')); + const root = path.resolve(config.musicDir); + if (target !== root && !target.startsWith(root + path.sep)) throw httpError(400, '路径不在音乐目录内'); + return target; +} + +function toRel(abs: string): string { + return path.relative(config.musicDir, abs).split(path.sep).join('/'); +} + +export interface LibraryEntry { + name: string; + path: string; + type: 'dir' | 'audio' | 'other'; + size: number; + mtime: number; + hasLyric?: boolean; + /** 文件夹内的封面图(cover.jpg / folder.jpg / artist.jpg),相对音乐目录 */ + cover?: string; +} + +const DIR_IMAGES = ['cover.jpg', 'folder.jpg', 'cover.png', 'artist.jpg']; + +export async function listDir(rel: string | undefined) { + const dir = resolveInLibrary(rel); + const stat = await fs.promises.stat(dir).catch(() => null); + if (!stat?.isDirectory()) throw httpError(404, '目录不存在'); + const names = await fs.promises.readdir(dir); + const nameSet = new Set(names); + const entries: LibraryEntry[] = []; + for (const name of names) { + if (name.startsWith('.')) continue; + const abs = path.join(dir, name); + const s = await fs.promises.stat(abs).catch(() => null); + if (!s) continue; + if (s.isDirectory()) { + const image = DIR_IMAGES.find((img) => fs.existsSync(path.join(abs, img))); + entries.push({ name, path: toRel(abs), type: 'dir', size: 0, mtime: s.mtimeMs, cover: image ? toRel(path.join(abs, image)) : undefined }); + } else { + const ext = path.extname(name).toLowerCase(); + const isAudio = AUDIO_EXTS.has(ext); + const base = name.slice(0, -ext.length); + entries.push({ + name, + path: toRel(abs), + type: isAudio ? 'audio' : 'other', + size: s.size, + mtime: s.mtimeMs, + hasLyric: isAudio ? nameSet.has(`${base}.lrc`) : undefined, + }); + } + } + entries.sort((a, b) => (a.type === 'dir' ? 0 : 1) - (b.type === 'dir' ? 0 : 1) || a.name.localeCompare(b.name, 'zh-CN', { numeric: true })); + return { + path: toRel(dir), + hasCover: nameSet.has('cover.jpg'), + entries, + }; +} + +async function collectAudio(abs: string): Promise { + const stat = await fs.promises.stat(abs); + if (stat.isFile()) return AUDIO_EXTS.has(path.extname(abs).toLowerCase()) ? [abs] : []; + const out: string[] = []; + for (const entry of await fs.promises.readdir(abs, { withFileTypes: true })) { + if (entry.name.startsWith('.')) continue; + out.push(...(await collectAudio(path.join(abs, entry.name)))); + } + return out.sort((a, b) => a.localeCompare(b, 'zh-CN', { numeric: true })); +} + +/** 在目录中写入 {目录名}.m3u8,路径相对该目录 */ +export async function writeM3u(rel: string): Promise { + const dir = resolveInLibrary(rel); + const files = await collectAudio(dir); + if (!files.length) throw httpError(400, '目录中没有音频文件'); + const name = `${path.basename(dir) || 'library'}.m3u8`; + const lines = ['#EXTM3U', ...files.map((f) => path.relative(dir, f).split(path.sep).join('/'))]; + await fs.promises.writeFile(path.join(dir, name), lines.join('\n') + '\n', 'utf-8'); + return toRel(path.join(dir, name)); +} + +/** 流式打包(不压缩,音频本身已压缩) */ +export async function zipStream(rel: string): Promise<{ stream: PassThrough; filename: string }> { + const target = resolveInLibrary(rel); + const stat = await fs.promises.stat(target).catch(() => null); + if (!stat) throw httpError(404, '路径不存在'); + const zip = new yazl.ZipFile(); + const base = stat.isDirectory() ? target : path.dirname(target); + const add = async (abs: string) => { + const s = await fs.promises.stat(abs); + if (s.isDirectory()) { + for (const name of await fs.promises.readdir(abs)) if (!name.startsWith('.')) await add(path.join(abs, name)); + } else { + zip.addFile(abs, path.join(path.basename(base), path.relative(base, abs)).split(path.sep).join('/'), { compress: false }); + } + }; + await add(target); + zip.end(); + const out = new PassThrough(); + zip.outputStream.pipe(out); + return { stream: out, filename: `${path.basename(target) || 'music'}.zip` }; +} + +/** 移入数据目录下的回收站(保留相对路径),不永久删除 */ +export async function moveToTrash(rel: string): Promise { + const target = resolveInLibrary(rel); + if (target === path.resolve(config.musicDir)) throw httpError(400, '不能删除音乐根目录'); + const dest = path.join(TRASH_DIR, new Date().toISOString().replace(/[:.]/g, '-'), path.relative(config.musicDir, target)); + await fs.promises.mkdir(path.dirname(dest), { recursive: true }); + try { + await fs.promises.rename(target, dest); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'EXDEV') throw e; + await fs.promises.cp(target, dest, { recursive: true }); + await fs.promises.rm(target, { recursive: true, force: true }); + } + return toRel(target); +} + +export async function trashInfo() { + let size = 0; + let count = 0; + const walk = async (dir: string) => { + for (const entry of await fs.promises.readdir(dir, { withFileTypes: true }).catch(() => [])) { + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) await walk(abs); + else { + count++; + size += (await fs.promises.stat(abs)).size; + } + } + }; + await walk(TRASH_DIR); + return { count, size }; +} + +export async function emptyTrash() { + await fs.promises.rm(TRASH_DIR, { recursive: true, force: true }); +} + +// ---------- 补全标签 ---------- + +export interface RetagJob { + id: number; + path: string; + source: Source; + total: number; + done: number; + current?: string; + results: Array<{ file: string; ok: boolean; match?: string; error?: string }>; + finished: boolean; +} + +let job: RetagJob | null = null; +let jobSeq = 0; + +export function currentRetagJob(): RetagJob | null { + return job; +} + +/** 从路径推断 歌名 / 歌手 / 专辑:支持 "歌手/专辑/NN. 歌名" 和 "歌手 - 歌名" */ +export function guessFromPath(abs: string): { title: string; artist?: string; album?: string } { + const base = path.basename(abs, path.extname(abs)); + const dash = base.match(/^(.+?)\s*[-—]\s*(.+)$/); + if (dash && !/^\d+\.\s/.test(base)) return { artist: dash[1]!.trim(), title: dash[2]!.trim() }; + const title = base.replace(/^\d+[.\s-]+\s*/, '').trim(); + const albumDir = path.dirname(abs); + const artistDir = path.dirname(albumDir); + const root = path.resolve(config.musicDir); + if (path.resolve(artistDir) !== root && path.resolve(albumDir) !== root && path.resolve(artistDir).startsWith(root)) { + return { title, album: path.basename(albumDir), artist: path.basename(artistDir) }; + } + return { title }; +} + +function pickBest(results: TrackSummary[], guess: ReturnType): TrackSummary | undefined { + const norm = (s: string | undefined) => (s || '').toLowerCase().replace(/\s+/g, ''); + const title = norm(guess.title); + const artist = norm(guess.artist); + const album = norm(guess.album); + const score = (t: TrackSummary) => + (norm(t.name) === title ? 4 : norm(t.name).includes(title) ? 2 : 0) + + (artist && norm(t.artists).includes(artist) ? 3 : 0) + + (album && norm(t.album) === album ? 2 : 0); + return [...results].sort((a, b) => score(b) - score(a))[0]; +} + +async function retagFile(abs: string, source: Source): Promise<{ match: string }> { + const provider = getProvider(source); + const guess = guessFromPath(abs); + const keyword = [guess.artist, guess.title].filter(Boolean).join(' '); + const { items } = await provider.search(keyword, { limit: 8 }); + const best = pickBest(items, guess); + if (!best) throw new Error('搜索无结果'); + + const [meta, lyric] = await Promise.all([provider.getTrackMeta(best.id).catch(() => null), provider.getLyric(best.id)]); + const dir = path.dirname(abs); + const base = path.basename(abs, path.extname(abs)); + if (lyric?.lrc) { + await fs.promises.writeFile(path.join(dir, `${base}.lrc`), lyric.lrc, 'utf-8'); + if (lyric.tlyric?.trim()) await fs.promises.writeFile(path.join(dir, `${base}.合并翻译.lrc`), mergeLrc(lyric.lrc, lyric.tlyric), 'utf-8'); + } + const coverUrl = meta?.cover || best.cover; + const cover = coverUrl ? await fetchBuffer(coverUrl).catch(() => null) : null; + await embedTags(abs, cover, buildTagMeta(meta?.name || best.name, meta?.artists || best.artists, meta?.album || best.album, meta, lyric?.lrc)); + return { match: `${best.artists} - ${best.name}${best.album ? ` 《${best.album}》` : ''}` }; +} + +export async function startRetag(rel: string, source: Source, onlyMissingLyric: boolean): Promise { + if (job && !job.finished) throw httpError(409, '已有补全任务在进行'); + const abs = resolveInLibrary(rel); + let files = await collectAudio(abs); + if (onlyMissingLyric) { + files = files.filter((f) => !fs.existsSync(path.join(path.dirname(f), `${path.basename(f, path.extname(f))}.lrc`))); + } + const current: RetagJob = { id: ++jobSeq, path: toRel(abs), source, total: files.length, done: 0, results: [], finished: false }; + job = current; + bus.emit('retag', current); + + void (async () => { + for (const file of files) { + current.current = toRel(file); + bus.emit('retag', current); + try { + const { match } = await retagFile(file, source); + current.results.push({ file: toRel(file), ok: true, match }); + } catch (e) { + current.results.push({ file: toRel(file), ok: false, error: errorMessage(e) }); + } + current.done++; + bus.emit('retag', current); + await sleep(400); + } + current.finished = true; + current.current = undefined; + bus.emit('retag', current); + })(); + return current; +} diff --git a/server/src/main.ts b/server/src/main.ts new file mode 100644 index 0000000..6823741 --- /dev/null +++ b/server/src/main.ts @@ -0,0 +1,20 @@ +import { createApp } from './app.ts'; +import { config } from './config.ts'; +import { hasFFmpeg } from './core/tagging.ts'; +import { importQQCookieFile, startQQKeeper } from './accounts.ts'; +import { qq } from './core/providers/index.ts'; +import { importLegacyHistory } from './db.ts'; +import { queue } from './queue.ts'; + +const imported = importLegacyHistory(config.legacyHistoryFile); +if (imported) console.log(`📥 已导入旧版下载历史 ${imported} 条`); +if (importQQCookieFile()) console.log(`🔑 已从 ${config.qqCookieFile} 导入 QQ 音乐 Cookie`); + +createApp().listen(config.port, process.env.HOST || '0.0.0.0', async () => { + console.log(`🎧 声匣已启动 http://localhost:${config.port}`); + console.log(` 音乐目录: ${config.musicDir}`); + console.log(` 数据目录: ${config.dataDir}`); + queue.pump(); + startQQKeeper(qq); + console.log(` ffmpeg : ${(await hasFFmpeg()) ? '✓' : '✗(FLAC 标签写入不可用)'}`); +}); diff --git a/server/src/queue.ts b/server/src/queue.ts new file mode 100644 index 0000000..e62bd52 --- /dev/null +++ b/server/src/queue.ts @@ -0,0 +1,314 @@ +// 下载队列:任务持久化在 SQLite,进度只在内存中并通过事件总线推送 +import { config } from './config.ts'; +import { downloadTrack, type Stage } from './core/pipeline.ts'; +import { getProvider } from './core/providers/index.ts'; +import { isQualityKey, type QualityKey } from './core/quality.ts'; +import { FatalError, type Source } from './core/types.ts'; +import { errorMessage, sleep } from './core/util.ts'; +import { addHistory, db, getSetting, setSetting } from './db.ts'; +import { bus } from './events.ts'; +import { getSettings } from './settings.ts'; + +export type TaskStatus = 'queued' | 'running' | 'paused' | 'done' | 'failed' | 'canceled'; + +export interface Task { + id: number; + source: Source; + trackId: string; + name: string; + artists: string; + album: string; + cover: string | null; + quality: QualityKey; + groupTitle: string | null; + status: TaskStatus; + stage: Stage | null; + error: string | null; + actualQuality: string | null; + bitrate: number | null; + size: number | null; + file: string | null; + skipped: boolean; + warnings: string[]; + createdAt: string; + updatedAt: string; +} + +export interface TaskProgress { + id: number; + stage?: Stage; + received: number; + total: number; + speed: number; +} + +export interface EnqueueItem { + source: Source; + id: string; + name?: string; + artists?: string; + album?: string; + cover?: string; +} + +const COLUMNS = `id, source, track_id AS trackId, name, artists, album, cover, quality, group_title AS groupTitle, + status, stage, error, actual_quality AS actualQuality, bitrate, size, file, skipped, warnings, + created_at AS createdAt, updated_at AS updatedAt`; + +function toTask(row: any): Task { + return { ...row, skipped: !!row.skipped, warnings: row.warnings ? JSON.parse(row.warnings) : [] }; +} + +function getTask(id: number): Task | null { + const row = db.prepare(`SELECT ${COLUMNS} FROM tasks WHERE id = ?`).get(id); + return row ? toTask(row) : null; +} + +function update(id: number, fields: Record): Task | null { + const keys = Object.keys(fields); + db.prepare(`UPDATE tasks SET ${keys.map((k) => `${k} = ?`).join(', ')}, updated_at = ? WHERE id = ?`).run( + ...(keys.map((k) => fields[k]) as any[]), + new Date().toISOString(), + id, + ); + const task = getTask(id); + if (task) bus.emit('task', task); + return task; +} + +type AbortReason = 'pause' | 'cancel'; + +class DownloadQueue { + private running = new Map(); + private progress = new Map(); + private lastStart = 0; + paused = getSetting('queue.paused', false); + + constructor() { + // 上次进程退出时还在跑的任务重新排队 + db.prepare("UPDATE tasks SET status = 'queued', stage = NULL WHERE status = 'running'").run(); + } + + list(scope: 'active' | 'finished' | 'all' = 'all', limit = 500): Task[] { + const where = + scope === 'active' + ? "WHERE status IN ('queued','running','paused')" + : scope === 'finished' + ? "WHERE status IN ('done','failed','canceled')" + : ''; + const order = scope === 'finished' ? 'ORDER BY updated_at DESC, id DESC' : 'ORDER BY id'; + return (db.prepare(`SELECT ${COLUMNS} FROM tasks ${where} ${order} LIMIT ?`).all(limit) as any[]).map(toTask); + } + + progressSnapshot(): TaskProgress[] { + return [...this.progress.values()]; + } + + summary() { + const rows = db.prepare('SELECT status, COUNT(*) AS n FROM tasks GROUP BY status').all() as Array<{ status: TaskStatus; n: number }>; + const counts = Object.fromEntries(rows.map((r) => [r.status, r.n])) as Partial>; + return { paused: this.paused, concurrency: getSettings().concurrency, counts }; + } + + private emitSummary() { + bus.emit('queue', this.summary()); + } + + enqueue(items: EnqueueItem[], opts: { quality?: string; group?: string } = {}): { added: Task[]; duplicates: number } { + const quality = isQualityKey(opts.quality) ? opts.quality : getSettings().quality; + const active = db.prepare("SELECT id FROM tasks WHERE source = ? AND track_id = ? AND status IN ('queued','running','paused')"); + const insert = db.prepare(` + INSERT INTO tasks (source, track_id, name, artists, album, cover, quality, group_title, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?) + `); + const added: Task[] = []; + let duplicates = 0; + const now = new Date().toISOString(); + db.exec('BEGIN'); + try { + for (const item of items) { + getProvider(item.source); + if (active.get(item.source, String(item.id))) { + duplicates++; + continue; + } + const { lastInsertRowid } = insert.run( + item.source, + String(item.id), + item.name || String(item.id), + item.artists || '', + item.album || '', + item.cover || null, + quality, + opts.group || null, + now, + now, + ); + added.push(getTask(Number(lastInsertRowid))!); + } + db.exec('COMMIT'); + } catch (e) { + db.exec('ROLLBACK'); + throw e; + } + for (const task of added) bus.emit('task', task); + this.emitSummary(); + this.pump(); + return { added, duplicates }; + } + + pause(id: number) { + const task = getTask(id); + if (task?.status === 'queued') update(id, { status: 'paused' }); + else if (task?.status === 'running') this.running.get(id)?.abort('pause' satisfies AbortReason); + this.emitSummary(); + } + + resume(id: number) { + const task = getTask(id); + if (task?.status === 'paused') update(id, { status: 'queued', stage: null }); + this.emitSummary(); + this.pump(); + } + + retry(id: number) { + const task = getTask(id); + if (task && (task.status === 'failed' || task.status === 'canceled')) { + update(id, { status: 'queued', stage: null, error: null }); + } + this.emitSummary(); + this.pump(); + } + + retryFailed(): number { + const ids = (db.prepare("SELECT id FROM tasks WHERE status = 'failed'").all() as Array<{ id: number }>).map((r) => r.id); + for (const id of ids) update(id, { status: 'queued', stage: null, error: null }); + this.emitSummary(); + this.pump(); + return ids.length; + } + + cancel(id: number) { + const task = getTask(id); + if (task?.status === 'queued' || task?.status === 'paused') update(id, { status: 'canceled', stage: null }); + else if (task?.status === 'running') this.running.get(id)?.abort('cancel' satisfies AbortReason); + this.emitSummary(); + } + + remove(id: number) { + if (this.running.has(id)) return; + db.prepare('DELETE FROM tasks WHERE id = ?').run(id); + bus.emit('task-removed', { ids: [id] }); + this.emitSummary(); + } + + clearFinished(includeFailed = false) { + const statuses = includeFailed ? "('done','canceled','failed')" : "('done','canceled')"; + const ids = (db.prepare(`SELECT id FROM tasks WHERE status IN ${statuses}`).all() as Array<{ id: number }>).map((r) => r.id); + db.prepare(`DELETE FROM tasks WHERE status IN ${statuses}`).run(); + bus.emit('task-removed', { ids }); + this.emitSummary(); + return ids.length; + } + + setPaused(paused: boolean) { + this.paused = paused; + setSetting('queue.paused', paused); + this.emitSummary(); + if (!paused) this.pump(); + } + + /** 按并发数启动排队中的任务。全局暂停只阻止新任务开始,进行中的任务会跑完 */ + pump() { + if (this.paused) return; + const { concurrency } = getSettings(); + while (this.running.size < concurrency) { + const exclude = [...this.running.keys()]; + const row = db + .prepare( + `SELECT id FROM tasks WHERE status = 'queued' ${exclude.length ? `AND id NOT IN (${exclude.join(',')})` : ''} ORDER BY id LIMIT 1`, + ) + .get() as { id: number } | undefined; + if (!row) break; + const controller = new AbortController(); + this.running.set(row.id, controller); + update(row.id, { status: 'running', stage: null, error: null }); + void this.run(row.id, controller); + } + } + + private async run(id: number, controller: AbortController) { + const task = getTask(id)!; + const settings = getSettings(); + const live: TaskProgress = { id, received: 0, total: 0, speed: 0 }; + this.progress.set(id, live); + try { + // 任务之间保持间隔,避免短时间内大量请求上游接口 + const wait = this.lastStart + settings.gapMs - Date.now(); + this.lastStart = Math.max(Date.now(), this.lastStart + settings.gapMs); + if (wait > 0) await sleep(wait, controller.signal); + + const result = await downloadTrack( + getProvider(task.source), + task.trackId, + { ...settings, quality: task.quality }, + { + musicDir: config.musicDir, + signal: controller.signal, + reporter: { + stage: (stage) => { + live.stage = stage; + update(id, { stage }); + }, + progress: (p) => { + Object.assign(live, p); + bus.emit('progress', live); + }, + }, + }, + ); + update(id, { + status: 'done', + stage: null, + name: result.name, + artists: result.artists, + album: result.album, + actual_quality: result.quality, + bitrate: result.bitrate ?? null, + size: result.size, + file: result.relFile, + skipped: result.skipped ? 1 : 0, + warnings: JSON.stringify(result.warnings), + }); + addHistory({ + source: task.source, + trackId: task.trackId, + name: result.name, + artists: result.artists, + album: result.album, + quality: result.quality, + bitrate: result.bitrate ?? null, + size: result.size, + file: result.relFile, + }); + bus.emit('history'); + } catch (e) { + const reason = controller.signal.aborted ? (controller.signal.reason as AbortReason) : null; + if (reason === 'pause') update(id, { status: 'paused', stage: null }); + else if (reason === 'cancel') update(id, { status: 'canceled', stage: null }); + else { + update(id, { status: 'failed', stage: null, error: errorMessage(e) }); + if (!(e instanceof FatalError)) console.warn(`✖ 任务 ${id} 失败: ${errorMessage(e)}`); + } + } finally { + this.running.delete(id); + this.progress.delete(id); + this.emitSummary(); + this.pump(); + } + } +} + +export const queue = new DownloadQueue(); + +bus.on('settings', () => queue.pump()); diff --git a/server/src/routes/api.ts b/server/src/routes/api.ts new file mode 100644 index 0000000..c556fba --- /dev/null +++ b/server/src/routes/api.ts @@ -0,0 +1,356 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { PassThrough } from 'node:stream'; +import Router from '@koa/router'; +import type { Context } from 'koa'; +import { clearQQCookie, getQQStatus, pollQRLogin, refreshQQ, saveQQCookie, startQRLogin } from '../accounts.ts'; +import { changePassword, isAuthenticated, login, logout, passwordEnabled } from '../auth.ts'; +import { config } from '../config.ts'; +import { availableSources, getProvider, qq, resolveLink } from '../core/providers/index.ts'; +import { QUALITY_INFO, QUALITY_KEYS } from '../core/quality.ts'; +import { hasFFmpeg } from '../core/tagging.ts'; +import type { Source, TrackSummary } from '../core/types.ts'; +import { allHistory, downloadedIds, historyStats, listHistory } from '../db.ts'; +import { bus } from '../events.ts'; +import { + AUDIO_EXTS, + currentRetagJob, + emptyTrash, + listDir, + moveToTrash, + resolveInLibrary, + startRetag, + trashInfo, + writeM3u, + zipStream, +} from '../library.ts'; +import { queue, type EnqueueItem } from '../queue.ts'; +import { getSettings, updateSettings } from '../settings.ts'; +import { proxyStream } from '../stream.ts'; + +export const api = new Router({ prefix: '/api' }); + +function intParam(value: unknown, fallback: number, max = Infinity): number { + const n = Number.parseInt(String(value ?? ''), 10); + return Number.isFinite(n) && n > 0 ? Math.min(n, max) : fallback; +} + +function notFound(what: string): never { + throw Object.assign(new Error(`${what}不存在或获取失败`), { status: 404 }); +} + +function body(ctx: Context): Record { + return (ctx.request as any).body || {}; +} + +/** 给列表中的歌曲标记是否已下载过 */ +function markDownloaded(source: string, detail: T) { + const owned = downloadedIds(source, detail.tracks.map((t) => t.id)); + return { ...detail, tracks: detail.tracks.map((t) => ({ ...t, downloaded: owned.has(t.id) })) }; +} + +// ---------- 基础 ---------- + +api.get('/health', async (ctx) => { + ctx.body = { ok: true, ffmpeg: await hasFFmpeg(), sources: availableSources() }; +}); + +api.get('/meta', (ctx) => { + ctx.body = { + sources: availableSources(), + qualities: QUALITY_KEYS.map((key) => ({ key, ...QUALITY_INFO[key] })), + sourceQualities: { netease: getProvider('netease').qualities, qq: qq.qualities }, + musicDir: config.musicDir, + }; +}); + +// ---------- 访问密码 ---------- + +api.get('/auth/status', (ctx) => { + ctx.body = { required: passwordEnabled(), authenticated: isAuthenticated(ctx) }; +}); + +api.post('/auth/login', (ctx) => { + if (!login(ctx, String(body(ctx).password ?? ''))) ctx.throw(401, '密码不正确'); + ctx.body = { ok: true }; +}); + +api.post('/auth/logout', (ctx) => { + logout(ctx); + ctx.body = { ok: true }; +}); + +api.put('/auth/password', (ctx) => { + const { current, password } = body(ctx); + changePassword(ctx, current, password); + ctx.body = { required: passwordEnabled() }; +}); + +// ---------- 设置与账号 ---------- + +api.get('/settings', (ctx) => { + ctx.body = getSettings(); +}); + +api.put('/settings', (ctx) => { + ctx.body = updateSettings(body(ctx)); +}); + +api.get('/accounts', async (ctx) => { + ctx.body = { qq: await getQQStatus(qq, ctx.query.refresh === '1') }; +}); + +api.post('/accounts/qq/cookie', async (ctx) => { + const cookie = String(body(ctx).cookie ?? '').trim(); + if (!cookie) ctx.throw(400, '请粘贴 Cookie'); + saveQQCookie(cookie); + ctx.body = { qq: await getQQStatus(qq, true) }; +}); + +api.post('/accounts/qq/qr', async (ctx) => { + ctx.body = await startQRLogin(); +}); + +api.get('/accounts/qq/qr/:id', async (ctx) => { + ctx.body = await pollQRLogin(qq, ctx.params.id!); +}); + +api.post('/accounts/qq/refresh', async (ctx) => { + const ok = await refreshQQ(qq); + ctx.body = { ok, qq: await getQQStatus(qq, true) }; +}); + +api.delete('/accounts/qq', (ctx) => { + clearQQCookie(); + ctx.body = { qq: { loggedIn: false } }; +}); + +// ---------- 搜索与详情 ---------- + +api.get('/search', async (ctx) => { + const q = String(ctx.query.q ?? '').trim(); + if (!q) ctx.throw(400, '请输入关键词'); + const provider = getProvider(String(ctx.query.source ?? 'netease')); + const result = await provider.search(q, { page: intParam(ctx.query.page, 1), limit: intParam(ctx.query.limit, 30, 100) }); + ctx.body = { ...result, items: markDownloaded(provider.source, { tracks: result.items }).tracks }; +}); + +api.get('/resolve-link', async (ctx) => { + const ref = await resolveLink(String(ctx.query.url ?? '')); + if (!ref) ctx.throw(400, '无法识别的链接或 ID'); + ctx.body = ref; +}); + +api.get('/:source/track/:id', async (ctx) => { + const provider = getProvider(ctx.params.source!); + const meta = (await provider.getTrackMeta(ctx.params.id!)) ?? notFound('歌曲'); + const owned = downloadedIds(provider.source, [meta.id]); + ctx.body = { ...meta, downloaded: owned.has(meta.id) }; +}); + +api.get('/:source/album/:id', async (ctx) => { + const detail = (await getProvider(ctx.params.source!).getAlbum(ctx.params.id!)) ?? notFound('专辑'); + ctx.body = markDownloaded(ctx.params.source!, detail); +}); + +api.get('/:source/playlist/:id', async (ctx) => { + const detail = (await getProvider(ctx.params.source!).getPlaylist(ctx.params.id!)) ?? notFound('歌单'); + ctx.body = markDownloaded(ctx.params.source!, detail); +}); + +api.get('/:source/artist/:id', async (ctx) => { + const detail = (await getProvider(ctx.params.source!).getArtist(ctx.params.id!)) ?? notFound('歌手'); + ctx.body = markDownloaded(ctx.params.source!, detail); +}); + +api.get('/:source/lyric/:id', async (ctx) => { + ctx.body = (await getProvider(ctx.params.source!).getLyric(ctx.params.id!)) ?? notFound('歌词'); +}); + +api.get('/:source/stream/:id', async (ctx) => { + await proxyStream(ctx, ctx.params.source!, ctx.params.id!); +}); + +// ---------- 下载队列 ---------- + +api.get('/tasks', (ctx) => { + const scope = ['active', 'finished'].includes(String(ctx.query.scope)) ? (ctx.query.scope as 'active' | 'finished') : 'all'; + ctx.body = { tasks: queue.list(scope), progress: queue.progressSnapshot(), summary: queue.summary() }; +}); + +api.post('/tasks', (ctx) => { + const { items, quality, group } = body(ctx); + if (!Array.isArray(items) || !items.length) ctx.throw(400, '没有要下载的歌曲'); + const clean: EnqueueItem[] = items + .filter((i: any) => i && typeof i.id !== 'undefined' && (i.source === 'netease' || i.source === 'qq')) + .map((i: any) => ({ + source: i.source as Source, + id: String(i.id), + name: i.name, + artists: i.artists, + album: i.album, + cover: i.cover, + })); + const { added, duplicates } = queue.enqueue(clean, { quality, group: typeof group === 'string' ? group : undefined }); + ctx.body = { added: added.length, duplicates }; +}); + +api.post('/tasks/:id/:action', (ctx) => { + const id = Number(ctx.params.id); + switch (ctx.params.action) { + case 'pause': + queue.pause(id); + break; + case 'resume': + queue.resume(id); + break; + case 'retry': + queue.retry(id); + break; + case 'cancel': + queue.cancel(id); + break; + default: + ctx.throw(404, '未知操作'); + } + ctx.body = { ok: true }; +}); + +api.delete('/tasks/:id', (ctx) => { + queue.remove(Number(ctx.params.id)); + ctx.body = { ok: true }; +}); + +api.post('/queue/:action', (ctx) => { + const action = ctx.params.action; + if (action === 'pause') queue.setPaused(true); + else if (action === 'resume') queue.setPaused(false); + else if (action === 'retry-failed') ctx.body = { retried: queue.retryFailed() }; + else if (action === 'clear') ctx.body = { removed: queue.clearFinished(body(ctx).includeFailed === true) }; + else ctx.throw(404, '未知操作'); + ctx.body ??= queue.summary(); +}); + +// ---------- 实时事件(SSE) ---------- + +api.get('/events', (ctx) => { + ctx.req.socket.setTimeout(0); + ctx.req.socket.setNoDelay(true); + ctx.req.socket.setKeepAlive(true); + ctx.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' }); + const stream = new PassThrough(); + ctx.status = 200; + ctx.body = stream; + + const send = (event: string, data: unknown) => stream.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + const handlers: Record void> = { + task: (d) => send('task', d), + 'task-removed': (d) => send('task-removed', d), + progress: (d) => send('progress', d), + queue: (d) => send('queue', d), + settings: (d) => send('settings', d), + account: (d) => send('account', d), + history: () => send('history', {}), + retag: (d) => send('retag', d), + }; + for (const [event, fn] of Object.entries(handlers)) bus.on(event, fn); + send('hello', { summary: queue.summary(), progress: queue.progressSnapshot() }); + const ping = setInterval(() => stream.write(': ping\n\n'), 25000); + + const cleanup = () => { + clearInterval(ping); + for (const [event, fn] of Object.entries(handlers)) bus.off(event, fn); + }; + ctx.req.on('close', cleanup); + stream.on('error', cleanup); +}); + +// ---------- 历史 ---------- + +api.get('/history', (ctx) => { + ctx.body = listHistory(intParam(ctx.query.limit, 50, 500), Number(ctx.query.offset) || 0, { + q: String(ctx.query.q ?? '').trim() || undefined, + source: ctx.query.source ? String(ctx.query.source) : undefined, + }); +}); + +api.get('/history/export', (ctx) => { + const rows = allHistory(); + const stamp = new Date().toISOString().slice(0, 10); + if (ctx.query.format === 'csv') { + const cols = ['createdAt', 'source', 'trackId', 'name', 'artists', 'album', 'quality', 'bitrate', 'size', 'file'] as const; + const esc = (v: unknown) => `"${String(v ?? '').replace(/"/g, '""')}"`; + const csv = [cols.join(','), ...rows.map((r) => cols.map((c) => esc(r[c])).join(','))].join('\r\n'); + ctx.set('Content-Disposition', `attachment; filename="history-${stamp}.csv"`); + ctx.type = 'text/csv; charset=utf-8'; + ctx.body = '' + csv; + } else { + ctx.set('Content-Disposition', `attachment; filename="history-${stamp}.json"`); + ctx.body = rows; + } +}); + +api.get('/stats', (ctx) => { + ctx.body = historyStats(); +}); + +// ---------- 曲库 ---------- + +api.get('/library', async (ctx) => { + ctx.body = await listDir(ctx.query.path ? String(ctx.query.path) : ''); +}); + +api.get('/library/file', async (ctx) => { + const abs = resolveInLibrary(String(ctx.query.path ?? '')); + const stat = await fs.promises.stat(abs).catch(() => null); + if (!stat?.isFile()) throw Object.assign(new Error('文件不存在'), { status: 404 }); + const ext = path.extname(abs).toLowerCase(); + const types: Record = { '.mp3': 'audio/mpeg', '.flac': 'audio/flac', '.m4a': 'audio/mp4', '.ogg': 'audio/ogg', '.jpg': 'image/jpeg', '.png': 'image/png', '.lrc': 'text/plain; charset=utf-8' }; + ctx.type = types[ext] || 'application/octet-stream'; + ctx.set('Accept-Ranges', 'bytes'); + const range = ctx.get('range').match(/bytes=(\d*)-(\d*)/); + if (range && AUDIO_EXTS.has(ext)) { + const start = range[1] ? Number(range[1]) : 0; + const end = range[2] ? Math.min(Number(range[2]), stat.size - 1) : stat.size - 1; + ctx.status = 206; + ctx.set('Content-Range', `bytes ${start}-${end}/${stat.size}`); + ctx.length = end - start + 1; + ctx.body = fs.createReadStream(abs, { start, end }); + } else { + ctx.length = stat.size; + ctx.body = fs.createReadStream(abs); + } +}); + +api.post('/library/m3u', async (ctx) => { + ctx.body = { file: await writeM3u(String(body(ctx).path ?? '')) }; +}); + +api.get('/library/zip', async (ctx) => { + const { stream, filename } = await zipStream(String(ctx.query.path ?? '')); + ctx.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`); + ctx.type = 'application/zip'; + ctx.body = stream; +}); + +api.post('/library/trash', async (ctx) => { + ctx.body = { moved: await moveToTrash(String(body(ctx).path ?? '')) }; +}); + +api.get('/library/trash', async (ctx) => { + ctx.body = await trashInfo(); +}); + +api.delete('/library/trash', async (ctx) => { + await emptyTrash(); + ctx.body = { ok: true }; +}); + +api.get('/library/retag', (ctx) => { + ctx.body = { job: currentRetagJob() }; +}); + +api.post('/library/retag', async (ctx) => { + const { path: rel, source, onlyMissingLyric } = body(ctx); + ctx.body = await startRetag(String(rel ?? ''), source === 'qq' ? 'qq' : 'netease', onlyMissingLyric === true); +}); diff --git a/server/src/secret.ts b/server/src/secret.ts new file mode 100644 index 0000000..6ec6cf4 --- /dev/null +++ b/server/src/secret.ts @@ -0,0 +1,51 @@ +// 本机密钥:首次启动在数据目录生成,用于加密存储的凭据与签名登录会话 +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { config } from './config.ts'; + +const KEY_FILE = path.join(config.dataDir, 'secret.key'); + +function loadKey(): Buffer { + try { + const hex = fs.readFileSync(KEY_FILE, 'utf-8').trim(); + if (/^[0-9a-f]{64}$/i.test(hex)) return Buffer.from(hex, 'hex'); + } catch {} + const key = crypto.randomBytes(32); + fs.writeFileSync(KEY_FILE, key.toString('hex'), { mode: 0o600 }); + return key; +} + +const KEY = loadKey(); + +export function encrypt(plain: string): string { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', KEY, iv); + const data = Buffer.concat([cipher.update(plain, 'utf-8'), cipher.final()]); + return [iv, cipher.getAuthTag(), data].map((b) => b.toString('base64url')).join('.'); +} + +export function decrypt(payload: string): string { + const [iv, tag, data] = payload.split('.').map((s) => Buffer.from(s, 'base64url')); + const decipher = crypto.createDecipheriv('aes-256-gcm', KEY, iv!); + decipher.setAuthTag(tag!); + return Buffer.concat([decipher.update(data!), decipher.final()]).toString('utf-8'); +} + +export function sign(value: string): string { + return crypto.createHmac('sha256', KEY).update(value).digest('base64url'); +} + +export function hashPassword(password: string): string { + const salt = crypto.randomBytes(16); + const hash = crypto.scryptSync(password, salt, 32); + return `${salt.toString('base64url')}.${hash.toString('base64url')}`; +} + +export function verifyPassword(password: string, stored: string): boolean { + const [salt, hash] = stored.split('.'); + if (!salt || !hash) return false; + const expected = Buffer.from(hash, 'base64url'); + const actual = crypto.scryptSync(password, Buffer.from(salt, 'base64url'), expected.length); + return crypto.timingSafeEqual(actual, expected); +} diff --git a/server/src/settings.ts b/server/src/settings.ts new file mode 100644 index 0000000..c1bd6d2 --- /dev/null +++ b/server/src/settings.ts @@ -0,0 +1,40 @@ +// 运行时设置:config.json 提供默认值,网页上修改后存入 SQLite +import { config, type DownloadDefaults } from './config.ts'; +import { isQualityKey } from './core/quality.ts'; +import { getSetting, setSetting } from './db.ts'; +import { bus } from './events.ts'; + +export interface Settings extends DownloadDefaults { + concurrency: number; + /** 批量任务之间的间隔,避免请求过快 */ + gapMs: number; +} + +const BASE: Settings = { ...config.defaults, concurrency: 2, gapMs: 400 }; + +let current: Settings = { ...BASE, ...getSetting>('settings', {}) }; + +export function getSettings(): Settings { + return current; +} + +function clampInt(value: unknown, min: number, max: number, fallback: number): number { + const n = Math.round(Number(value)); + return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : fallback; +} + +export function updateSettings(patch: Partial>): Settings { + const next = { ...current }; + if (patch.quality !== undefined && isQualityKey(patch.quality)) next.quality = patch.quality; + if (patch.layout === 'album' || patch.layout === 'flat') next.layout = patch.layout; + if (patch.retries !== undefined) next.retries = clampInt(patch.retries, 1, 10, next.retries); + if (patch.concurrency !== undefined) next.concurrency = clampInt(patch.concurrency, 1, 6, next.concurrency); + if (patch.gapMs !== undefined) next.gapMs = clampInt(patch.gapMs, 0, 5000, next.gapMs); + for (const key of ['lyric', 'translatedLyric', 'embedTags', 'albumCover'] as const) { + if (typeof patch[key] === 'boolean') next[key] = patch[key] as boolean; + } + current = next; + setSetting('settings', current); + bus.emit('settings', current); + return current; +} diff --git a/server/src/stream.ts b/server/src/stream.ts new file mode 100644 index 0000000..feae7d0 --- /dev/null +++ b/server/src/stream.ts @@ -0,0 +1,34 @@ +// 试听代理:解析出 320K 音源后由服务端转发(支持 Range),绕过防盗链与混合内容 +import { Readable } from 'node:stream'; +import type { Context } from 'koa'; +import { UA } from './core/net/http.ts'; +import { getProvider } from './core/providers/index.ts'; +import { TtlCache } from './core/util.ts'; + +const urlCache = new TtlCache<{ url: string; ext: string }>(3 * 60 * 1000, 200); +const MIME: Record = { mp3: 'audio/mpeg', flac: 'audio/flac', m4a: 'audio/mp4', ogg: 'audio/ogg' }; + +export async function proxyStream(ctx: Context, source: string, id: string) { + const provider = getProvider(source); + const audio = await urlCache.getOrSet(`${source}:${id}`, async () => { + const r = await provider.resolveAudio(id, 'high'); + return { url: r.url, ext: r.ext }; + }); + + const controller = new AbortController(); + ctx.req.on('close', () => controller.abort()); + const headers: Record = { 'User-Agent': UA }; + if (ctx.get('range')) headers.Range = ctx.get('range'); + const upstream = await fetch(audio.url, { headers, signal: controller.signal }); + if (!upstream.ok || !upstream.body) ctx.throw(502, `音源请求失败 (HTTP ${upstream.status})`); + + ctx.status = upstream.status; + ctx.set('Content-Type', MIME[audio.ext] || upstream.headers.get('content-type') || 'application/octet-stream'); + ctx.set('Accept-Ranges', 'bytes'); + ctx.set('Cache-Control', 'no-store'); + for (const h of ['content-length', 'content-range']) { + const v = upstream.headers.get(h); + if (v) ctx.set(h, v); + } + ctx.body = Readable.fromWeb(upstream.body as any); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..cb5b524 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "nodenext", + "moduleResolution": "nodenext", + "lib": ["esnext"], + "types": ["node"], + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "noUncheckedIndexedAccess": true + }, + "include": ["server/**/*.ts", "vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..5766983 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,19 @@ +import vue from '@vitejs/plugin-vue'; +import { defineConfig } from 'vite'; + +const apiPort = process.env.PORT || '8066'; + +export default defineConfig({ + root: 'web', + plugins: [vue()], + server: { + port: 5173, + proxy: { + '/api': { target: `http://127.0.0.1:${apiPort}`, changeOrigin: true }, + }, + }, + build: { + outDir: '../dist/web', + emptyOutDir: true, + }, +}); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..59edda7 --- /dev/null +++ b/web/index.html @@ -0,0 +1,20 @@ + + + + + + + + 声匣 + + + +
+ + + diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..8491a0f --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..a8387b4 --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,411 @@ + + + + + diff --git a/web/src/components/CoverArt.vue b/web/src/components/CoverArt.vue new file mode 100644 index 0000000..289eaa1 --- /dev/null +++ b/web/src/components/CoverArt.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/web/src/components/DetailHeader.vue b/web/src/components/DetailHeader.vue new file mode 100644 index 0000000..0098b89 --- /dev/null +++ b/web/src/components/DetailHeader.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/web/src/components/Icon.vue b/web/src/components/Icon.vue new file mode 100644 index 0000000..772116b --- /dev/null +++ b/web/src/components/Icon.vue @@ -0,0 +1,57 @@ + + + + + + + diff --git a/web/src/components/PlayerBar.vue b/web/src/components/PlayerBar.vue new file mode 100644 index 0000000..d5b90d9 --- /dev/null +++ b/web/src/components/PlayerBar.vue @@ -0,0 +1,240 @@ + + + + + diff --git a/web/src/components/QRLoginDialog.vue b/web/src/components/QRLoginDialog.vue new file mode 100644 index 0000000..b5b1c45 --- /dev/null +++ b/web/src/components/QRLoginDialog.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/web/src/components/QualityPicker.vue b/web/src/components/QualityPicker.vue new file mode 100644 index 0000000..1be14b6 --- /dev/null +++ b/web/src/components/QualityPicker.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/web/src/components/ToastHost.vue b/web/src/components/ToastHost.vue new file mode 100644 index 0000000..9f48b67 --- /dev/null +++ b/web/src/components/ToastHost.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/web/src/components/TrackList.vue b/web/src/components/TrackList.vue new file mode 100644 index 0000000..ddb73c8 --- /dev/null +++ b/web/src/components/TrackList.vue @@ -0,0 +1,464 @@ + + + + + diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..ef92bed --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,323 @@ +export type Source = 'netease' | 'qq'; +export type QualityKey = 'standard' | 'high' | 'lossless' | 'hires' | 'master' | 'spatial' | 'effect'; + +export interface ArtistRef { + id: string; + name: string; +} + +export interface TrackSummary { + source: Source; + id: string; + name: string; + artists: string; + artistRefs?: ArtistRef[]; + album: string; + albumId?: string; + cover?: string; + duration?: number; + trackNo?: number; + qualities?: QualityKey[]; + vip?: boolean; + downloaded?: boolean; +} + +export interface Paged { + items: T[]; + total: number; + page: number; + limit: number; +} + +export interface AlbumSummary { + source: Source; + id: string; + name: string; + artist: string; + cover?: string; + publishTime?: number; + size?: number; + type?: string; +} + +export interface AlbumDetail extends AlbumSummary { + artistRefs?: ArtistRef[]; + company?: string; + description?: string; + tracks: TrackSummary[]; +} + +export interface PlaylistDetail { + source: Source; + id: string; + name: string; + cover?: string; + creator?: string; + description?: string; + trackCount: number; + tracks: TrackSummary[]; +} + +export interface ArtistDetail { + source: Source; + id: string; + name: string; + avatar?: string; + description?: string; + tracks: TrackSummary[]; + albums: AlbumSummary[]; +} + +export interface TrackMeta { + source: Source; + id: string; + name: string; + artists: string; + album: string; + albumId?: string; + cover?: string; + duration?: number; + trackNo?: number; + trackTotal?: number; + year?: number; + date?: string; + genre?: string; + publisher?: string; + downloaded?: boolean; +} + +export interface QualityOption { + key: QualityKey; + label: string; + badge: string; +} + +export interface Meta { + sources: Source[]; + qualities: QualityOption[]; + sourceQualities: Record; + musicDir: string; +} + +export interface Settings { + quality: QualityKey; + layout: 'album' | 'flat'; + retries: number; + lyric: boolean; + translatedLyric: boolean; + embedTags: boolean; + albumCover: boolean; + concurrency: number; + gapMs: number; +} + +export interface Health { + ok: boolean; + ffmpeg: boolean; + sources: Source[]; +} + +export interface QQStatus { + loggedIn: boolean; + valid?: boolean; + uin?: string; + nick?: string; + avatar?: string; + vip?: { active: boolean; label: string; expires?: string }; + keyCreatedAt?: string; + keyExpiresAt?: string; + savedAt?: string; + via?: 'cookie' | 'file' | 'qr' | 'refresh'; + canRefresh?: boolean; + lastRefresh?: { at: string; ok: boolean; error?: string } | null; + error?: string; +} + +export type QRState = 'waiting' | 'scanned' | 'expired' | 'refused' | 'done' | 'error'; + +export type TaskStatus = 'queued' | 'running' | 'paused' | 'done' | 'failed' | 'canceled'; +export type Stage = 'resolve' | 'metadata' | 'download' | 'lyric' | 'cover' | 'tag'; + +export interface Task { + id: number; + source: Source; + trackId: string; + name: string; + artists: string; + album: string; + cover: string | null; + quality: QualityKey; + groupTitle: string | null; + status: TaskStatus; + stage: Stage | null; + error: string | null; + actualQuality: QualityKey | null; + bitrate: number | null; + size: number | null; + file: string | null; + skipped: boolean; + warnings: string[]; + createdAt: string; + updatedAt: string; +} + +export interface TaskProgress { + id: number; + stage?: Stage; + received: number; + total: number; + speed: number; +} + +export interface QueueSummary { + paused: boolean; + concurrency: number; + counts: Partial>; +} + +export interface HistoryEntry { + id: number; + source: Source; + trackId: string; + name: string; + artists: string; + album: string; + quality: QualityKey | null; + bitrate: number | null; + size: number | null; + file: string | null; + createdAt: string; +} + +export interface Stats { + count: number; + size: number; + bySource: Array<{ key: string; count: number }>; + byQuality: Array<{ key: string; count: number }>; + topArtists: Array<{ key: string; count: number }>; + daily: Array<{ key: string; count: number }>; +} + +export interface LinkRef { + source: Source; + kind: 'track' | 'album' | 'playlist' | 'artist'; + id: string; +} + +export interface LibraryEntry { + name: string; + path: string; + type: 'dir' | 'audio' | 'other'; + size: number; + mtime: number; + hasLyric?: boolean; + cover?: string; +} + +export interface LibraryListing { + path: string; + hasCover: boolean; + entries: LibraryEntry[]; +} + +export interface RetagJob { + id: number; + path: string; + source: Source; + total: number; + done: number; + current?: string; + results: Array<{ file: string; ok: boolean; match?: string; error?: string }>; + finished: boolean; +} + +export class ApiError extends Error { + status: number; + constructor(message: string, status: number) { + super(message); + this.status = status; + } +} + +/** 401 时由 store 跳转登录 */ +export let onUnauthorized: () => void = () => {}; +export function setUnauthorizedHandler(fn: () => void) { + onUnauthorized = fn; +} + +async function request(path: string, init?: RequestInit): Promise { + const res = await fetch(`/api${path}`, { + ...init, + headers: init?.body ? { 'Content-Type': 'application/json', ...init?.headers } : init?.headers, + }); + const body = await res.json().catch(() => ({})); + if (res.status === 401 && !path.startsWith('/auth/')) onUnauthorized(); + if (!res.ok) throw new ApiError(body.error || `请求失败 (${res.status})`, res.status); + return body as T; +} + +const qs = (params: Record) => + new URLSearchParams(Object.entries(params).filter(([, v]) => v !== undefined && v !== '') as Array<[string, string]>).toString(); + +const json = (method: string, data?: unknown): RequestInit => ({ method, body: JSON.stringify(data ?? {}) }); + +export const api = { + health: () => request('/health'), + meta: () => request('/meta'), + + authStatus: () => request<{ required: boolean; authenticated: boolean }>('/auth/status'), + login: (password: string) => request<{ ok: true }>('/auth/login', json('POST', { password })), + logout: () => request<{ ok: true }>('/auth/logout', json('POST')), + setPassword: (current: string | undefined, password: string | undefined) => + request<{ required: boolean }>('/auth/password', json('PUT', { current, password })), + + settings: () => request('/settings'), + saveSettings: (patch: Partial) => request('/settings', json('PUT', patch)), + accounts: (refresh = false) => request<{ qq: QQStatus }>(`/accounts${refresh ? '?refresh=1' : ''}`), + saveQQCookie: (cookie: string) => request<{ qq: QQStatus }>('/accounts/qq/cookie', json('POST', { cookie })), + logoutQQ: () => request<{ qq: QQStatus }>('/accounts/qq', { method: 'DELETE' }), + qqQR: () => request<{ id: string; image: string }>('/accounts/qq/qr', json('POST')), + qqQRStatus: (id: string) => request<{ state: QRState; message?: string; qq?: QQStatus }>(`/accounts/qq/qr/${id}`), + refreshQQ: () => request<{ ok: boolean; qq: QQStatus }>('/accounts/qq/refresh', json('POST')), + + search: (source: Source, q: string, page = 1, limit = 30) => + request>(`/search?${qs({ source, q, page, limit })}`), + resolveLink: (url: string) => request(`/resolve-link?${qs({ url })}`), + track: (source: Source, id: string) => request(`/${source}/track/${encodeURIComponent(id)}`), + album: (source: Source, id: string) => request(`/${source}/album/${encodeURIComponent(id)}`), + playlist: (source: Source, id: string) => request(`/${source}/playlist/${encodeURIComponent(id)}`), + artist: (source: Source, id: string) => request(`/${source}/artist/${encodeURIComponent(id)}`), + lyric: (source: Source, id: string) => request<{ lrc?: string; tlyric?: string }>(`/${source}/lyric/${encodeURIComponent(id)}`), + streamUrl: (source: Source, id: string) => `/api/${source}/stream/${encodeURIComponent(id)}`, + + tasks: (scope: 'active' | 'finished' | 'all' = 'all') => + request<{ tasks: Task[]; progress: TaskProgress[]; summary: QueueSummary }>(`/tasks?scope=${scope}`), + enqueue: (items: TrackSummary[], quality: QualityKey, group?: string) => + request<{ added: number; duplicates: number }>( + '/tasks', + json('POST', { + quality, + group, + items: items.map((t) => ({ source: t.source, id: t.id, name: t.name, artists: t.artists, album: t.album, cover: t.cover })), + }), + ), + taskAction: (id: number, action: 'pause' | 'resume' | 'retry' | 'cancel') => request(`/tasks/${id}/${action}`, json('POST')), + removeTask: (id: number) => request(`/tasks/${id}`, { method: 'DELETE' }), + queueAction: (action: 'pause' | 'resume' | 'retry-failed' | 'clear', data?: unknown) => request(`/queue/${action}`, json('POST', data)), + + history: (params: { limit?: number; offset?: number; q?: string; source?: string }) => + request<{ items: HistoryEntry[]; total: number }>(`/history?${qs(params)}`), + historyExportUrl: (format: 'csv' | 'json') => `/api/history/export?format=${format}`, + stats: () => request('/stats'), + + library: (path = '') => request(`/library?${qs({ path })}`), + libraryFileUrl: (path: string) => `/api/library/file?${qs({ path })}`, + libraryZipUrl: (path: string) => `/api/library/zip?${qs({ path })}`, + writeM3u: (path: string) => request<{ file: string }>('/library/m3u', json('POST', { path })), + trash: (path: string) => request<{ moved: string }>('/library/trash', json('POST', { path })), + trashInfo: () => request<{ count: number; size: number }>('/library/trash'), + emptyTrash: () => request('/library/trash', { method: 'DELETE' }), + retagJob: () => request<{ job: RetagJob | null }>('/library/retag').then((r) => r.job), + retag: (path: string, source: Source, onlyMissingLyric: boolean) => + request('/library/retag', json('POST', { path, source, onlyMissingLyric })), +}; diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts new file mode 100644 index 0000000..170aea9 --- /dev/null +++ b/web/src/lib/format.ts @@ -0,0 +1,89 @@ +import type { QualityKey, Source } from './api.ts'; + +export const SOURCE_LABEL: Record = { netease: '网易云', qq: 'QQ 音乐' }; + +export const QUALITY_BADGE: Record = { + standard: '128K', + high: '320K', + lossless: 'FLAC', + hires: 'HI-RES', + master: 'MASTER', + spatial: 'SPATIAL', + effect: 'EFFECT', +}; + +export const QUALITY_LABEL: Record = { + standard: '标准', + high: '极高', + lossless: '无损', + hires: 'Hi-Res', + master: '超清母带', + spatial: '空间音频', + effect: '高清臻品', +}; + +const QUALITY_RANK: QualityKey[] = ['effect', 'spatial', 'master', 'hires', 'lossless', 'high', 'standard']; + +export function isGold(q: string | null | undefined): boolean { + return q === 'hires' || q === 'master' || q === 'spatial' || q === 'effect'; +} + +/** 该曲目声明的最佳音质(用于徽章) */ +export function bestQuality(qs: QualityKey[] | undefined): QualityKey | undefined { + if (!qs?.length) return undefined; + return QUALITY_RANK.find((q) => qs.includes(q)); +} + +export function formatDuration(ms?: number | null): string { + if (!ms) return '--:--'; + const s = Math.round(ms / 1000); + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; +} + +export function formatSeconds(sec: number): string { + if (!Number.isFinite(sec)) return '0:00'; + const s = Math.floor(sec); + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; +} + +export function formatSize(bytes?: number | null): string { + if (!bytes) return '—'; + if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(2)} GB`; + if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`; + return `${Math.max(1, Math.round(bytes / 1024))} KB`; +} + +export function formatSpeed(bps: number): string { + return bps >= 1048576 ? `${(bps / 1048576).toFixed(1)} MB/s` : `${Math.round(bps / 1024)} KB/s`; +} + +export function formatDate(ts?: number | string | null): string { + if (!ts) return ''; + const d = new Date(ts); + return Number.isNaN(d.getTime()) ? '' : `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +const dtf = new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); +export function formatDateTime(ts: string): string { + return dtf.format(new Date(ts)); +} + +export function coverThumb(url: string | undefined | null, size = 120): string | undefined { + if (!url) return undefined; + const https = url.replace(/^http:/, 'https:'); + if (/music\.126\.net/.test(https)) return `${https.split('?')[0]}?param=${size}y${size}`; + if (/y\.gtimg\.cn\/music\/photo_new\/T00\dR\d+x\d+/.test(https)) { + const s = size <= 150 ? 150 : size <= 300 ? 300 : 800; + return https.replace(/R\d+x\d+/, `R${s}x${s}`); + } + return https; +} + +export const STAGE_LABEL: Record = { + resolve: '解析音源', + metadata: '获取信息', + download: '下载中', + lyric: '保存歌词', + cover: '获取封面', + tag: '写入标签', +}; diff --git a/web/src/lib/player.ts b/web/src/lib/player.ts new file mode 100644 index 0000000..ff9cdaf --- /dev/null +++ b/web/src/lib/player.ts @@ -0,0 +1,128 @@ +// 试听播放器:单个