commit 21945cc54f646c4a34df5342ae02805f2a37732a Author: Jocay <1579649885@qq.com> Date: Sat Oct 10 22:05:30 2026 +0800 feat: 声匣 —— 网易云 / QQ 音乐自托管下载服务 由 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 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 @@ +// 试听播放器:单个