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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
data
|
||||||
|
music
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
music/
|
||||||
|
data/*
|
||||||
|
!data/.gitkeep
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -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 -- <id|链接> [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<null>` 的接口要返回对象包一层(Koa 对 `null` body 返回 204)
|
||||||
|
- TypeScript 固定 6.x:TS 7 没有 JS API,vue-tsc 无法使用
|
||||||
|
- 网易云红 / QQ 绿已用 dataviz 校验器验证(深色 QQ 绿 `#42a07d`);二者 CVD 区分度在 6–8,必须配合文字标签使用
|
||||||
+36
@@ -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"]
|
||||||
@@ -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 部署验证
|
||||||
+29
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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 "$@"
|
||||||
Generated
+2999
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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));
|
||||||
@@ -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<StoredQQ | null>('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<StoredQQ | null>('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<StoredQQ | null>('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<boolean> | null = null;
|
||||||
|
|
||||||
|
/** 续期 musickey。成功后新凭据立即持久化 */
|
||||||
|
export function refreshQQ(provider: QQProvider): Promise<boolean> {
|
||||||
|
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<string, QRSession>();
|
||||||
|
|
||||||
|
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<QQStatus> {
|
||||||
|
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<StoredQQ | null>('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<RefreshRecord | null>('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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string | null>('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<string | null>('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<string | null>('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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, any> {
|
||||||
|
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 });
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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<string, string>();
|
||||||
|
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<keyof Credits, RegExp> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<string, string>;
|
||||||
|
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<T = any>(url: string, opts: RequestOpts = {}): Promise<T> {
|
||||||
|
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<Buffer> {
|
||||||
|
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<string, string> } = {},
|
||||||
|
): Promise<number> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<DownloadResult> {
|
||||||
|
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<DownloadResult> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<Source, MusicProvider> = { 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<LinkRef | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<QualityKey, string> = {
|
||||||
|
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<string, QualityKey>;
|
||||||
|
|
||||||
|
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<T> {
|
||||||
|
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<NeteaseAlbumRaw | null>(10 * 60 * 1000);
|
||||||
|
|
||||||
|
constructor(cfg: NeteaseConfig) {
|
||||||
|
this.cfg = cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- chksz ----
|
||||||
|
|
||||||
|
private chksz<T>(endpoint: string, params: Record<string, string | number>, 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<ChkszResponse<T>>(url.toString(), { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(keyword: string, { page = 1, limit = 30 } = {}): Promise<Paged<TrackSummary>> {
|
||||||
|
const res = await this.chksz<any>(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<Lyric | null> {
|
||||||
|
try {
|
||||||
|
const res = await this.chksz<Lyric>(this.cfg.lyric, { id });
|
||||||
|
return res.code === 200 && res.data ? res.data : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPlaylist(id: string): Promise<PlaylistDetail | null> {
|
||||||
|
const res = await this.chksz<any>(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<ResolvedAudio> {
|
||||||
|
let lastMsg = '';
|
||||||
|
for (const q of fallbackChain(quality)) {
|
||||||
|
const res = await this.chksz<any>(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<any | null> {
|
||||||
|
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<NeteaseAlbumRaw | null> {
|
||||||
|
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<AlbumDetail | null> {
|
||||||
|
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<TrackMeta | null> {
|
||||||
|
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<ArtistDetail | null> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
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<string, unknown> };
|
||||||
|
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<K extends string>(
|
||||||
|
reqs: Record<K, MusicuRequest>,
|
||||||
|
opts: { signal?: AbortSignal; auth?: QQAuth | null; comm?: Record<string, unknown> } = {},
|
||||||
|
): Promise<Record<K, MusicuResult>> {
|
||||||
|
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<string, any>;
|
||||||
|
if (json.code !== 0) throw new Error(`QQ 音乐接口错误 (code ${json.code})`);
|
||||||
|
return json as Record<K, MusicuResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单个请求的便捷封装:code 非 0 时抛错 */
|
||||||
|
async call(req: MusicuRequest, signal?: AbortSignal): Promise<any> {
|
||||||
|
const { r } = await this.musicu({ r: req }, { signal });
|
||||||
|
if (r.code !== 0) throw new Error(`QQ 音乐接口错误 ${req.method} (code ${r.code})`);
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Record<QualityKey, { prefix: string; ext: string; size: (f: any) => 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<AlbumInfo | null>(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<Paged<TrackSummary>> {
|
||||||
|
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<any | null> {
|
||||||
|
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<AlbumInfo | null> {
|
||||||
|
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<AlbumDetail | null> {
|
||||||
|
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<PlaylistDetail | null> {
|
||||||
|
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<ArtistDetail | null> {
|
||||||
|
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<Lyric | null> {
|
||||||
|
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<TrackMeta | null> {
|
||||||
|
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<ResolvedAudio> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<number, string> = {
|
||||||
|
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<string, unknown>, 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<QQCredential> {
|
||||||
|
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<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
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<string, string>) =>
|
||||||
|
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<QRCode> {
|
||||||
|
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<string, QRState> = { '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<QQCredential> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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<QualityKey, { label: string; badge: string }> = {
|
||||||
|
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));
|
||||||
|
}
|
||||||
@@ -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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
const tags: Tags & Record<string, unknown> = {
|
||||||
|
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<void> {
|
||||||
|
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';
|
||||||
|
}
|
||||||
@@ -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<T> {
|
||||||
|
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<Paged<TrackSummary>>;
|
||||||
|
getTrackMeta(id: string): Promise<TrackMeta | null>;
|
||||||
|
getAlbum(id: string): Promise<AlbumDetail | null>;
|
||||||
|
getPlaylist(id: string): Promise<PlaylistDetail | null>;
|
||||||
|
getArtist(id: string): Promise<ArtistDetail | null>;
|
||||||
|
getLyric(id: string): Promise<Lyric | null>;
|
||||||
|
/** 按降级链解析音频地址;全部失败时抛错 */
|
||||||
|
resolveAudio(id: string, quality: QualityKey, signal?: AbortSignal): Promise<ResolvedAudio>;
|
||||||
|
/** 同步识别链接;短链等需要请求的情况返回 null,交给 expandLink */
|
||||||
|
parseLink(input: string): LinkRef | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 无法通过重试解决的错误(例如版权缺失),流水线遇到它不再重试 */
|
||||||
|
export class FatalError extends Error {
|
||||||
|
override name = 'FatalError';
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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<V> {
|
||||||
|
private map = new Map<string, { value: V; expires: number }>();
|
||||||
|
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<V>): Promise<V> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<HistoryEntry, 'id' | 'createdAt'> & { 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<string> {
|
||||||
|
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<T>(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<string, string> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// 进程内事件总线:队列 / 设置 / 账号变化通过它推送给 SSE 客户端
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
|
||||||
|
export const bus = new EventEmitter();
|
||||||
|
bus.setMaxListeners(100);
|
||||||
@@ -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<string[]> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<typeof guessFromPath>): 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<RetagJob> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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 标签写入不可用)'}`);
|
||||||
|
});
|
||||||
@@ -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<string, unknown>): 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<number, AbortController>();
|
||||||
|
private progress = new Map<number, TaskProgress>();
|
||||||
|
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<Record<TaskStatus, number>>;
|
||||||
|
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());
|
||||||
@@ -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<string, any> {
|
||||||
|
return (ctx.request as any).body || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 给列表中的歌曲标记是否已下载过 */
|
||||||
|
function markDownloaded<T extends { tracks: TrackSummary[] }>(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<string, (data: unknown) => 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<string, string> = { '.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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<Partial<Settings>>('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<Record<keyof Settings, unknown>>): 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;
|
||||||
|
}
|
||||||
@@ -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<string, string> = { 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<string, string> = { '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);
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<title>声匣</title>
|
||||||
|
<script>
|
||||||
|
try {
|
||||||
|
const t = localStorage.getItem('sx-theme');
|
||||||
|
if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t;
|
||||||
|
} catch {}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><circle cx="16" cy="16" r="15" fill="#1F1D1A"/><circle cx="16" cy="16" r="11" fill="none" stroke="#3a3631" stroke-width=".8"/><circle cx="16" cy="16" r="8" fill="none" stroke="#3a3631" stroke-width=".8"/><circle cx="16" cy="16" r="4.5" fill="#B5523B"/><circle cx="16" cy="16" r="1.2" fill="#F4F1EA"/></svg>
|
||||||
|
After Width: | Height: | Size: 367 B |
+411
@@ -0,0 +1,411 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue';
|
||||||
|
import PlayerBar from './components/PlayerBar.vue';
|
||||||
|
import ToastHost from './components/ToastHost.vue';
|
||||||
|
import { formatSpeed } from './lib/format.ts';
|
||||||
|
import { activeCount, auth, bootstrap, connected, progress, qqStatus, summary } from './lib/store.ts';
|
||||||
|
import { cycleTheme, themeMode } from './lib/theme.ts';
|
||||||
|
import { navItems } from './router.ts';
|
||||||
|
import LoginView from './views/LoginView.vue';
|
||||||
|
|
||||||
|
onMounted(() => bootstrap().catch(() => {}));
|
||||||
|
|
||||||
|
const themeLabel = { auto: '跟随系统', light: '纸', dark: '墨' } as const;
|
||||||
|
|
||||||
|
const totalSpeed = computed(() => [...progress.values()].reduce((s, p) => s + (p.speed || 0), 0));
|
||||||
|
const qqLabel = computed(() => {
|
||||||
|
const s = qqStatus.value;
|
||||||
|
if (!s) return '…';
|
||||||
|
if (!s.loggedIn) return '未登录';
|
||||||
|
if (s.valid === false) return '已失效';
|
||||||
|
return s.vip?.active ? s.vip.label : '已登录';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<LoginView v-if="auth.checked && !auth.authenticated" @success="bootstrap" />
|
||||||
|
<div v-else class="shell">
|
||||||
|
<aside class="side">
|
||||||
|
<RouterLink to="/" class="brand">
|
||||||
|
<span class="disc" aria-hidden="true"><i /></span>
|
||||||
|
<span class="brand-text">
|
||||||
|
<strong>声匣</strong>
|
||||||
|
<span class="eyebrow">Sound Case</span>
|
||||||
|
</span>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<nav class="nav">
|
||||||
|
<RouterLink v-for="(item, i) in navItems" :key="item.path" :to="item.path" class="nav-item" :class="{ active: item.path === '/' ? $route.path === '/' || ['album', 'playlist', 'artist'].includes(String($route.name)) : $route.path.startsWith(item.path) }">
|
||||||
|
<span class="nav-no mono">{{ String(i + 1).padStart(2, '0') }}</span>
|
||||||
|
<span class="nav-label">{{ item.label }}</span>
|
||||||
|
<span v-if="item.name === 'queue' && activeCount" class="nav-badge mono">{{ activeCount }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<RouterLink v-if="activeCount || summary.paused" to="/queue" class="dock">
|
||||||
|
<div class="dock-head">
|
||||||
|
<span class="eyebrow">{{ summary.paused ? 'Paused' : 'Live' }}</span>
|
||||||
|
<span class="mono faint">{{ summary.paused ? '已暂停' : formatSpeed(totalSpeed) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dock-line">
|
||||||
|
<span class="pulse" :class="{ off: summary.paused }" />
|
||||||
|
<span>进行 <b class="mono">{{ summary.counts.running ?? 0 }}</b> · 排队 <b class="mono">{{ summary.counts.queued ?? 0 }}</b></span>
|
||||||
|
</div>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<div class="side-foot">
|
||||||
|
<div class="sources">
|
||||||
|
<div class="src">
|
||||||
|
<span class="dot netease" :class="{ on: connected }" />
|
||||||
|
网易云
|
||||||
|
<span class="faint mono">{{ connected ? '可用' : '连接中' }}</span>
|
||||||
|
</div>
|
||||||
|
<RouterLink to="/settings" class="src">
|
||||||
|
<span class="dot qq" :class="{ on: qqStatus?.loggedIn && qqStatus.valid !== false }" />
|
||||||
|
QQ 音乐
|
||||||
|
<span class="faint mono">{{ qqLabel }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
<button class="theme" type="button" @click="cycleTheme" :title="`主题:${themeLabel[themeMode]}`">
|
||||||
|
<span class="theme-swatch" :data-mode="themeMode" />
|
||||||
|
{{ themeLabel[themeMode] }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="column">
|
||||||
|
<main class="main">
|
||||||
|
<RouterView v-slot="{ Component, route }">
|
||||||
|
<Transition name="page" mode="out-in">
|
||||||
|
<component :is="Component" :key="route.fullPath" />
|
||||||
|
</Transition>
|
||||||
|
</RouterView>
|
||||||
|
</main>
|
||||||
|
<PlayerBar />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ToastHost />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--sidebar-w) 1fr;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
padding: 28px 18px 22px 24px;
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand strong {
|
||||||
|
display: block;
|
||||||
|
font: 600 22px/1.1 var(--font-serif);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand .eyebrow {
|
||||||
|
font-size: 9.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disc {
|
||||||
|
position: relative;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: repeating-radial-gradient(circle, var(--ink) 0 1.4px, color-mix(in oklab, var(--ink) 82%, var(--bg)) 1.4px 2.6px);
|
||||||
|
transition: transform 1.2s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.disc i {
|
||||||
|
position: absolute;
|
||||||
|
inset: 11px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: inset 0 0 0 4px var(--accent), inset 0 0 0 5px var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand:hover .disc {
|
||||||
|
transform: rotate(160deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--ink-2);
|
||||||
|
transition: color var(--dur) var(--ease), background var(--dur) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-no {
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-label {
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
align-self: center;
|
||||||
|
min-width: 20px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-ink);
|
||||||
|
font-size: 10.5px;
|
||||||
|
line-height: 18px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active .nav-no {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
font-size: 12.5px;
|
||||||
|
transition: border-color var(--dur) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock:hover {
|
||||||
|
border-color: var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-head .eyebrow {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock b {
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
animation: pulse 1.6s var(--ease) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse.off {
|
||||||
|
background: var(--ink-3);
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 5px var(--accent-soft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-foot {
|
||||||
|
margin-top: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sources {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.src {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.src .mono {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid currentColor;
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.on.netease {
|
||||||
|
background: var(--netease);
|
||||||
|
border-color: var(--netease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.on.qq {
|
||||||
|
background: var(--qq);
|
||||||
|
border-color: var(--qq);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: var(--ink-3);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-swatch {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
background: linear-gradient(135deg, #f4f1ea 50%, #121315 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-swatch[data-mode='light'] {
|
||||||
|
background: #f4f1ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-swatch[data-mode='dark'] {
|
||||||
|
background: #121315;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-enter-active,
|
||||||
|
.page-leave-active {
|
||||||
|
transition: opacity 150ms var(--ease), transform 150ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side {
|
||||||
|
order: 2;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
padding: 6px 8px calc(6px + env(safe-area-inset-bottom));
|
||||||
|
border-right: 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
background: var(--surface);
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand,
|
||||||
|
.side-foot,
|
||||||
|
.dock {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-label {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { coverThumb } from '../lib/format.ts';
|
||||||
|
|
||||||
|
const props = defineProps<{ src?: string | null; size?: number; round?: boolean; px?: number }>();
|
||||||
|
const failed = ref(false);
|
||||||
|
watch(
|
||||||
|
() => props.src,
|
||||||
|
() => (failed.value = false),
|
||||||
|
);
|
||||||
|
const url = computed(() => (failed.value ? undefined : coverThumb(props.src, props.px ?? (props.size ?? 44) * 2)));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="cover" :class="{ round }" :style="{ width: `${size ?? 44}px`, height: `${size ?? 44}px` }">
|
||||||
|
<img v-if="url" :src="url" alt="" loading="lazy" referrerpolicy="no-referrer" @error="failed = true" />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cover {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 4px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 50%, var(--line-strong) 0 7%, transparent 7.5%),
|
||||||
|
repeating-radial-gradient(circle, var(--surface-2) 0 2px, color-mix(in oklab, var(--surface-2) 70%, var(--line)) 2px 3px);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover.round {
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import type { Source } from '../lib/api.ts';
|
||||||
|
import { SOURCE_LABEL } from '../lib/format.ts';
|
||||||
|
import CoverArt from './CoverArt.vue';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
source: Source;
|
||||||
|
kind: string;
|
||||||
|
title: string;
|
||||||
|
cover?: string;
|
||||||
|
round?: boolean;
|
||||||
|
meta?: Array<string | undefined | false>;
|
||||||
|
description?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const expanded = ref(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="dh" :class="source">
|
||||||
|
<div class="art" :class="{ round }">
|
||||||
|
<CoverArt :src="cover" :size="196" :px="500" :round="round" />
|
||||||
|
<span v-if="!round" class="vinyl" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div class="info">
|
||||||
|
<span class="eyebrow kicker"><i class="dot" />{{ kind }} · {{ SOURCE_LABEL[source] }}</span>
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
<div class="by"><slot name="by" /></div>
|
||||||
|
<div v-if="meta?.some(Boolean)" class="meta mono faint">
|
||||||
|
<template v-for="(m, i) in meta.filter(Boolean)" :key="i">
|
||||||
|
<span v-if="i" class="sep">/</span><span>{{ m }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<p v-if="description" class="desc muted" :class="{ open: expanded }" @click="expanded = !expanded">{{ description }}</p>
|
||||||
|
<div class="actions"><slot name="actions" /></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dh {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 40px;
|
||||||
|
align-items: end;
|
||||||
|
padding: 8px 0 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.art {
|
||||||
|
position: relative;
|
||||||
|
width: 196px;
|
||||||
|
height: 196px;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.art :deep(.cover) {
|
||||||
|
box-shadow: var(--shadow), inset 0 0 0 1px var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vinyl {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
left: 70px;
|
||||||
|
width: 184px;
|
||||||
|
height: 184px;
|
||||||
|
z-index: -1;
|
||||||
|
border-radius: 50%;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle, var(--accent) 0 13%, var(--ink) 13% 14%, transparent 14%),
|
||||||
|
repeating-radial-gradient(circle, var(--ink) 0 1.5px, color-mix(in oklab, var(--ink) 84%, var(--bg)) 1.5px 3px);
|
||||||
|
transition: transform 600ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh:hover .vinyl {
|
||||||
|
transform: translateX(14px) rotate(40deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
min-width: 0;
|
||||||
|
padding-left: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.art.round + .info {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kicker {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--netease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qq .dot {
|
||||||
|
background: var(--qq);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: clamp(26px, 3.4vw, 40px);
|
||||||
|
line-height: 1.15;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.by {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.by :deep(a:hover) {
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sep {
|
||||||
|
color: var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
margin: 14px 0 0;
|
||||||
|
max-width: 640px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: pre-line;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc.open {
|
||||||
|
-webkit-line-clamp: unset;
|
||||||
|
line-clamp: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.dh {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.art {
|
||||||
|
width: 140px;
|
||||||
|
height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.art :deep(.cover) {
|
||||||
|
width: 140px !important;
|
||||||
|
height: 140px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vinyl {
|
||||||
|
width: 130px;
|
||||||
|
height: 130px;
|
||||||
|
left: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// 线性图标,1.6px 描边,跟随 currentColor
|
||||||
|
defineProps<{ name: string; size?: number }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
const paths = {
|
||||||
|
play: '<path d="M8 5.5v13l11-6.5z" fill="currentColor" stroke="none"/>',
|
||||||
|
pause: '<rect x="7" y="5.5" width="3.4" height="13" rx="1" fill="currentColor" stroke="none"/><rect x="13.6" y="5.5" width="3.4" height="13" rx="1" fill="currentColor" stroke="none"/>',
|
||||||
|
download: '<path d="M12 4v11m0 0-4.5-4.5M12 15l4.5-4.5M5 19.5h14"/>',
|
||||||
|
check: '<path d="m5 12.5 4.5 4.5L19 7.5"/>',
|
||||||
|
x: '<path d="M6 6l12 12M18 6 6 18"/>',
|
||||||
|
retry: '<path d="M4.5 12a7.5 7.5 0 1 0 2.2-5.3M4.5 4.5v4h4"/>',
|
||||||
|
folder: '<path d="M3.5 7.5a2 2 0 0 1 2-2h4l2 2h7a2 2 0 0 1 2 2v7.5a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2z"/>',
|
||||||
|
music: '<path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/>',
|
||||||
|
file: '<path d="M7 3.5h7l4 4v13H7z"/><path d="M14 3.5v4h4"/>',
|
||||||
|
trash: '<path d="M5 7h14M10 7V5h4v2M7 7l1 12.5h8L17 7"/>',
|
||||||
|
zip: '<path d="M7 3.5h10v17H7z"/><path d="M12 3.5v2m0 2v2m0 2v2"/><rect x="10.5" y="14" width="3" height="3" rx=".6"/>',
|
||||||
|
list: '<path d="M9 7h11M9 12h11M9 17h11M4.5 7h.01M4.5 12h.01M4.5 17h.01"/>',
|
||||||
|
search: '<circle cx="11" cy="11" r="6.5"/><path d="m20 20-4.4-4.4"/>',
|
||||||
|
chevron: '<path d="m9 6 6 6-6 6"/>',
|
||||||
|
back: '<path d="m15 6-6 6 6 6"/>',
|
||||||
|
tag: '<path d="M4 4h7.5l8.5 8.5-7.5 7.5L4 11.5z"/><circle cx="8.5" cy="8.5" r="1.3"/>',
|
||||||
|
pauseCircle: '<circle cx="12" cy="12" r="8.5"/><path d="M10 9v6M14 9v6"/>',
|
||||||
|
playCircle: '<circle cx="12" cy="12" r="8.5"/><path d="m10.2 8.8 5 3.2-5 3.2z"/>',
|
||||||
|
volume: '<path d="M4.5 9.5h3l4.5-4v13l-4.5-4h-3z"/><path d="M15.5 9a4 4 0 0 1 0 6M18 6.5a7.5 7.5 0 0 1 0 11"/>',
|
||||||
|
link: '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>',
|
||||||
|
user: '<circle cx="12" cy="8.5" r="3.8"/><path d="M4.5 20c1.2-3.8 4.2-5.5 7.5-5.5s6.3 1.7 7.5 5.5"/>',
|
||||||
|
disc: '<circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="2.2"/>',
|
||||||
|
export: '<path d="M12 15V4m0 0L7.5 8.5M12 4l4.5 4.5M5 14v5.5h14V14"/>',
|
||||||
|
plus: '<path d="M12 5v14M5 12h14"/>',
|
||||||
|
logout: '<path d="M14 4.5H6.5v15H14M10.5 12H20m0 0-3.5-3.5M20 12l-3.5 3.5"/>',
|
||||||
|
} as const;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
class="icon"
|
||||||
|
:width="size ?? 18"
|
||||||
|
:height="size ?? 18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.6"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
v-html="paths[name as keyof typeof paths]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { formatSeconds } from '../lib/format.ts';
|
||||||
|
import { currentLyric, player, seek, setVolume, stop, toggle } from '../lib/player.ts';
|
||||||
|
import CoverArt from './CoverArt.vue';
|
||||||
|
import Icon from './Icon.vue';
|
||||||
|
|
||||||
|
const pct = computed(() => (player.duration ? (player.time / player.duration) * 100 : 0));
|
||||||
|
|
||||||
|
function onSeek(e: MouseEvent) {
|
||||||
|
const el = e.currentTarget as HTMLElement;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
seek((e.clientX - rect.left) / rect.width);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Transition name="slide">
|
||||||
|
<div v-if="player.current" class="player">
|
||||||
|
<div class="disc" :class="{ spin: player.playing }">
|
||||||
|
<CoverArt :src="player.current.cover" :size="46" round />
|
||||||
|
<i class="hole" />
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
<div class="t">{{ player.current.title }}</div>
|
||||||
|
<div class="a muted">
|
||||||
|
<Transition name="fade" mode="out-in">
|
||||||
|
<span v-if="currentLyric && player.playing" :key="currentLyric" class="lyric">{{ currentLyric }}</span>
|
||||||
|
<span v-else>{{ player.current.artists }}</span>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="pp" type="button" @click="toggle" :aria-label="player.playing ? '暂停' : '播放'">
|
||||||
|
<Icon :name="player.playing ? 'pause' : 'play'" :size="20" />
|
||||||
|
</button>
|
||||||
|
<div class="timeline">
|
||||||
|
<span class="mono faint">{{ formatSeconds(player.time) }}</span>
|
||||||
|
<div class="track" @click="onSeek">
|
||||||
|
<div class="fill" :style="{ width: `${pct}%` }" />
|
||||||
|
<div class="knob" :style="{ left: `${pct}%` }" />
|
||||||
|
</div>
|
||||||
|
<span class="mono faint">{{ formatSeconds(player.duration) }}</span>
|
||||||
|
</div>
|
||||||
|
<label class="vol" title="音量">
|
||||||
|
<Icon name="volume" :size="17" />
|
||||||
|
<input type="range" min="0" max="1" step="0.01" :value="player.volume" @input="setVolume(Number(($event.target as HTMLInputElement).value))" />
|
||||||
|
</label>
|
||||||
|
<button class="close" type="button" @click="stop" aria-label="关闭"><Icon name="x" :size="16" /></button>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.player {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(120px, 240px) auto 1fr auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 10px 20px 10px 16px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
background: color-mix(in oklab, var(--surface) 94%, transparent);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.disc {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 0 3px var(--ink), var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.disc.spin {
|
||||||
|
animation: spin 7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hole {
|
||||||
|
position: absolute;
|
||||||
|
inset: 19px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg);
|
||||||
|
box-shadow: 0 0 0 2px var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t {
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.a {
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyric {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pp {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--bg);
|
||||||
|
transition: transform var(--dur) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pp:hover {
|
||||||
|
transform: scale(1.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
height: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 6px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 6px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.knob {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
margin-left: -4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--dur) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track:hover .knob {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vol {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vol input {
|
||||||
|
width: 80px;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: none;
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-enter-active,
|
||||||
|
.slide-leave-active {
|
||||||
|
transition: transform 220ms var(--ease), opacity 220ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-enter-from,
|
||||||
|
.slide-leave-to {
|
||||||
|
transform: translateY(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 180ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.player {
|
||||||
|
grid-template-columns: auto 1fr auto auto;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline,
|
||||||
|
.vol {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
import { api, type QQStatus, type QRState } from '../lib/api.ts';
|
||||||
|
import Icon from './Icon.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits<{ close: []; success: [qq: QQStatus] }>();
|
||||||
|
|
||||||
|
const image = ref('');
|
||||||
|
const state = ref<QRState | 'loading'>('loading');
|
||||||
|
const message = ref('');
|
||||||
|
let id = '';
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let alive = true;
|
||||||
|
|
||||||
|
const hint = computed(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
loading: '正在获取二维码…',
|
||||||
|
waiting: '请使用手机 QQ 扫描二维码',
|
||||||
|
scanned: '已扫描,请在手机上确认登录',
|
||||||
|
expired: '二维码已过期',
|
||||||
|
refused: '已在手机上取消登录',
|
||||||
|
done: '登录成功',
|
||||||
|
error: message.value || '登录失败',
|
||||||
|
})[state.value],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
clearTimeout(timer);
|
||||||
|
state.value = 'loading';
|
||||||
|
image.value = '';
|
||||||
|
try {
|
||||||
|
const qr = await api.qqQR();
|
||||||
|
id = qr.id;
|
||||||
|
image.value = qr.image;
|
||||||
|
state.value = 'waiting';
|
||||||
|
poll();
|
||||||
|
} catch (e) {
|
||||||
|
state.value = 'error';
|
||||||
|
message.value = (e as Error).message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function poll() {
|
||||||
|
timer = setTimeout(async () => {
|
||||||
|
if (!alive) return;
|
||||||
|
try {
|
||||||
|
const res = await api.qqQRStatus(id);
|
||||||
|
state.value = res.state;
|
||||||
|
message.value = res.message ?? '';
|
||||||
|
if (res.state === 'done' && res.qq) {
|
||||||
|
emit('success', res.qq);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.state === 'waiting' || res.state === 'scanned') poll();
|
||||||
|
} catch (e) {
|
||||||
|
state.value = 'error';
|
||||||
|
message.value = (e as Error).message;
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(start);
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
alive = false;
|
||||||
|
clearTimeout(timer);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="backdrop" @click.self="emit('close')">
|
||||||
|
<div class="dialog" role="dialog" aria-label="QQ 扫码登录">
|
||||||
|
<button class="x" type="button" aria-label="关闭" @click="emit('close')"><Icon name="x" :size="16" /></button>
|
||||||
|
<span class="eyebrow">QQ 音乐 · 扫码登录</span>
|
||||||
|
<div class="frame" :class="state">
|
||||||
|
<img v-if="image" :src="image" alt="登录二维码" />
|
||||||
|
<div v-if="state !== 'waiting' && state !== 'loading'" class="veil">
|
||||||
|
<Icon v-if="state === 'scanned' || state === 'done'" name="check" :size="34" />
|
||||||
|
<button v-else type="button" class="btn sm" @click="start"><Icon name="retry" :size="14" /> 刷新二维码</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="hint" :class="state">{{ hint }}</p>
|
||||||
|
<p class="faint small">登录后自动获取 refresh_token,凭据会在后台定期续期。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 40;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
background: color-mix(in oklab, var(--ink) 35%, transparent);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
width: 320px;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 26px 26px 22px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.x {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: none;
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.x:hover {
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame {
|
||||||
|
position: relative;
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: inset 0 0 0 1px var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
.veil {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in oklab, var(--surface) 88%, transparent);
|
||||||
|
color: var(--qq);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint.scanned,
|
||||||
|
.hint.done {
|
||||||
|
color: var(--qq);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint.expired,
|
||||||
|
.hint.refused,
|
||||||
|
.hint.error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
margin: -6px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import type { QualityKey, Source } from '../lib/api.ts';
|
||||||
|
import { QUALITY_BADGE, QUALITY_LABEL } from '../lib/format.ts';
|
||||||
|
import { meta } from '../lib/store.ts';
|
||||||
|
|
||||||
|
const props = defineProps<{ source: Source }>();
|
||||||
|
const model = defineModel<QualityKey>({ required: true });
|
||||||
|
|
||||||
|
const options = computed(() => {
|
||||||
|
const supported = meta.value?.sourceQualities[props.source];
|
||||||
|
const all = (meta.value?.qualities.map((q) => q.key) ?? []) as QualityKey[];
|
||||||
|
return (supported?.length ? all.filter((k) => supported.includes(k)) : all).reverse();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<label class="qp" title="下载音质(不可用时自动降级)">
|
||||||
|
<span class="eyebrow">音质</span>
|
||||||
|
<select v-model="model">
|
||||||
|
<option v-for="q in options" :key="q" :value="q">{{ QUALITY_LABEL[q] }} · {{ QUALITY_BADGE[q] }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.qp {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 4px 0 12px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
option {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { toasts } from '../lib/store.ts';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="toasts" aria-live="polite">
|
||||||
|
<TransitionGroup name="toast">
|
||||||
|
<div v-for="t in toasts" :key="t.id" class="toast" :class="t.kind">
|
||||||
|
<i class="mark" />
|
||||||
|
<span>{{ t.text }}</span>
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toasts {
|
||||||
|
position: fixed;
|
||||||
|
right: 20px;
|
||||||
|
top: 20px;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
max-width: 380px;
|
||||||
|
padding: 10px 16px 10px 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
font-size: 13px;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ok .mark {
|
||||||
|
background: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error .mark {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-enter-active,
|
||||||
|
.toast-leave-active {
|
||||||
|
transition: all 220ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.toasts {
|
||||||
|
left: 12px;
|
||||||
|
right: 12px;
|
||||||
|
top: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import type { QualityKey, TrackSummary } from '../lib/api.ts';
|
||||||
|
import { bestQuality, formatDuration, isGold, QUALITY_BADGE, STAGE_LABEL } from '../lib/format.ts';
|
||||||
|
import { isPlaying, player, playOnline } from '../lib/player.ts';
|
||||||
|
import { activeByTrack, defaultQualityFor, download, isDownloaded, progress, trackKey } from '../lib/store.ts';
|
||||||
|
import CoverArt from './CoverArt.vue';
|
||||||
|
import Icon from './Icon.vue';
|
||||||
|
import QualityPicker from './QualityPicker.vue';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
tracks: TrackSummary[];
|
||||||
|
group?: string;
|
||||||
|
showCover?: boolean;
|
||||||
|
showAlbum?: boolean;
|
||||||
|
numbering?: 'index' | 'track';
|
||||||
|
hideToolbar?: boolean;
|
||||||
|
}>(),
|
||||||
|
{ showCover: true, showAlbum: true, numbering: 'index' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const source = computed(() => props.tracks[0]?.source ?? 'netease');
|
||||||
|
const quality = ref<QualityKey>(defaultQualityFor(source.value));
|
||||||
|
watch(source, (s) => (quality.value = defaultQualityFor(s)));
|
||||||
|
|
||||||
|
const selected = ref(new Set<string>());
|
||||||
|
let anchor = -1;
|
||||||
|
watch(
|
||||||
|
() => props.tracks,
|
||||||
|
() => {
|
||||||
|
selected.value = new Set();
|
||||||
|
anchor = -1;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const allSelected = computed(() => props.tracks.length > 0 && selected.value.size === props.tracks.length);
|
||||||
|
const pendingCount = computed(() => props.tracks.filter((t) => !isDownloaded(t)).length);
|
||||||
|
|
||||||
|
function toggleRow(index: number, e: MouseEvent) {
|
||||||
|
const next = new Set(selected.value);
|
||||||
|
const key = props.tracks[index]!.id;
|
||||||
|
if (e.shiftKey && anchor >= 0) {
|
||||||
|
const [a, b] = [Math.min(anchor, index), Math.max(anchor, index)];
|
||||||
|
const value = !next.has(key);
|
||||||
|
for (let i = a; i <= b; i++) value ? next.add(props.tracks[i]!.id) : next.delete(props.tracks[i]!.id);
|
||||||
|
} else {
|
||||||
|
next.has(key) ? next.delete(key) : next.add(key);
|
||||||
|
}
|
||||||
|
anchor = index;
|
||||||
|
selected.value = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll() {
|
||||||
|
selected.value = allSelected.value ? new Set() : new Set(props.tracks.map((t) => t.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPending() {
|
||||||
|
selected.value = new Set(props.tracks.filter((t) => !isDownloaded(t)).map((t) => t.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadSelected() {
|
||||||
|
const items = props.tracks.filter((t) => selected.value.has(t.id));
|
||||||
|
await download(items, quality.value, props.group);
|
||||||
|
selected.value = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadAll() {
|
||||||
|
download(props.tracks, quality.value, props.group);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowState(t: TrackSummary) {
|
||||||
|
const task = activeByTrack.value.get(trackKey(t.source, t.id));
|
||||||
|
if (task) {
|
||||||
|
if (task.status === 'running') {
|
||||||
|
const p = progress.get(task.id);
|
||||||
|
if (p && p.stage === 'download' && p.total) return { kind: 'running', text: `${Math.round((p.received / p.total) * 100)}%`, pct: p.received / p.total };
|
||||||
|
return { kind: 'running', text: STAGE_LABEL[task.stage ?? 'resolve'] ?? '处理中', pct: 0 };
|
||||||
|
}
|
||||||
|
return { kind: task.status, text: task.status === 'paused' ? '已暂停' : '排队中', pct: 0 };
|
||||||
|
}
|
||||||
|
if (isDownloaded(t)) return { kind: 'done', text: '已下载', pct: 1 };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function number(t: TrackSummary, i: number) {
|
||||||
|
return String(props.numbering === 'track' && t.trackNo ? t.trackNo : i + 1).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
const best = (t: TrackSummary) => bestQuality(t.qualities);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tracklist" :class="{ 'no-cover': !showCover }">
|
||||||
|
<div v-if="!hideToolbar" class="toolbar">
|
||||||
|
<button type="button" class="check" :class="{ on: allSelected, part: selected.size && !allSelected }" @click="toggleAll" title="全选">
|
||||||
|
<Icon v-if="allSelected" name="check" :size="13" />
|
||||||
|
</button>
|
||||||
|
<span class="sel muted">
|
||||||
|
<template v-if="selected.size">已选 <b class="mono">{{ selected.size }}</b> 首</template>
|
||||||
|
<template v-else>
|
||||||
|
共 <b class="mono">{{ tracks.length }}</b> 首
|
||||||
|
<button v-if="pendingCount && pendingCount < tracks.length" class="link" type="button" @click="selectPending">
|
||||||
|
· 选择未下载的 {{ pendingCount }} 首
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
<div class="spacer" />
|
||||||
|
<QualityPicker v-model="quality" :source="source" />
|
||||||
|
<button v-if="selected.size" class="btn primary" type="button" @click="downloadSelected">
|
||||||
|
<Icon name="download" :size="16" /> 下载所选
|
||||||
|
</button>
|
||||||
|
<button v-else class="btn" type="button" :disabled="!tracks.length" @click="downloadAll">
|
||||||
|
<Icon name="download" :size="16" /> 全部下载
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol class="rows">
|
||||||
|
<li
|
||||||
|
v-for="(t, i) in tracks"
|
||||||
|
:key="`${t.source}-${t.id}-${i}`"
|
||||||
|
class="row"
|
||||||
|
:class="[t.source, { selected: selected.has(t.id), current: player.current?.key === `${t.source}:${t.id}` }]"
|
||||||
|
@dblclick="playOnline(t)"
|
||||||
|
>
|
||||||
|
<button type="button" class="check" :class="{ on: selected.has(t.id) }" @click="toggleRow(i, $event)" :aria-label="`选择 ${t.name}`">
|
||||||
|
<Icon v-if="selected.has(t.id)" name="check" :size="13" />
|
||||||
|
</button>
|
||||||
|
<span class="num">
|
||||||
|
<span class="mono faint idx">{{ number(t, i) }}</span>
|
||||||
|
<button type="button" class="play" @click="playOnline(t)" :aria-label="`试听 ${t.name}`">
|
||||||
|
<Icon :name="isPlaying(`${t.source}:${t.id}`) ? 'pause' : 'play'" :size="16" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<CoverArt v-if="showCover" :src="t.cover" :size="40" />
|
||||||
|
<div class="title">
|
||||||
|
<div class="name">
|
||||||
|
{{ t.name }}
|
||||||
|
<span v-if="t.vip" class="vip" title="VIP 歌曲">VIP</span>
|
||||||
|
</div>
|
||||||
|
<div class="sub muted">
|
||||||
|
<template v-if="t.artistRefs?.length">
|
||||||
|
<template v-for="(a, ai) in t.artistRefs" :key="a.id">
|
||||||
|
<span v-if="ai" class="faint"> / </span>
|
||||||
|
<RouterLink :to="`/artist/${t.source}/${a.id}`" class="ln">{{ a.name }}</RouterLink>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<template v-else>{{ t.artists }}</template>
|
||||||
|
<template v-if="showAlbum && t.album">
|
||||||
|
<span class="faint"> · </span>
|
||||||
|
<RouterLink v-if="t.albumId" :to="`/album/${t.source}/${t.albumId}`" class="ln faint">{{ t.album }}</RouterLink>
|
||||||
|
<span v-else class="faint">{{ t.album }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="badges">
|
||||||
|
<span v-if="best(t)" class="badge" :class="{ gold: isGold(best(t)) }">{{ QUALITY_BADGE[best(t)!] }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="state">
|
||||||
|
<template v-if="rowState(t)">
|
||||||
|
<span class="st" :class="rowState(t)!.kind">
|
||||||
|
<Icon v-if="rowState(t)!.kind === 'done'" name="check" :size="14" />
|
||||||
|
{{ rowState(t)!.kind === 'done' ? '' : rowState(t)!.text }}
|
||||||
|
</span>
|
||||||
|
<span v-if="rowState(t)!.kind === 'running'" class="bar"><i :style="{ width: `${rowState(t)!.pct * 100}%` }" /></span>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
<span class="dur mono faint">{{ formatDuration(t.duration) }}</span>
|
||||||
|
<button type="button" class="dl" title="下载" @click="download([t], quality, group)">
|
||||||
|
<Icon name="download" :size="17" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 10px 12px 12px;
|
||||||
|
background: color-mix(in oklab, var(--bg) 92%, transparent);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border-bottom: 1.5px solid var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sel {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sel b {
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1.5px solid var(--line-strong);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent-ink);
|
||||||
|
transition: background var(--dur) var(--ease), border-color var(--dur) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check:hover {
|
||||||
|
border-color: var(--ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check.on {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check.part {
|
||||||
|
background: linear-gradient(var(--accent), var(--accent)) center / 8px 2px no-repeat;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 18px 30px auto minmax(0, 1fr) auto 96px 44px 32px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 9px 10px 9px 12px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
transition: background var(--dur) var(--ease), border-color var(--dur) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 13px;
|
||||||
|
bottom: 13px;
|
||||||
|
width: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--netease);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--dur) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.qq::before {
|
||||||
|
background: var(--qq);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover::before,
|
||||||
|
.row.selected::before,
|
||||||
|
.row.current::before {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover {
|
||||||
|
border-bottom-color: var(--line-strong);
|
||||||
|
background: color-mix(in oklab, var(--surface) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.selected {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-cover .row {
|
||||||
|
grid-template-columns: 18px 30px minmax(0, 1fr) auto 96px 44px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idx {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--bg);
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.85);
|
||||||
|
transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover .play,
|
||||||
|
.row.current .play,
|
||||||
|
.play:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.current .play {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 14.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.current .name {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vip {
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font: 600 9px/14px var(--font-mono);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--qq);
|
||||||
|
border: 1px solid color-mix(in oklab, var(--qq) 50%, transparent);
|
||||||
|
vertical-align: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.netease .vip {
|
||||||
|
color: var(--netease);
|
||||||
|
border-color: color-mix(in oklab, var(--netease) 50%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
font-size: 12.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ln:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
text-decoration-color: var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--ink-3);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st.done {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st.running {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
width: 72px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--line);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
transition: width 300ms linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dur {
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink-3);
|
||||||
|
transition: color var(--dur) var(--ease), background var(--dur) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
grid-template-columns: 18px auto minmax(0, 1fr) auto 32px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-cover .row {
|
||||||
|
grid-template-columns: 18px minmax(0, 1fr) auto 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num,
|
||||||
|
.badges,
|
||||||
|
.dur {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state .bar {
|
||||||
|
width: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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<T> {
|
||||||
|
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<Source, QualityKey[]>;
|
||||||
|
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<Record<TaskStatus, number>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
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<string, string | number | undefined>) =>
|
||||||
|
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>('/health'),
|
||||||
|
meta: () => request<Meta>('/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>('/settings'),
|
||||||
|
saveSettings: (patch: Partial<Settings>) => request<Settings>('/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<Paged<TrackSummary>>(`/search?${qs({ source, q, page, limit })}`),
|
||||||
|
resolveLink: (url: string) => request<LinkRef>(`/resolve-link?${qs({ url })}`),
|
||||||
|
track: (source: Source, id: string) => request<TrackMeta>(`/${source}/track/${encodeURIComponent(id)}`),
|
||||||
|
album: (source: Source, id: string) => request<AlbumDetail>(`/${source}/album/${encodeURIComponent(id)}`),
|
||||||
|
playlist: (source: Source, id: string) => request<PlaylistDetail>(`/${source}/playlist/${encodeURIComponent(id)}`),
|
||||||
|
artist: (source: Source, id: string) => request<ArtistDetail>(`/${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<any>(`/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>('/stats'),
|
||||||
|
|
||||||
|
library: (path = '') => request<LibraryListing>(`/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<RetagJob>('/library/retag', json('POST', { path, source, onlyMissingLyric })),
|
||||||
|
};
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import type { QualityKey, Source } from './api.ts';
|
||||||
|
|
||||||
|
export const SOURCE_LABEL: Record<Source, string> = { netease: '网易云', qq: 'QQ 音乐' };
|
||||||
|
|
||||||
|
export const QUALITY_BADGE: Record<QualityKey, string> = {
|
||||||
|
standard: '128K',
|
||||||
|
high: '320K',
|
||||||
|
lossless: 'FLAC',
|
||||||
|
hires: 'HI-RES',
|
||||||
|
master: 'MASTER',
|
||||||
|
spatial: 'SPATIAL',
|
||||||
|
effect: 'EFFECT',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const QUALITY_LABEL: Record<QualityKey, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
resolve: '解析音源',
|
||||||
|
metadata: '获取信息',
|
||||||
|
download: '下载中',
|
||||||
|
lyric: '保存歌词',
|
||||||
|
cover: '获取封面',
|
||||||
|
tag: '写入标签',
|
||||||
|
};
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// 试听播放器:单个 <audio>,支持在线音源(服务端代理)与本地曲库文件,并同步滚动歌词
|
||||||
|
import { computed, reactive, ref } from 'vue';
|
||||||
|
import { api, type Source } from './api.ts';
|
||||||
|
import { toastError } from './store.ts';
|
||||||
|
|
||||||
|
export interface PlayItem {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
artists: string;
|
||||||
|
cover?: string;
|
||||||
|
src: string;
|
||||||
|
/** 在线曲目用于加载歌词 */
|
||||||
|
source?: Source;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LrcLine {
|
||||||
|
t: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const player = reactive({
|
||||||
|
current: null as PlayItem | null,
|
||||||
|
playing: false,
|
||||||
|
loading: false,
|
||||||
|
time: 0,
|
||||||
|
duration: 0,
|
||||||
|
volume: 0.85,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lyric = ref<LrcLine[]>([]);
|
||||||
|
let audio: HTMLAudioElement | null = null;
|
||||||
|
|
||||||
|
function parseLrc(text: string | undefined): LrcLine[] {
|
||||||
|
if (!text) return [];
|
||||||
|
const out: LrcLine[] = [];
|
||||||
|
for (const line of text.split('\n')) {
|
||||||
|
const stamps = [...line.matchAll(/\[(\d+):(\d+(?:\.\d+)?)\]/g)];
|
||||||
|
const content = line.replace(/\[[^\]]*\]/g, '').trim();
|
||||||
|
if (!stamps.length || !content) continue;
|
||||||
|
for (const m of stamps) out.push({ t: Number(m[1]) * 60 + Number(m[2]), text: content });
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => a.t - b.t);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const currentLyric = computed(() => {
|
||||||
|
const lines = lyric.value;
|
||||||
|
let found = '';
|
||||||
|
for (const l of lines) {
|
||||||
|
if (l.t > player.time + 0.2) break;
|
||||||
|
found = l.text;
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
});
|
||||||
|
|
||||||
|
function ensureAudio(): HTMLAudioElement {
|
||||||
|
if (audio) return audio;
|
||||||
|
audio = new Audio();
|
||||||
|
audio.preload = 'auto';
|
||||||
|
audio.volume = player.volume;
|
||||||
|
audio.addEventListener('timeupdate', () => (player.time = audio!.currentTime));
|
||||||
|
audio.addEventListener('durationchange', () => (player.duration = audio!.duration || 0));
|
||||||
|
audio.addEventListener('play', () => (player.playing = true));
|
||||||
|
audio.addEventListener('pause', () => (player.playing = false));
|
||||||
|
audio.addEventListener('waiting', () => (player.loading = true));
|
||||||
|
audio.addEventListener('playing', () => (player.loading = false));
|
||||||
|
audio.addEventListener('ended', () => (player.playing = false));
|
||||||
|
audio.addEventListener('error', () => {
|
||||||
|
player.loading = false;
|
||||||
|
player.playing = false;
|
||||||
|
if (player.current) toastError(new Error('试听失败:音源不可用'));
|
||||||
|
});
|
||||||
|
return audio;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function play(item: PlayItem) {
|
||||||
|
const a = ensureAudio();
|
||||||
|
if (player.current?.key === item.key) {
|
||||||
|
toggle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
player.current = item;
|
||||||
|
player.time = 0;
|
||||||
|
player.duration = 0;
|
||||||
|
player.loading = true;
|
||||||
|
lyric.value = [];
|
||||||
|
a.src = item.src;
|
||||||
|
a.play().catch(() => {});
|
||||||
|
if (item.source && item.id) {
|
||||||
|
api
|
||||||
|
.lyric(item.source, item.id)
|
||||||
|
.then((l) => {
|
||||||
|
if (player.current?.key === item.key) lyric.value = parseLrc(l.lrc);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playOnline(t: { source: Source; id: string; name: string; artists: string; cover?: string }) {
|
||||||
|
play({ key: `${t.source}:${t.id}`, title: t.name, artists: t.artists, cover: t.cover, src: api.streamUrl(t.source, t.id), source: t.source, id: t.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggle() {
|
||||||
|
if (!audio || !player.current) return;
|
||||||
|
if (audio.paused) audio.play().catch(() => {});
|
||||||
|
else audio.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seek(ratio: number) {
|
||||||
|
if (audio && player.duration) audio.currentTime = Math.max(0, Math.min(1, ratio)) * player.duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setVolume(v: number) {
|
||||||
|
player.volume = v;
|
||||||
|
if (audio) audio.volume = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stop() {
|
||||||
|
audio?.pause();
|
||||||
|
if (audio) audio.removeAttribute('src');
|
||||||
|
player.current = null;
|
||||||
|
player.playing = false;
|
||||||
|
lyric.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPlaying(key: string): boolean {
|
||||||
|
return player.current?.key === key && player.playing;
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// 全局状态:元数据、设置、账号、下载队列(经 SSE 实时同步)、提示消息
|
||||||
|
import { computed, reactive, ref } from 'vue';
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
setUnauthorizedHandler,
|
||||||
|
type Meta,
|
||||||
|
type QQStatus,
|
||||||
|
type QualityKey,
|
||||||
|
type QueueSummary,
|
||||||
|
type RetagJob,
|
||||||
|
type Settings,
|
||||||
|
type Source,
|
||||||
|
type Task,
|
||||||
|
type TaskProgress,
|
||||||
|
type TrackSummary,
|
||||||
|
} from './api.ts';
|
||||||
|
|
||||||
|
export const meta = ref<Meta | null>(null);
|
||||||
|
export const settings = ref<Settings | null>(null);
|
||||||
|
export const qqStatus = ref<QQStatus | null>(null);
|
||||||
|
export const ffmpeg = ref<boolean | null>(null);
|
||||||
|
|
||||||
|
export const auth = reactive({ checked: false, required: false, authenticated: true });
|
||||||
|
|
||||||
|
export const tasks = reactive(new Map<number, Task>());
|
||||||
|
export const progress = reactive(new Map<number, TaskProgress>());
|
||||||
|
export const summary = ref<QueueSummary>({ paused: false, concurrency: 2, counts: {} });
|
||||||
|
export const retagJob = ref<RetagJob | null>(null);
|
||||||
|
/** 每次有新的下载历史时递增,供页面刷新 */
|
||||||
|
export const historyTick = ref(0);
|
||||||
|
/** 本次会话内完成下载的曲目 */
|
||||||
|
export const downloadedNow = reactive(new Set<string>());
|
||||||
|
export const connected = ref(false);
|
||||||
|
|
||||||
|
export const trackKey = (source: Source, id: string) => `${source}:${id}`;
|
||||||
|
|
||||||
|
/** 曲目 → 进行中的任务(用于列表行内显示状态) */
|
||||||
|
export const activeByTrack = computed(() => {
|
||||||
|
const map = new Map<string, Task>();
|
||||||
|
for (const t of tasks.values()) {
|
||||||
|
if (t.status === 'queued' || t.status === 'running' || t.status === 'paused') map.set(trackKey(t.source, t.trackId), t);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const activeCount = computed(() => (summary.value.counts.running ?? 0) + (summary.value.counts.queued ?? 0));
|
||||||
|
|
||||||
|
export function isDownloaded(t: TrackSummary): boolean {
|
||||||
|
return !!t.downloaded || downloadedNow.has(trackKey(t.source, t.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 提示 ----------
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: number;
|
||||||
|
text: string;
|
||||||
|
kind: 'info' | 'ok' | 'error';
|
||||||
|
}
|
||||||
|
export const toasts = ref<Toast[]>([]);
|
||||||
|
let toastSeq = 0;
|
||||||
|
export function toast(text: string, kind: Toast['kind'] = 'info', ms = 3200) {
|
||||||
|
const id = ++toastSeq;
|
||||||
|
toasts.value.push({ id, text, kind });
|
||||||
|
setTimeout(() => (toasts.value = toasts.value.filter((t) => t.id !== id)), ms);
|
||||||
|
}
|
||||||
|
export function toastError(e: unknown) {
|
||||||
|
toast(e instanceof Error ? e.message : String(e), 'error', 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 加载 ----------
|
||||||
|
|
||||||
|
export async function loadTasks() {
|
||||||
|
const res = await api.tasks('all');
|
||||||
|
tasks.clear();
|
||||||
|
for (const t of res.tasks) tasks.set(t.id, t);
|
||||||
|
progress.clear();
|
||||||
|
for (const p of res.progress) progress.set(p.id, p);
|
||||||
|
summary.value = res.summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshAccounts(force = false) {
|
||||||
|
try {
|
||||||
|
qqStatus.value = (await api.accounts(force)).qq;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let source: EventSource | null = null;
|
||||||
|
|
||||||
|
function connectEvents() {
|
||||||
|
source?.close();
|
||||||
|
source = new EventSource('/api/events');
|
||||||
|
const on = (event: string, fn: (data: any) => void) =>
|
||||||
|
source!.addEventListener(event, (e) => fn(JSON.parse((e as MessageEvent).data)));
|
||||||
|
|
||||||
|
on('hello', (d) => {
|
||||||
|
connected.value = true;
|
||||||
|
summary.value = d.summary;
|
||||||
|
loadTasks().catch(() => {});
|
||||||
|
});
|
||||||
|
on('task', (t: Task) => {
|
||||||
|
const prev = tasks.get(t.id);
|
||||||
|
tasks.set(t.id, t);
|
||||||
|
if (t.status !== 'running') progress.delete(t.id);
|
||||||
|
if (t.status === 'done' && prev?.status !== 'done') downloadedNow.add(trackKey(t.source, t.trackId));
|
||||||
|
if (t.status === 'failed' && prev?.status !== 'failed') toast(`下载失败:${t.name} — ${t.error}`, 'error', 6000);
|
||||||
|
});
|
||||||
|
on('task-removed', (d: { ids: number[] }) => d.ids.forEach((id) => tasks.delete(id)));
|
||||||
|
on('progress', (p: TaskProgress) => progress.set(p.id, p));
|
||||||
|
on('queue', (s: QueueSummary) => (summary.value = s));
|
||||||
|
on('settings', (s: Settings) => (settings.value = s));
|
||||||
|
on('account', () => refreshAccounts(true));
|
||||||
|
on('history', () => historyTick.value++);
|
||||||
|
on('retag', (j: RetagJob) => (retagJob.value = j));
|
||||||
|
source.onerror = () => (connected.value = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bootstrap() {
|
||||||
|
const status = await api.authStatus();
|
||||||
|
Object.assign(auth, status, { checked: true });
|
||||||
|
if (!status.authenticated) return;
|
||||||
|
const [m, s, h] = await Promise.all([api.meta(), api.settings(), api.health()]);
|
||||||
|
meta.value = m;
|
||||||
|
settings.value = s;
|
||||||
|
ffmpeg.value = h.ffmpeg;
|
||||||
|
connectEvents();
|
||||||
|
refreshAccounts();
|
||||||
|
api.retagJob().then((j) => (retagJob.value = j)).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
setUnauthorizedHandler(() => {
|
||||||
|
auth.authenticated = false;
|
||||||
|
source?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 动作 ----------
|
||||||
|
|
||||||
|
/** 平台支持的最佳音质(不超过默认设置) */
|
||||||
|
export function defaultQualityFor(src: Source): QualityKey {
|
||||||
|
const want = settings.value?.quality ?? 'master';
|
||||||
|
const supported = meta.value?.sourceQualities[src] ?? [];
|
||||||
|
if (!supported.length || supported.includes(want)) return want;
|
||||||
|
const order: QualityKey[] = ['hires', 'lossless', 'high', 'standard'];
|
||||||
|
const start = Math.max(0, order.indexOf(want));
|
||||||
|
return order.slice(start).find((q) => supported.includes(q)) ?? supported[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function download(items: TrackSummary[], quality: QualityKey, group?: string) {
|
||||||
|
if (!items.length) return;
|
||||||
|
try {
|
||||||
|
const { added, duplicates } = await api.enqueue(items, quality, group);
|
||||||
|
const extra = duplicates ? `,${duplicates} 首已在队列中` : '';
|
||||||
|
toast(added ? `已加入下载队列 ${added} 首${extra}` : `全部已在队列中`, added ? 'ok' : 'info');
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
export type ThemeMode = 'auto' | 'light' | 'dark';
|
||||||
|
|
||||||
|
function read(): ThemeMode {
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem('sx-theme');
|
||||||
|
return v === 'light' || v === 'dark' ? v : 'auto';
|
||||||
|
} catch {
|
||||||
|
return 'auto';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const themeMode = ref<ThemeMode>(read());
|
||||||
|
|
||||||
|
watch(themeMode, (mode) => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (mode === 'auto') delete root.dataset.theme;
|
||||||
|
else root.dataset.theme = mode;
|
||||||
|
try {
|
||||||
|
if (mode === 'auto') localStorage.removeItem('sx-theme');
|
||||||
|
else localStorage.setItem('sx-theme', mode);
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export function cycleTheme() {
|
||||||
|
const order: ThemeMode[] = ['auto', 'light', 'dark'];
|
||||||
|
themeMode.value = order[(order.indexOf(themeMode.value) + 1) % order.length]!;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import App from './App.vue';
|
||||||
|
import { router } from './router.ts';
|
||||||
|
import './styles/tokens.css';
|
||||||
|
import './styles/base.css';
|
||||||
|
|
||||||
|
createApp(App).use(router).mount('#app');
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
import SearchView from './views/SearchView.vue';
|
||||||
|
|
||||||
|
export const navItems = [
|
||||||
|
{ path: '/', name: 'search', label: '搜索' },
|
||||||
|
{ path: '/queue', name: 'queue', label: '队列' },
|
||||||
|
{ path: '/library', name: 'library', label: '曲库' },
|
||||||
|
{ path: '/history', name: 'history', label: '历史' },
|
||||||
|
{ path: '/settings', name: 'settings', label: '设置' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', name: 'search', component: SearchView },
|
||||||
|
{ path: '/album/:source/:id', name: 'album', component: () => import('./views/AlbumView.vue'), props: true },
|
||||||
|
{ path: '/playlist/:source/:id', name: 'playlist', component: () => import('./views/PlaylistView.vue'), props: true },
|
||||||
|
{ path: '/artist/:source/:id', name: 'artist', component: () => import('./views/ArtistView.vue'), props: true },
|
||||||
|
{ path: '/queue', name: 'queue', component: () => import('./views/QueueView.vue') },
|
||||||
|
{ path: '/library', name: 'library', component: () => import('./views/LibraryView.vue') },
|
||||||
|
{ path: '/history', name: 'history', component: () => import('./views/HistoryView.vue') },
|
||||||
|
{ path: '/settings', name: 'settings', component: () => import('./views/SettingsView.vue') },
|
||||||
|
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||||
|
],
|
||||||
|
scrollBehavior: () => ({ top: 0 }),
|
||||||
|
});
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
font: 14px/1.6 var(--font-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
transition: background var(--dur) var(--ease), color var(--dur) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 纸张颗粒 */
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 100;
|
||||||
|
opacity: var(--grain-opacity);
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-feature-settings: 'tnum';
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faint {
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
font: 500 11px/1 var(--font-mono);
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 描边小字徽章;.gold 用于 Hi-Res / 母带 */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 4px;
|
||||||
|
font: 600 10px/1 var(--font-mono);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--ink-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.gold {
|
||||||
|
border-color: color-mix(in oklab, var(--gold) 55%, transparent);
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
transition: border-color var(--dur) var(--ease), background var(--dur) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
border-color: var(--ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary:hover {
|
||||||
|
background: color-mix(in oklab, var(--accent) 88%, var(--ink));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid color-mix(in oklab, var(--accent) 60%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--line-strong);
|
||||||
|
border: 3px solid var(--bg);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* {
|
||||||
|
transition: none !important;
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 页面通用骨架 */
|
||||||
|
.page {
|
||||||
|
max-width: 1080px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 40px 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head h2 {
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head .row-between {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
font: 400 14px var(--font-mono);
|
||||||
|
color: var(--ink-3);
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.ghost {
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.ghost:hover {
|
||||||
|
border-color: var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.sm {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.danger:hover {
|
||||||
|
border-color: var(--danger);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--dur) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
border-color: var(--ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 3px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg button {
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: none;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
transition: background var(--dur) var(--ease), color var(--dur) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg button:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg button[aria-selected='true'] {
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-msg {
|
||||||
|
padding: 80px 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
height: 56px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: linear-gradient(90deg, transparent, var(--surface-2), transparent) 0 0 / 50% 100% no-repeat;
|
||||||
|
animation: shimmer 1.4s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
from {
|
||||||
|
background-position: -50% 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background-position: 150% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.page {
|
||||||
|
padding: 20px 16px 120px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/* 声匣 · 设计 token —— 「唱片内页」:纸与墨,赤陶点睛,来源色只做细线 */
|
||||||
|
:root {
|
||||||
|
--bg: #f4f1ea;
|
||||||
|
--surface: #fbf9f4;
|
||||||
|
--surface-2: #efebe2;
|
||||||
|
--line: #e3ded3;
|
||||||
|
--line-strong: #cfc8ba;
|
||||||
|
--ink: #1f1d1a;
|
||||||
|
--ink-2: #6b655b;
|
||||||
|
--ink-3: #a39c8f;
|
||||||
|
--accent: #b5523b;
|
||||||
|
--accent-ink: #fbf9f4;
|
||||||
|
--accent-soft: color-mix(in oklab, var(--accent) 12%, transparent);
|
||||||
|
--netease: #c4473a;
|
||||||
|
--qq: #2f8f6b;
|
||||||
|
--gold: #a8812c;
|
||||||
|
--ok: #4f7a52;
|
||||||
|
--danger: #b3412f;
|
||||||
|
--shadow: 0 1px 0 rgba(31, 29, 26, 0.04), 0 8px 24px -12px rgba(31, 29, 26, 0.18);
|
||||||
|
--grain-opacity: 0.035;
|
||||||
|
|
||||||
|
--font-serif: 'Noto Serif SC', 'Source Han Serif SC', 'Songti SC', 'STSong', serif;
|
||||||
|
--font-sans: 'Inter', 'HarmonyOS Sans SC', 'PingFang SC', 'Microsoft YaHei UI', 'Microsoft YaHei', system-ui, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Cascadia Mono', 'SF Mono', ui-monospace, Consolas, monospace;
|
||||||
|
|
||||||
|
--radius: 10px;
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--ease: cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||||
|
--dur: 180ms;
|
||||||
|
--sidebar-w: 216px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme='light']) {
|
||||||
|
--bg: #121315;
|
||||||
|
--surface: #1a1b1e;
|
||||||
|
--surface-2: #202125;
|
||||||
|
--line: #2a2b2f;
|
||||||
|
--line-strong: #3a3b40;
|
||||||
|
--ink: #ece8df;
|
||||||
|
--ink-2: #9a958b;
|
||||||
|
--ink-3: #5e5a53;
|
||||||
|
--accent: #e07a5f;
|
||||||
|
--accent-ink: #121315;
|
||||||
|
--netease: #e06456;
|
||||||
|
--qq: #42a07d;
|
||||||
|
--gold: #d9b45a;
|
||||||
|
--ok: #7fae82;
|
||||||
|
--danger: #e0715f;
|
||||||
|
--shadow: 0 1px 0 rgba(0, 0, 0, 0.3), 0 12px 32px -16px rgba(0, 0, 0, 0.6);
|
||||||
|
--grain-opacity: 0.05;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] {
|
||||||
|
--bg: #121315;
|
||||||
|
--surface: #1a1b1e;
|
||||||
|
--surface-2: #202125;
|
||||||
|
--line: #2a2b2f;
|
||||||
|
--line-strong: #3a3b40;
|
||||||
|
--ink: #ece8df;
|
||||||
|
--ink-2: #9a958b;
|
||||||
|
--ink-3: #5e5a53;
|
||||||
|
--accent: #e07a5f;
|
||||||
|
--accent-ink: #121315;
|
||||||
|
--netease: #e06456;
|
||||||
|
--qq: #42a07d;
|
||||||
|
--gold: #d9b45a;
|
||||||
|
--ok: #7fae82;
|
||||||
|
--danger: #e0715f;
|
||||||
|
--shadow: 0 1px 0 rgba(0, 0, 0, 0.3), 0 12px 32px -16px rgba(0, 0, 0, 0.6);
|
||||||
|
--grain-opacity: 0.05;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import DetailHeader from '../components/DetailHeader.vue';
|
||||||
|
import Icon from '../components/Icon.vue';
|
||||||
|
import TrackList from '../components/TrackList.vue';
|
||||||
|
import { api, type AlbumDetail, type Source } from '../lib/api.ts';
|
||||||
|
import { formatDate } from '../lib/format.ts';
|
||||||
|
import { playOnline } from '../lib/player.ts';
|
||||||
|
|
||||||
|
const props = defineProps<{ source: Source; id: string }>();
|
||||||
|
const album = ref<AlbumDetail | null>(null);
|
||||||
|
const error = ref('');
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
album.value = await api.album(props.source, props.id);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = (e as Error).message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const tracks = computed(() => (album.value?.tracks ?? []).map((t) => ({ ...t, cover: t.cover || album.value?.cover })));
|
||||||
|
|
||||||
|
function playFirst() {
|
||||||
|
const t = album.value?.tracks[0];
|
||||||
|
if (t) playOnline({ ...t, cover: t.cover || album.value?.cover });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page">
|
||||||
|
<button class="back btn ghost sm" type="button" @click="$router.back()"><Icon name="back" :size="15" /> 返回</button>
|
||||||
|
<p v-if="error" class="state-msg">{{ error }}</p>
|
||||||
|
<template v-else-if="album">
|
||||||
|
<DetailHeader
|
||||||
|
:source="album.source"
|
||||||
|
:kind="album.type || '专辑'"
|
||||||
|
:title="album.name"
|
||||||
|
:cover="album.cover"
|
||||||
|
:meta="[formatDate(album.publishTime), album.company, `${album.tracks.length} 首`]"
|
||||||
|
:description="album.description"
|
||||||
|
>
|
||||||
|
<template #by>
|
||||||
|
<template v-if="album.artistRefs?.length">
|
||||||
|
<template v-for="(a, i) in album.artistRefs" :key="a.id">
|
||||||
|
<span v-if="i" class="faint"> / </span>
|
||||||
|
<RouterLink :to="`/artist/${album.source}/${a.id}`">{{ a.name }}</RouterLink>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<span v-else>{{ album.artist }}</span>
|
||||||
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<button class="btn" type="button" @click="playFirst"><Icon name="play" :size="14" /> 试听</button>
|
||||||
|
</template>
|
||||||
|
</DetailHeader>
|
||||||
|
<TrackList :tracks="tracks" :show-cover="false" :show-album="false" numbering="track" :group="`专辑 · ${album.name}`" />
|
||||||
|
</template>
|
||||||
|
<div v-else class="loading"><div v-for="i in 8" :key="i" class="skeleton" /></div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.back {
|
||||||
|
margin: -16px 0 14px -10px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import CoverArt from '../components/CoverArt.vue';
|
||||||
|
import DetailHeader from '../components/DetailHeader.vue';
|
||||||
|
import Icon from '../components/Icon.vue';
|
||||||
|
import TrackList from '../components/TrackList.vue';
|
||||||
|
import { api, type ArtistDetail, type Source } from '../lib/api.ts';
|
||||||
|
import { formatDate } from '../lib/format.ts';
|
||||||
|
|
||||||
|
const props = defineProps<{ source: Source; id: string }>();
|
||||||
|
const artist = ref<ArtistDetail | null>(null);
|
||||||
|
const error = ref('');
|
||||||
|
const tab = ref<'tracks' | 'albums'>('tracks');
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
artist.value = await api.artist(props.source, props.id);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = (e as Error).message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page">
|
||||||
|
<button class="back btn ghost sm" type="button" @click="$router.back()"><Icon name="back" :size="15" /> 返回</button>
|
||||||
|
<p v-if="error" class="state-msg">{{ error }}</p>
|
||||||
|
<template v-else-if="artist">
|
||||||
|
<DetailHeader
|
||||||
|
:source="artist.source"
|
||||||
|
kind="歌手"
|
||||||
|
:title="artist.name"
|
||||||
|
:cover="artist.avatar"
|
||||||
|
round
|
||||||
|
:meta="[`热门 ${artist.tracks.length} 首`, `专辑 ${artist.albums.length} 张`]"
|
||||||
|
:description="artist.description"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<div class="seg" role="tablist">
|
||||||
|
<button type="button" role="tab" :aria-selected="tab === 'tracks'" @click="tab = 'tracks'">热门歌曲</button>
|
||||||
|
<button type="button" role="tab" :aria-selected="tab === 'albums'" @click="tab = 'albums'">专辑</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</DetailHeader>
|
||||||
|
|
||||||
|
<TrackList v-if="tab === 'tracks'" :tracks="artist.tracks" :group="`歌手 · ${artist.name}`" />
|
||||||
|
|
||||||
|
<div v-else class="albums">
|
||||||
|
<RouterLink v-for="a in artist.albums" :key="a.id" :to="`/album/${a.source}/${a.id}`" class="album">
|
||||||
|
<div class="sleeve">
|
||||||
|
<CoverArt :src="a.cover" :size="168" :px="300" />
|
||||||
|
</div>
|
||||||
|
<div class="a-name">{{ a.name }}</div>
|
||||||
|
<div class="a-meta mono faint">
|
||||||
|
{{ formatDate(a.publishTime).slice(0, 4) }}<template v-if="a.size"> · {{ a.size }} 首</template><template v-if="a.type"> · {{ a.type }}</template>
|
||||||
|
</div>
|
||||||
|
</RouterLink>
|
||||||
|
<p v-if="!artist.albums.length" class="state-msg">暂无专辑</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="loading"><div v-for="i in 8" :key="i" class="skeleton" /></div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.back {
|
||||||
|
margin: -16px 0 14px -10px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.albums {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
||||||
|
gap: 30px 22px;
|
||||||
|
padding-top: 18px;
|
||||||
|
border-top: 1.5px solid var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.album {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sleeve {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
transition: transform 260ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sleeve :deep(.cover) {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
box-shadow: var(--shadow), inset 0 0 0 1px var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.album:hover .sleeve {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.a-name {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.a-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
import Icon from '../components/Icon.vue';
|
||||||
|
import { api, type HistoryEntry, type Source, type Stats } from '../lib/api.ts';
|
||||||
|
import { formatDateTime, formatSize, isGold, QUALITY_BADGE, QUALITY_LABEL, SOURCE_LABEL } from '../lib/format.ts';
|
||||||
|
import { historyTick } from '../lib/store.ts';
|
||||||
|
|
||||||
|
const items = ref<HistoryEntry[]>([]);
|
||||||
|
const total = ref(0);
|
||||||
|
const loading = ref(true);
|
||||||
|
const error = ref('');
|
||||||
|
const stats = ref<Stats | null>(null);
|
||||||
|
const q = ref('');
|
||||||
|
const source = ref<'' | Source>('');
|
||||||
|
|
||||||
|
const hasMore = computed(() => items.value.length < total.value);
|
||||||
|
|
||||||
|
async function load(reset = false) {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await api.history({ limit: 50, offset: reset ? 0 : items.value.length, q: q.value.trim(), source: source.value });
|
||||||
|
items.value = reset ? res.items : [...items.value, ...res.items];
|
||||||
|
total.value = res.total;
|
||||||
|
} catch (e) {
|
||||||
|
error.value = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
stats.value = await api.stats().catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
load(true);
|
||||||
|
loadStats();
|
||||||
|
});
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
watch([q, source], () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => load(true), 250);
|
||||||
|
});
|
||||||
|
watch(historyTick, () => {
|
||||||
|
load(true);
|
||||||
|
loadStats();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 统计 ----
|
||||||
|
|
||||||
|
/** 近 30 天每日下载数(补齐没有下载的日期) */
|
||||||
|
const days = computed(() => {
|
||||||
|
const map = new Map(stats.value?.daily.map((d) => [d.key, d.count]) ?? []);
|
||||||
|
const out: Array<{ key: string; count: number }> = [];
|
||||||
|
for (let i = 29; i >= 0; i--) {
|
||||||
|
const key = new Date(Date.now() - i * 86400000).toISOString().slice(0, 10);
|
||||||
|
out.push({ key, count: map.get(key) ?? 0 });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
const dayMax = computed(() => Math.max(1, ...days.value.map((d) => d.count)));
|
||||||
|
const last30 = computed(() => days.value.reduce((s, d) => s + d.count, 0));
|
||||||
|
const hoverDay = ref<{ key: string; count: number; x: number } | null>(null);
|
||||||
|
|
||||||
|
const sources = computed(() => {
|
||||||
|
const all = stats.value?.bySource ?? [];
|
||||||
|
const sum = all.reduce((s, x) => s + x.count, 0) || 1;
|
||||||
|
return all.map((x) => ({ ...x, pct: (x.count / sum) * 100 }));
|
||||||
|
});
|
||||||
|
const sourceLabel = (key: string) => SOURCE_LABEL[key as Source] ?? key;
|
||||||
|
const sourcesAria = computed(() => sources.value.map((s) => `${sourceLabel(s.key)} ${s.count} 首`).join(','));
|
||||||
|
|
||||||
|
const qualities = computed(() => {
|
||||||
|
const all = stats.value?.byQuality ?? [];
|
||||||
|
const max = Math.max(1, ...all.map((x) => x.count));
|
||||||
|
return all.map((x) => ({ ...x, pct: (x.count / max) * 100 }));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page">
|
||||||
|
<header class="page-head">
|
||||||
|
<span class="eyebrow">04 · History</span>
|
||||||
|
<div class="row-between">
|
||||||
|
<h2>下载历史</h2>
|
||||||
|
<div class="exports">
|
||||||
|
<a class="btn sm" :href="api.historyExportUrl('csv')"><Icon name="export" :size="14" /> CSV</a>
|
||||||
|
<a class="btn sm" :href="api.historyExportUrl('json')"><Icon name="export" :size="14" /> JSON</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="stats" class="stats">
|
||||||
|
<div class="tile">
|
||||||
|
<span class="eyebrow">累计</span>
|
||||||
|
<span class="big mono">{{ stats.count.toLocaleString() }}</span>
|
||||||
|
<span class="faint small">首歌曲</span>
|
||||||
|
</div>
|
||||||
|
<div class="tile">
|
||||||
|
<span class="eyebrow">占用</span>
|
||||||
|
<span class="big mono">{{ formatSize(stats.size) }}</span>
|
||||||
|
<span class="faint small">按下载时文件大小</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<figure class="tile wide chart" aria-label="近 30 天每日下载数">
|
||||||
|
<figcaption>
|
||||||
|
<span class="eyebrow">近 30 天</span>
|
||||||
|
<span class="small muted"><b class="mono">{{ last30 }}</b> 首</span>
|
||||||
|
</figcaption>
|
||||||
|
<div class="bars" @mouseleave="hoverDay = null">
|
||||||
|
<div
|
||||||
|
v-for="(d, i) in days"
|
||||||
|
:key="d.key"
|
||||||
|
class="col"
|
||||||
|
@mouseenter="hoverDay = { ...d, x: (i + 0.5) / days.length }"
|
||||||
|
>
|
||||||
|
<i :style="{ height: d.count ? `${Math.max(4, (d.count / dayMax) * 100)}%` : '0' }" :class="{ hot: hoverDay?.key === d.key }" />
|
||||||
|
</div>
|
||||||
|
<div v-if="hoverDay" class="tip" :style="{ left: `${hoverDay.x * 100}%` }">
|
||||||
|
<span class="mono">{{ hoverDay.key.slice(5) }}</span> · {{ hoverDay.count }} 首
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="axis mono faint"><span>{{ days[0]?.key.slice(5) }}</span><span>今天</span></div>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<div class="tile wide">
|
||||||
|
<span class="eyebrow">来源</span>
|
||||||
|
<div class="meter" role="img" :aria-label="sourcesAria">
|
||||||
|
<i v-for="s in sources" :key="s.key" :class="s.key" :style="{ width: `${s.pct}%` }" :title="`${s.count} 首`" />
|
||||||
|
</div>
|
||||||
|
<div class="legend small">
|
||||||
|
<span v-for="s in sources" :key="s.key"><i class="sw" :class="s.key" />{{ sourceLabel(s.key) }} <b class="mono">{{ s.count }}</b> <span class="faint mono">{{ Math.round(s.pct) }}%</span></span>
|
||||||
|
</div>
|
||||||
|
<span class="eyebrow q-title">音质</span>
|
||||||
|
<ul class="qlist small">
|
||||||
|
<li v-for="x in qualities" :key="x.key">
|
||||||
|
<span class="ql">{{ QUALITY_LABEL[x.key as keyof typeof QUALITY_LABEL] ?? '未知' }}</span>
|
||||||
|
<span class="qbar"><i :style="{ width: `${x.pct}%` }" :class="{ gold: isGold(x.key) }" /></span>
|
||||||
|
<span class="mono muted qn">{{ x.count }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<input v-model="q" class="input" type="search" placeholder="搜索歌名、歌手、专辑…" />
|
||||||
|
<div class="seg" role="tablist">
|
||||||
|
<button type="button" role="tab" :aria-selected="source === ''" @click="source = ''">全部</button>
|
||||||
|
<button type="button" role="tab" :aria-selected="source === 'netease'" @click="source = 'netease'">网易云</button>
|
||||||
|
<button type="button" role="tab" :aria-selected="source === 'qq'" @click="source = 'qq'">QQ 音乐</button>
|
||||||
|
</div>
|
||||||
|
<span class="faint mono small">{{ total }} 条</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="error-text">{{ error }}</p>
|
||||||
|
|
||||||
|
<ol v-if="items.length" class="list">
|
||||||
|
<li v-for="h in items" :key="h.id" class="row" :class="h.source">
|
||||||
|
<time class="mono faint">{{ formatDateTime(h.createdAt) }}</time>
|
||||||
|
<div class="title">
|
||||||
|
<div class="name">{{ h.name }}</div>
|
||||||
|
<div class="sub muted">{{ h.artists }}<span v-if="h.album" class="faint"> · {{ h.album }}</span></div>
|
||||||
|
</div>
|
||||||
|
<span v-if="h.quality" class="badge" :class="{ gold: isGold(h.quality) }">
|
||||||
|
{{ QUALITY_BADGE[h.quality] ?? h.quality.toUpperCase() }}
|
||||||
|
</span>
|
||||||
|
<span v-else />
|
||||||
|
<span class="mono faint size">{{ formatSize(h.size) }}</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<div v-else-if="!loading" class="state-msg">没有记录</div>
|
||||||
|
|
||||||
|
<div class="more">
|
||||||
|
<button v-if="hasMore" class="btn" :disabled="loading" @click="load()">{{ loading ? '加载中…' : '加载更多' }}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.exports {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 2fr 2fr;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big {
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.2;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
figcaption {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
figcaption b {
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bars {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
height: 74px;
|
||||||
|
margin-top: 6px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.col {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col i {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 3px 3px 0 0;
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: 0.78;
|
||||||
|
transition: opacity var(--dur) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.col i.hot {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 4px);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 3px 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
font-size: 11.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.axis {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
height: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.netease {
|
||||||
|
background: var(--netease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qq {
|
||||||
|
background: var(--qq);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 14px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend > span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend b {
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-title {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qlist {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qlist li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 64px 1fr 40px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql {
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbar {
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbar i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qbar i.gold {
|
||||||
|
background: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qn {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding-bottom: 14px;
|
||||||
|
border-bottom: 1.5px solid var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters .input {
|
||||||
|
width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters .small {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 96px 1fr auto 72px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 12px 4px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row time {
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
font-size: 12.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size {
|
||||||
|
font-size: 11.5px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.stats {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wide {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.filters {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters .input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row time,
|
||||||
|
.size {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,566 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import Icon from '../components/Icon.vue';
|
||||||
|
import { api, type LibraryEntry, type LibraryListing, type Source } from '../lib/api.ts';
|
||||||
|
import { formatSize } from '../lib/format.ts';
|
||||||
|
import { isPlaying, play } from '../lib/player.ts';
|
||||||
|
import { historyTick, retagJob, toast, toastError } from '../lib/store.ts';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const listing = ref<LibraryListing | null>(null);
|
||||||
|
const error = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
const showOther = ref(false);
|
||||||
|
const retagOpen = ref(false);
|
||||||
|
const retagSource = ref<Source>('netease');
|
||||||
|
const retagMissingOnly = ref(true);
|
||||||
|
const trash = ref<{ count: number; size: number } | null>(null);
|
||||||
|
|
||||||
|
const path = computed(() => String(route.query.path ?? ''));
|
||||||
|
const crumbs = computed(() => {
|
||||||
|
const parts = path.value ? path.value.split('/') : [];
|
||||||
|
return parts.map((name, i) => ({ name, path: parts.slice(0, i + 1).join('/') }));
|
||||||
|
});
|
||||||
|
const dirs = computed(() => listing.value?.entries.filter((e) => e.type === 'dir') ?? []);
|
||||||
|
const audio = computed(() => listing.value?.entries.filter((e) => e.type === 'audio') ?? []);
|
||||||
|
const other = computed(() => listing.value?.entries.filter((e) => e.type === 'other') ?? []);
|
||||||
|
const totalSize = computed(() => audio.value.reduce((s, e) => s + e.size, 0));
|
||||||
|
const folderName = computed(() => crumbs.value.at(-1)?.name ?? '音乐目录');
|
||||||
|
const artistName = computed(() => (crumbs.value.length >= 2 ? crumbs.value.at(-2)!.name : ''));
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
listing.value = await api.library(path.value);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = (e as Error).message;
|
||||||
|
listing.value = null;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
if (!path.value) api.trashInfo().then((t) => (trash.value = t)).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
watch(path, load);
|
||||||
|
watch(historyTick, () => !loading.value && load());
|
||||||
|
watch(
|
||||||
|
() => retagJob.value?.finished,
|
||||||
|
(finished) => finished && load(),
|
||||||
|
);
|
||||||
|
|
||||||
|
function go(p: string) {
|
||||||
|
router.push({ path: '/library', query: p ? { path: p } : {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function stripExt(name: string) {
|
||||||
|
return name.replace(/\.[^.]+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function playFile(e: LibraryEntry) {
|
||||||
|
play({
|
||||||
|
key: `file:${e.path}`,
|
||||||
|
title: stripExt(e.name).replace(/^\d+\.\s*/, ''),
|
||||||
|
artists: artistName.value || folderName.value,
|
||||||
|
cover: listing.value?.hasCover ? api.libraryFileUrl(`${path.value}/cover.jpg`) : undefined,
|
||||||
|
src: api.libraryFileUrl(e.path),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function m3u() {
|
||||||
|
try {
|
||||||
|
const { file } = await api.writeM3u(path.value);
|
||||||
|
toast(`已生成 ${file}`, 'ok');
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trashPath(p: string, label: string) {
|
||||||
|
if (!confirm(`将「${label}」移入回收站?\n(可在数据目录 .trash 中找回)`)) return;
|
||||||
|
try {
|
||||||
|
await api.trash(p);
|
||||||
|
toast('已移入回收站');
|
||||||
|
if (p === path.value) go(crumbs.value.slice(0, -1).map((c) => c.name).join('/'));
|
||||||
|
else load();
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRetag() {
|
||||||
|
try {
|
||||||
|
retagJob.value = await api.retag(path.value, retagSource.value, retagMissingOnly.value);
|
||||||
|
retagOpen.value = false;
|
||||||
|
if (!retagJob.value.total) toast('没有需要补全的文件');
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emptyTrash() {
|
||||||
|
if (!confirm('永久删除回收站中的所有文件?此操作不可撤销。')) return;
|
||||||
|
await api.emptyTrash().catch(toastError);
|
||||||
|
trash.value = { count: 0, size: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobPct = computed(() => (retagJob.value?.total ? (retagJob.value.done / retagJob.value.total) * 100 : 0));
|
||||||
|
const jobFailures = computed(() => retagJob.value?.results.filter((r) => !r.ok) ?? []);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page">
|
||||||
|
<header class="page-head">
|
||||||
|
<span class="eyebrow">03 · Library</span>
|
||||||
|
<nav class="crumbs">
|
||||||
|
<button type="button" class="crumb" :class="{ cur: !crumbs.length }" @click="go('')">音乐目录</button>
|
||||||
|
<template v-for="(c, i) in crumbs" :key="c.path">
|
||||||
|
<Icon name="chevron" :size="14" class="faint" />
|
||||||
|
<button type="button" class="crumb" :class="{ cur: i === crumbs.length - 1 }" @click="go(c.path)">{{ c.name }}</button>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
|
<div class="row-between">
|
||||||
|
<h2>
|
||||||
|
{{ folderName }}
|
||||||
|
<span class="count">
|
||||||
|
<template v-if="dirs.length">{{ dirs.length }} 个文件夹</template>
|
||||||
|
<template v-if="dirs.length && audio.length"> · </template>
|
||||||
|
<template v-if="audio.length">{{ audio.length }} 首 · {{ formatSize(totalSize) }}</template>
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div class="tools">
|
||||||
|
<button class="btn sm" type="button" title="补全标签与歌词" @click="retagOpen = !retagOpen"><Icon name="tag" :size="15" /> 补全标签</button>
|
||||||
|
<template v-if="path">
|
||||||
|
<button class="btn sm" type="button" title="生成 M3U 播放列表" @click="m3u"><Icon name="list" :size="15" /> M3U</button>
|
||||||
|
<a class="btn sm" :href="api.libraryZipUrl(path)" title="打包下载"><Icon name="zip" :size="15" /> ZIP</a>
|
||||||
|
<button class="btn sm danger" type="button" title="移入回收站" @click="trashPath(path, folderName)"><Icon name="trash" :size="15" /></button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Transition name="fold">
|
||||||
|
<div v-if="retagOpen" class="panel">
|
||||||
|
<div>
|
||||||
|
<div class="panel-title">补全标签</div>
|
||||||
|
<p class="faint panel-desc">按「歌手/专辑/序号. 歌名」或「歌手 - 歌名」识别文件,搜索匹配后写入封面、标签与歌词。范围:{{ path || '整个音乐目录' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="panel-row">
|
||||||
|
<div class="seg" role="tablist">
|
||||||
|
<button type="button" role="tab" :aria-selected="retagSource === 'netease'" @click="retagSource = 'netease'">网易云</button>
|
||||||
|
<button type="button" role="tab" :aria-selected="retagSource === 'qq'" @click="retagSource = 'qq'">QQ 音乐</button>
|
||||||
|
</div>
|
||||||
|
<label class="chk"><input v-model="retagMissingOnly" type="checkbox" /> 只处理缺少歌词的文件</label>
|
||||||
|
<div class="spacer" />
|
||||||
|
<button class="btn sm ghost" type="button" @click="retagOpen = false">取消</button>
|
||||||
|
<button class="btn sm primary" type="button" :disabled="!!retagJob && !retagJob.finished" @click="startRetag">开始</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<div v-if="retagJob && (!retagJob.finished || retagJob.results.length)" class="job">
|
||||||
|
<div class="job-head">
|
||||||
|
<span>
|
||||||
|
<b>{{ retagJob.finished ? '补全完成' : '正在补全标签' }}</b>
|
||||||
|
<span class="faint"> · {{ retagJob.path || '音乐目录' }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="mono faint">{{ retagJob.done }}/{{ retagJob.total }}<template v-if="jobFailures.length"> · 失败 {{ jobFailures.length }}</template></span>
|
||||||
|
</div>
|
||||||
|
<span class="bar"><i :style="{ width: `${jobPct}%` }" /></span>
|
||||||
|
<div v-if="retagJob.current" class="faint mono small">{{ retagJob.current }}</div>
|
||||||
|
<details v-if="retagJob.results.length" class="small">
|
||||||
|
<summary class="faint">查看明细</summary>
|
||||||
|
<ul>
|
||||||
|
<li v-for="r in retagJob.results" :key="r.file" :class="{ bad: !r.ok }">
|
||||||
|
<span class="mono">{{ r.file.split('/').pop() }}</span> → {{ r.ok ? r.match : r.error }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="state-msg">{{ error }}</p>
|
||||||
|
|
||||||
|
<div v-if="dirs.length" class="grid">
|
||||||
|
<button v-for="d in dirs" :key="d.path" type="button" class="tile" @click="go(d.path)">
|
||||||
|
<span class="art">
|
||||||
|
<img v-if="d.cover" :src="api.libraryFileUrl(d.cover)" alt="" loading="lazy" />
|
||||||
|
<span v-else class="glyph"><Icon name="folder" :size="30" /></span>
|
||||||
|
</span>
|
||||||
|
<span class="tile-name">{{ d.name }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol v-if="audio.length" class="files">
|
||||||
|
<li v-for="f in audio" :key="f.path" class="file" :class="{ current: isPlaying(`file:${f.path}`) }">
|
||||||
|
<button type="button" class="play" @click="playFile(f)" :aria-label="`播放 ${f.name}`">
|
||||||
|
<Icon :name="isPlaying(`file:${f.path}`) ? 'pause' : 'play'" :size="14" />
|
||||||
|
</button>
|
||||||
|
<span class="fname">{{ stripExt(f.name) }}</span>
|
||||||
|
<span class="ext mono">{{ f.name.split('.').pop()?.toUpperCase() }}</span>
|
||||||
|
<span class="lrc mono" :class="{ on: f.hasLyric }" :title="f.hasLyric ? '有歌词' : '缺少歌词'">LRC</span>
|
||||||
|
<span class="size mono faint">{{ formatSize(f.size) }}</span>
|
||||||
|
<button type="button" class="del" title="移入回收站" @click="trashPath(f.path, f.name)"><Icon name="trash" :size="15" /></button>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div v-if="other.length" class="other">
|
||||||
|
<button type="button" class="link faint" @click="showOther = !showOther">其他文件 {{ other.length }} 个 {{ showOther ? '▴' : '▾' }}</button>
|
||||||
|
<ul v-if="showOther">
|
||||||
|
<li v-for="o in other" :key="o.path" class="faint mono">
|
||||||
|
<a :href="api.libraryFileUrl(o.path)" target="_blank">{{ o.name }}</a> <span>{{ formatSize(o.size) }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="listing && !listing.entries.length" class="state-msg">空文件夹</p>
|
||||||
|
|
||||||
|
<footer v-if="!path && trash" class="trash faint">
|
||||||
|
<Icon name="trash" :size="14" /> 回收站 {{ trash.count }} 个文件 · {{ formatSize(trash.size) }}
|
||||||
|
<button v-if="trash.count" class="link" type="button" @click="emptyTrash">清空</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.crumbs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb {
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
padding: 2px 4px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb.cur {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font: 600 15px var(--font-serif);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-desc {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chk {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chk input {
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold-enter-active,
|
||||||
|
.fold-leave-active {
|
||||||
|
transition: opacity 180ms var(--ease), transform 180ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fold-enter-from,
|
||||||
|
.fold-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-left: 2px solid var(--accent);
|
||||||
|
background: var(--surface);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
display: block;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--line);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
transition: width 300ms linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
details ul {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
details li.bad {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(142px, 1fr));
|
||||||
|
gap: 24px 18px;
|
||||||
|
padding: 20px 0 28px;
|
||||||
|
border-top: 1.5px solid var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
text-align: left;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.art {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface-2);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--line);
|
||||||
|
transition: transform 240ms var(--ease), box-shadow 240ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile:hover .art {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: var(--shadow), inset 0 0 0 1px var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.art img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glyph {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--ink-3);
|
||||||
|
background: repeating-radial-gradient(circle at 50% 50%, transparent 0 6px, color-mix(in oklab, var(--line) 60%, transparent) 6px 7px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-name {
|
||||||
|
font-size: 13.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border-top: 1.5px solid var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px minmax(0, 1fr) auto auto 72px 30px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 9px 6px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file:hover {
|
||||||
|
background: color-mix(in oklab, var(--surface) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file.current .fname {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: none;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play:hover,
|
||||||
|
.file.current .play {
|
||||||
|
background: var(--ink);
|
||||||
|
border-color: var(--ink);
|
||||||
|
color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fname {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ext,
|
||||||
|
.lrc {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lrc {
|
||||||
|
text-decoration: line-through;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lrc.on {
|
||||||
|
text-decoration: none;
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.size {
|
||||||
|
font-size: 11.5px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.del {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: none;
|
||||||
|
color: var(--ink-3);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file:hover .del {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.del:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.other {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.other ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 8px 0 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.other a:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link.faint {
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trash {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 40px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.file {
|
||||||
|
grid-template-columns: 28px minmax(0, 1fr) auto 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ext,
|
||||||
|
.size {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.del {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { api } from '../lib/api.ts';
|
||||||
|
import { auth } from '../lib/store.ts';
|
||||||
|
|
||||||
|
const emit = defineEmits<{ success: [] }>();
|
||||||
|
const password = ref('');
|
||||||
|
const error = ref('');
|
||||||
|
const busy = ref(false);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
busy.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
await api.login(password.value);
|
||||||
|
auth.authenticated = true;
|
||||||
|
emit('success');
|
||||||
|
} catch (e) {
|
||||||
|
error.value = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="login">
|
||||||
|
<form class="card" @submit.prevent="submit">
|
||||||
|
<div class="rings" aria-hidden="true" />
|
||||||
|
<h1>声匣</h1>
|
||||||
|
<p class="faint eyebrow">Sound Case</p>
|
||||||
|
<input v-model="password" class="input" type="password" placeholder="访问密码" autofocus autocomplete="current-password" />
|
||||||
|
<button class="btn primary" type="submit" :disabled="busy || !password">进入</button>
|
||||||
|
<p v-if="error" class="error-text">{{ error }}</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rings {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle, var(--accent) 0 12%, var(--bg) 12% 14%, transparent 14%),
|
||||||
|
repeating-radial-gradient(circle, var(--ink) 0 1.4px, color-mix(in oklab, var(--ink) 84%, var(--bg)) 1.4px 2.8px);
|
||||||
|
animation: spin 18s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 30px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: -6px 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input,
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import DetailHeader from '../components/DetailHeader.vue';
|
||||||
|
import Icon from '../components/Icon.vue';
|
||||||
|
import TrackList from '../components/TrackList.vue';
|
||||||
|
import { api, type PlaylistDetail, type Source } from '../lib/api.ts';
|
||||||
|
|
||||||
|
const props = defineProps<{ source: Source; id: string }>();
|
||||||
|
const playlist = ref<PlaylistDetail | null>(null);
|
||||||
|
const error = ref('');
|
||||||
|
const filter = ref('');
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
playlist.value = await api.playlist(props.source, props.id);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = (e as Error).message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const tracks = computed(() => {
|
||||||
|
const list = playlist.value?.tracks ?? [];
|
||||||
|
const q = filter.value.trim().toLowerCase();
|
||||||
|
return q ? list.filter((t) => `${t.name} ${t.artists} ${t.album}`.toLowerCase().includes(q)) : list;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page">
|
||||||
|
<button class="back btn ghost sm" type="button" @click="$router.back()"><Icon name="back" :size="15" /> 返回</button>
|
||||||
|
<p v-if="error" class="state-msg">{{ error }}</p>
|
||||||
|
<template v-else-if="playlist">
|
||||||
|
<DetailHeader
|
||||||
|
:source="playlist.source"
|
||||||
|
kind="歌单"
|
||||||
|
:title="playlist.name"
|
||||||
|
:cover="playlist.cover"
|
||||||
|
:meta="[`${playlist.trackCount} 首`, playlist.tracks.length < playlist.trackCount && `已获取 ${playlist.tracks.length} 首`]"
|
||||||
|
:description="playlist.description"
|
||||||
|
>
|
||||||
|
<template #by>
|
||||||
|
<span v-if="playlist.creator">by {{ playlist.creator }}</span>
|
||||||
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<input v-model="filter" class="input" type="search" placeholder="在歌单中筛选…" />
|
||||||
|
</template>
|
||||||
|
</DetailHeader>
|
||||||
|
<TrackList :tracks="tracks" :group="`歌单 · ${playlist.name}`" />
|
||||||
|
</template>
|
||||||
|
<div v-else class="loading"><div v-for="i in 8" :key="i" class="skeleton" /></div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.back {
|
||||||
|
margin: -16px 0 14px -10px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import CoverArt from '../components/CoverArt.vue';
|
||||||
|
import Icon from '../components/Icon.vue';
|
||||||
|
import { api, type Task } from '../lib/api.ts';
|
||||||
|
import { formatSize, formatSpeed, isGold, QUALITY_BADGE, QUALITY_LABEL, SOURCE_LABEL, STAGE_LABEL } from '../lib/format.ts';
|
||||||
|
import { isPlaying, play } from '../lib/player.ts';
|
||||||
|
import { progress, summary, tasks, toast, toastError } from '../lib/store.ts';
|
||||||
|
|
||||||
|
type Tab = 'active' | 'done' | 'failed';
|
||||||
|
const tab = ref<Tab>('active');
|
||||||
|
|
||||||
|
const ORDER: Record<string, number> = { running: 0, queued: 1, paused: 2 };
|
||||||
|
const lists = computed(() => {
|
||||||
|
const all = [...tasks.values()];
|
||||||
|
return {
|
||||||
|
active: all
|
||||||
|
.filter((t) => t.status in ORDER)
|
||||||
|
.sort((a, b) => ORDER[a.status]! - ORDER[b.status]! || a.id - b.id),
|
||||||
|
done: all.filter((t) => t.status === 'done').sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)),
|
||||||
|
failed: all
|
||||||
|
.filter((t) => t.status === 'failed' || t.status === 'canceled')
|
||||||
|
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const shown = computed(() => lists.value[tab.value].slice(0, 400));
|
||||||
|
|
||||||
|
async function act(t: Task, action: 'pause' | 'resume' | 'retry' | 'cancel') {
|
||||||
|
try {
|
||||||
|
await api.taskAction(t.id, action);
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(t: Task) {
|
||||||
|
await api.removeTask(t.id).catch(toastError);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePause() {
|
||||||
|
await api.queueAction(summary.value.paused ? 'resume' : 'pause').catch(toastError);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryFailed() {
|
||||||
|
const res = await api.queueAction('retry-failed').catch(toastError);
|
||||||
|
if (res) toast(`已重新排队 ${res.retried} 首`, 'ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clear() {
|
||||||
|
const res = await api.queueAction('clear', { includeFailed: tab.value === 'failed' }).catch(toastError);
|
||||||
|
if (res) toast(`已清理 ${res.removed} 条记录`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pct(t: Task) {
|
||||||
|
const p = progress.get(t.id);
|
||||||
|
return p?.total ? Math.min(100, (p.received / p.total) * 100) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function playLocal(t: Task) {
|
||||||
|
if (!t.file) return;
|
||||||
|
play({ key: `file:${t.file}`, title: t.name, artists: t.artists, cover: t.cover ?? undefined, src: api.libraryFileUrl(t.file), source: t.source, id: t.trackId });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page">
|
||||||
|
<header class="page-head">
|
||||||
|
<span class="eyebrow">02 · Queue</span>
|
||||||
|
<div class="row-between">
|
||||||
|
<h2>下载队列</h2>
|
||||||
|
<div class="actions">
|
||||||
|
<span class="faint mono conc">并发 {{ summary.concurrency }}</span>
|
||||||
|
<button class="btn" type="button" @click="togglePause">
|
||||||
|
<Icon :name="summary.paused ? 'playCircle' : 'pauseCircle'" :size="16" />
|
||||||
|
{{ summary.paused ? '继续队列' : '暂停队列' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="summary.paused" class="paused-note">队列已暂停:进行中的任务会完成,新任务暂不开始。</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<div class="seg" role="tablist">
|
||||||
|
<button type="button" role="tab" :aria-selected="tab === 'active'" @click="tab = 'active'">
|
||||||
|
进行中 <span class="mono">{{ lists.active.length }}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" role="tab" :aria-selected="tab === 'done'" @click="tab = 'done'">
|
||||||
|
已完成 <span class="mono">{{ lists.done.length }}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" role="tab" :aria-selected="tab === 'failed'" @click="tab = 'failed'">
|
||||||
|
失败 / 取消 <span class="mono">{{ lists.failed.length }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="tab-actions">
|
||||||
|
<button v-if="tab === 'failed' && lists.failed.some((t) => t.status === 'failed')" class="btn sm" type="button" @click="retryFailed">
|
||||||
|
<Icon name="retry" :size="14" /> 全部重试
|
||||||
|
</button>
|
||||||
|
<button v-if="tab !== 'active' && shown.length" class="btn sm ghost" type="button" @click="clear">清理记录</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TransitionGroup v-if="shown.length" tag="ol" name="list" class="rows">
|
||||||
|
<li v-for="t in shown" :key="t.id" class="row" :class="[t.status, t.source]">
|
||||||
|
<CoverArt :src="t.cover" :size="42" />
|
||||||
|
<div class="title">
|
||||||
|
<div class="name">{{ t.name }}</div>
|
||||||
|
<div class="sub muted">
|
||||||
|
{{ t.artists || '—' }}<span v-if="t.album" class="faint"> · {{ t.album }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="t.status === 'running'" class="prog">
|
||||||
|
<span class="bar"><i :style="{ width: `${pct(t)}%` }" /></span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="t.status === 'failed'" class="err">{{ t.error }}</div>
|
||||||
|
<div v-else-if="t.status === 'done' && t.file" class="file faint mono" :title="t.file">{{ t.file }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info">
|
||||||
|
<template v-if="t.status === 'running'">
|
||||||
|
<span class="st run">{{ STAGE_LABEL[progress.get(t.id)?.stage ?? t.stage ?? 'resolve'] }}</span>
|
||||||
|
<span v-if="progress.get(t.id)?.total" class="mono faint small">
|
||||||
|
{{ Math.round(pct(t)) }}% · {{ formatSize(progress.get(t.id)!.total) }} · {{ formatSpeed(progress.get(t.id)!.speed) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="t.status === 'done'">
|
||||||
|
<span class="badge" :class="{ gold: isGold(t.actualQuality) }" :title="t.actualQuality && t.actualQuality !== t.quality ? `请求 ${QUALITY_LABEL[t.quality]},实际 ${QUALITY_LABEL[t.actualQuality]}` : ''">
|
||||||
|
{{ t.actualQuality ? QUALITY_BADGE[t.actualQuality] : '—' }}
|
||||||
|
</span>
|
||||||
|
<span class="mono faint small">
|
||||||
|
{{ formatSize(t.size) }}<template v-if="t.skipped"> · 已存在</template><template v-if="t.warnings.length"> · <span :title="t.warnings.join('\n')" class="warn">{{ t.warnings.length }} 提示</span></template>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="st">{{ { queued: '排队中', paused: '已暂停', failed: '失败', canceled: '已取消' }[t.status as string] }}</span>
|
||||||
|
<span class="mono faint small">{{ SOURCE_LABEL[t.source] }} · {{ QUALITY_LABEL[t.quality] }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ops">
|
||||||
|
<template v-if="t.status === 'running' || t.status === 'queued'">
|
||||||
|
<button type="button" title="暂停" @click="act(t, 'pause')"><Icon name="pauseCircle" /></button>
|
||||||
|
<button type="button" title="取消" @click="act(t, 'cancel')"><Icon name="x" /></button>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="t.status === 'paused'">
|
||||||
|
<button type="button" title="继续" @click="act(t, 'resume')"><Icon name="playCircle" /></button>
|
||||||
|
<button type="button" title="取消" @click="act(t, 'cancel')"><Icon name="x" /></button>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="t.status === 'done'">
|
||||||
|
<button type="button" title="播放本地文件" @click="playLocal(t)"><Icon :name="isPlaying(`file:${t.file}`) ? 'pause' : 'play'" /></button>
|
||||||
|
<button type="button" title="移除记录" @click="remove(t)"><Icon name="x" /></button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<button type="button" title="重试" @click="act(t, 'retry')"><Icon name="retry" /></button>
|
||||||
|
<button type="button" title="移除记录" @click="remove(t)"><Icon name="x" /></button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</TransitionGroup>
|
||||||
|
|
||||||
|
<div v-else class="empty">
|
||||||
|
<div class="sleeve" aria-hidden="true"><div class="record" /></div>
|
||||||
|
<p class="faint">{{ tab === 'active' ? '队列是空的 —— 去搜索页挑几首歌吧' : '暂无记录' }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conc {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paused-note {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding-bottom: 14px;
|
||||||
|
border-bottom: 1.5px solid var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg .mono {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 42px minmax(0, 1fr) 210px 76px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 6px 12px 12px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
width: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--netease);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.qq::before {
|
||||||
|
background: var(--qq);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.paused,
|
||||||
|
.row.canceled {
|
||||||
|
opacity: 0.62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 14.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
font-size: 12.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prog {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
display: block;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--line);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
transition: width 300ms linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.err {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st.run {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warn {
|
||||||
|
color: var(--gold);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops button {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: none;
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops button:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-enter-active,
|
||||||
|
.list-leave-active {
|
||||||
|
transition: all 220ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-enter-from,
|
||||||
|
.list-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 26px;
|
||||||
|
padding: 80px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sleeve {
|
||||||
|
position: relative;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow), inset 0 0 0 1px var(--line);
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
left: 40px;
|
||||||
|
width: 108px;
|
||||||
|
height: 108px;
|
||||||
|
z-index: -1;
|
||||||
|
border-radius: 50%;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle, var(--accent) 0 14%, var(--ink) 14% 15%, transparent 15%),
|
||||||
|
repeating-radial-gradient(circle, var(--ink) 0 1.5px, color-mix(in oklab, var(--ink) 85%, var(--bg)) 1.5px 3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.tabs {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
grid-template-columns: 42px minmax(0, 1fr) 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import Icon from '../components/Icon.vue';
|
||||||
|
import TrackList from '../components/TrackList.vue';
|
||||||
|
import { api, type Source, type TrackSummary } from '../lib/api.ts';
|
||||||
|
import { SOURCE_LABEL } from '../lib/format.ts';
|
||||||
|
import { qqStatus } from '../lib/store.ts';
|
||||||
|
|
||||||
|
// 模块级状态:离开页面再回来时保留搜索结果
|
||||||
|
const state = reactive({
|
||||||
|
source: 'netease' as Source,
|
||||||
|
query: '',
|
||||||
|
searched: '',
|
||||||
|
items: [] as TrackSummary[],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const RECENT_KEY = 'sx-recent';
|
||||||
|
function readRecent(): string[] {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(RECENT_KEY) || '[]');
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const recent = ref<string[]>(readRecent());
|
||||||
|
function remember(q: string) {
|
||||||
|
recent.value = [q, ...recent.value.filter((x) => x !== q)].slice(0, 8);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(RECENT_KEY, JSON.stringify(recent.value));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
const hasMore = computed(() => state.items.length < state.total);
|
||||||
|
const looksLikeLink = (text: string) => /https?:\/\/|^\d{5,}$|^00[0-9A-Za-z]{12}$/.test(text.trim());
|
||||||
|
|
||||||
|
async function openLink(text: string): Promise<boolean> {
|
||||||
|
const link = await api.resolveLink(text).catch(() => null);
|
||||||
|
if (!link) return false;
|
||||||
|
if (link.kind === 'track') {
|
||||||
|
const meta = await api.track(link.source, link.id);
|
||||||
|
state.source = link.source;
|
||||||
|
state.items = [{ ...meta, artistRefs: undefined }];
|
||||||
|
state.total = 1;
|
||||||
|
state.searched = `${SOURCE_LABEL[link.source]} 歌曲 · ${meta.name}`;
|
||||||
|
} else {
|
||||||
|
router.push(`/${link.kind}/${link.source}/${link.id}`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(text = state.query) {
|
||||||
|
const q = text.trim();
|
||||||
|
if (!q) return;
|
||||||
|
state.query = q;
|
||||||
|
error.value = '';
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
if (looksLikeLink(q) && (await openLink(q))) return;
|
||||||
|
const res = await api.search(state.source, q, 1);
|
||||||
|
state.items = res.items;
|
||||||
|
state.total = res.total;
|
||||||
|
state.page = 1;
|
||||||
|
state.searched = q;
|
||||||
|
remember(q);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchSource(s: Source) {
|
||||||
|
if (state.source === s) return;
|
||||||
|
state.source = s;
|
||||||
|
if (state.searched && !looksLikeLink(state.query)) submit(state.query);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await api.search(state.source, state.searched, state.page + 1);
|
||||||
|
state.page += 1;
|
||||||
|
state.items = [...state.items, ...res.items];
|
||||||
|
} catch (e) {
|
||||||
|
error.value = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const qqHint = computed(() => state.source === 'qq' && qqStatus.value && !qqStatus.value.loggedIn);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page">
|
||||||
|
<header class="head">
|
||||||
|
<span class="eyebrow">01 · Search</span>
|
||||||
|
<form class="searchbar" @submit.prevent="submit()">
|
||||||
|
<Icon name="search" :size="22" class="lead" />
|
||||||
|
<input v-model="state.query" class="q" type="search" placeholder="搜索歌曲、歌手,或粘贴分享链接" autocomplete="off" autofocus />
|
||||||
|
<div class="seg" role="tablist" aria-label="音乐来源">
|
||||||
|
<button type="button" role="tab" :aria-selected="state.source === 'netease'" @click="switchSource('netease')">网易云</button>
|
||||||
|
<button type="button" role="tab" :aria-selected="state.source === 'qq'" @click="switchSource('qq')">QQ 音乐</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p v-if="qqHint" class="hint">
|
||||||
|
QQ 音乐尚未登录,只能下载免费曲目。<RouterLink to="/settings">去导入 Cookie →</RouterLink>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p v-if="error" class="error-text">{{ error }}</p>
|
||||||
|
|
||||||
|
<template v-if="state.items.length">
|
||||||
|
<div class="meta-line">
|
||||||
|
<span>「{{ state.searched }}」</span>
|
||||||
|
<span class="faint mono">{{ state.total }} results · {{ SOURCE_LABEL[state.source] }}</span>
|
||||||
|
</div>
|
||||||
|
<TrackList :tracks="state.items" />
|
||||||
|
<div class="more">
|
||||||
|
<button v-if="hasMore" class="btn" :disabled="loading" @click="loadMore">{{ loading ? '加载中…' : '加载更多' }}</button>
|
||||||
|
<span v-else class="faint">— 已到末尾 —</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else-if="loading" class="loading">
|
||||||
|
<div v-for="i in 6" :key="i" class="skeleton" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="empty">
|
||||||
|
<div class="rings" aria-hidden="true" />
|
||||||
|
<p class="empty-title">从一首歌开始。</p>
|
||||||
|
<p class="faint">关键词、歌曲 ID,或网易云 / QQ 音乐的歌曲、专辑、歌单、歌手链接</p>
|
||||||
|
<div v-if="recent.length" class="recent">
|
||||||
|
<button v-for="r in recent" :key="r" type="button" class="chip" @click="submit(r)">{{ r }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
border-bottom: 1.5px solid var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.q {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 0 12px;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
outline: none;
|
||||||
|
font: 400 26px/1.3 var(--font-serif);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q::placeholder {
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.q::-webkit-search-cancel-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-line {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
margin: 6px 0 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 28px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 80px 0 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rings {
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle, var(--accent) 0 9%, var(--bg) 9% 10.5%, transparent 10.5%),
|
||||||
|
repeating-radial-gradient(circle, var(--line) 0 1px, transparent 1px 7px);
|
||||||
|
mask: radial-gradient(circle, #000 60%, transparent 71%);
|
||||||
|
animation: spin 24s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font: 500 24px/1.4 var(--font-serif);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 520px;
|
||||||
|
margin-top: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface);
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip:hover {
|
||||||
|
border-color: var(--line-strong);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.searchbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q {
|
||||||
|
flex-basis: 100%;
|
||||||
|
border-bottom: 1.5px solid var(--ink);
|
||||||
|
font-size: 21px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,589 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import Icon from '../components/Icon.vue';
|
||||||
|
import QRLoginDialog from '../components/QRLoginDialog.vue';
|
||||||
|
import { api, type QQStatus, type QualityKey, type Settings } from '../lib/api.ts';
|
||||||
|
import { formatDate, QUALITY_BADGE, QUALITY_LABEL } from '../lib/format.ts';
|
||||||
|
import { auth, ffmpeg, meta, qqStatus, refreshAccounts, settings, toast, toastError } from '../lib/store.ts';
|
||||||
|
import { themeMode, type ThemeMode } from '../lib/theme.ts';
|
||||||
|
|
||||||
|
// ---- QQ 账号 ----
|
||||||
|
const cookieInput = ref('');
|
||||||
|
const showCookieForm = ref(false);
|
||||||
|
const savingCookie = ref(false);
|
||||||
|
const checking = ref(false);
|
||||||
|
|
||||||
|
async function saveCookie() {
|
||||||
|
savingCookie.value = true;
|
||||||
|
try {
|
||||||
|
qqStatus.value = (await api.saveQQCookie(cookieInput.value)).qq;
|
||||||
|
cookieInput.value = '';
|
||||||
|
showCookieForm.value = false;
|
||||||
|
toast(qqStatus.value.valid ? `已登录:${qqStatus.value.nick ?? 'QQ 音乐'}` : 'Cookie 已保存,但校验未通过', qqStatus.value.valid ? 'ok' : 'error');
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e);
|
||||||
|
} finally {
|
||||||
|
savingCookie.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
checking.value = true;
|
||||||
|
await refreshAccounts(true);
|
||||||
|
checking.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logoutQQ() {
|
||||||
|
if (!confirm('清除已保存的 QQ 音乐 Cookie?')) return;
|
||||||
|
qqStatus.value = (await api.logoutQQ()).qq;
|
||||||
|
}
|
||||||
|
|
||||||
|
const showQR = ref(false);
|
||||||
|
const renewing = ref(false);
|
||||||
|
|
||||||
|
function onQRSuccess(qq: QQStatus) {
|
||||||
|
qqStatus.value = qq;
|
||||||
|
showQR.value = false;
|
||||||
|
toast(`已登录:${qq.nick ?? 'QQ 音乐'}`, 'ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renew() {
|
||||||
|
renewing.value = true;
|
||||||
|
try {
|
||||||
|
const res = await api.refreshQQ();
|
||||||
|
qqStatus.value = res.qq;
|
||||||
|
toast(res.ok ? '凭据已续期' : `续期失败:${res.qq.lastRefresh?.error ?? ''}`, res.ok ? 'ok' : 'error');
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e);
|
||||||
|
} finally {
|
||||||
|
renewing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function relTime(iso: string | undefined, future = false): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
const diff = (Date.parse(iso) - Date.now()) / 1000 * (future ? 1 : -1);
|
||||||
|
if (diff < 0) return future ? '已过期' : '刚刚';
|
||||||
|
if (diff < 3600) return `${Math.max(1, Math.round(diff / 60))} 分钟${future ? '后' : '前'}`;
|
||||||
|
if (diff < 86400) return `${Math.round(diff / 3600)} 小时${future ? '后' : '前'}`;
|
||||||
|
return `${Math.round(diff / 86400)} 天${future ? '后' : '前'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIA_LABEL = { cookie: '粘贴 Cookie', file: 'Cookie 文件', qr: '扫码登录', refresh: '自动续期' } as const;
|
||||||
|
|
||||||
|
// ---- 下载设置(修改即保存) ----
|
||||||
|
async function save(patch: Partial<Settings>) {
|
||||||
|
try {
|
||||||
|
settings.value = await api.saveSettings(patch);
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const qualityOptions = computed(() => [...(meta.value?.qualities ?? [])].reverse());
|
||||||
|
|
||||||
|
// ---- 访问密码 ----
|
||||||
|
const pwCurrent = ref('');
|
||||||
|
const pwNext = ref('');
|
||||||
|
async function savePassword(clear = false) {
|
||||||
|
try {
|
||||||
|
const res = await api.setPassword(pwCurrent.value || undefined, clear ? undefined : pwNext.value);
|
||||||
|
auth.required = res.required;
|
||||||
|
pwCurrent.value = '';
|
||||||
|
pwNext.value = '';
|
||||||
|
toast(res.required ? '访问密码已更新' : '已关闭访问密码', 'ok');
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logoutSite() {
|
||||||
|
await api.logout();
|
||||||
|
auth.authenticated = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const themes: Array<{ key: ThemeMode; label: string }> = [
|
||||||
|
{ key: 'auto', label: '跟随系统' },
|
||||||
|
{ key: 'light', label: '纸 · 浅色' },
|
||||||
|
{ key: 'dark', label: '墨 · 深色' },
|
||||||
|
];
|
||||||
|
|
||||||
|
onMounted(() => refreshAccounts());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page narrow">
|
||||||
|
<header class="page-head">
|
||||||
|
<span class="eyebrow">05 · Settings</span>
|
||||||
|
<h2>设置</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- QQ 音乐账号 -->
|
||||||
|
<section class="group">
|
||||||
|
<h3>QQ 音乐账号</h3>
|
||||||
|
<div class="account" :class="{ off: !qqStatus?.loggedIn }">
|
||||||
|
<img v-if="qqStatus?.avatar" :src="qqStatus.avatar" class="avatar" alt="" referrerpolicy="no-referrer" />
|
||||||
|
<span v-else class="avatar ph"><Icon name="user" :size="22" /></span>
|
||||||
|
<div class="acc-info">
|
||||||
|
<template v-if="qqStatus?.loggedIn">
|
||||||
|
<div class="acc-name">
|
||||||
|
{{ qqStatus.nick ?? 'QQ 用户' }}
|
||||||
|
<span v-if="qqStatus.vip?.active" class="vip">{{ qqStatus.vip.label }}</span>
|
||||||
|
<span v-if="qqStatus.valid === false" class="bad">登录已失效</span>
|
||||||
|
</div>
|
||||||
|
<div class="faint mono small">
|
||||||
|
{{ qqStatus.uin }}
|
||||||
|
<template v-if="qqStatus.vip?.expires"> · 会员至 {{ qqStatus.vip.expires }}</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="acc-name">未登录</div>
|
||||||
|
<div class="faint small">扫码或导入 y.qq.com 的 Cookie 后可下载 VIP 曲目(最高 FLAC / Hi-Res)</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="acc-ops">
|
||||||
|
<button v-if="qqStatus?.loggedIn" class="btn sm" type="button" :disabled="checking" @click="check"><Icon name="retry" :size="14" /> 校验</button>
|
||||||
|
<button class="btn sm" :class="{ primary: !qqStatus?.loggedIn }" type="button" @click="showQR = true">扫码登录</button>
|
||||||
|
<button v-if="qqStatus?.loggedIn" class="btn sm ghost danger" type="button" title="退出登录" @click="logoutQQ"><Icon name="logout" :size="14" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<dl v-if="qqStatus?.loggedIn" class="cred small">
|
||||||
|
<dt>凭据来源</dt>
|
||||||
|
<dd>{{ qqStatus.via ? VIA_LABEL[qqStatus.via] : '—' }} <span v-if="qqStatus.savedAt" class="faint">· {{ relTime(qqStatus.savedAt) }}</span></dd>
|
||||||
|
<dt>musickey</dt>
|
||||||
|
<dd>
|
||||||
|
生成于 {{ relTime(qqStatus.keyCreatedAt) || '未知' }}
|
||||||
|
<template v-if="qqStatus.keyExpiresAt"> · {{ relTime(qqStatus.keyExpiresAt, true) }}过期</template>
|
||||||
|
</dd>
|
||||||
|
<dt>自动续期</dt>
|
||||||
|
<dd>
|
||||||
|
<template v-if="qqStatus.canRefresh">
|
||||||
|
已开启
|
||||||
|
<span v-if="qqStatus.lastRefresh" :class="qqStatus.lastRefresh.ok ? 'ok' : 'bad'">
|
||||||
|
· 上次{{ qqStatus.lastRefresh.ok ? '成功' : `失败(${qqStatus.lastRefresh.error})` }},{{ relTime(qqStatus.lastRefresh.at) }}
|
||||||
|
</span>
|
||||||
|
<button class="link" type="button" :disabled="renewing" @click="renew">{{ renewing ? '续期中…' : '立即续期' }}</button>
|
||||||
|
</template>
|
||||||
|
<span v-else class="faint">当前凭据缺少 refresh_token,请扫码登录</span>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
<button v-if="!showCookieForm" class="link small" type="button" @click="showCookieForm = true">或手动粘贴 Cookie</button>
|
||||||
|
<form v-if="showCookieForm" class="cookie-form" @submit.prevent="saveCookie">
|
||||||
|
<textarea v-model="cookieInput" class="input area" rows="4" placeholder="在浏览器登录 y.qq.com 后,开发者工具 → Network → 任一请求的 Cookie 请求头,整段粘贴到这里" />
|
||||||
|
<div class="form-row">
|
||||||
|
<span class="faint small">需包含 uin 与 qm_keyst(或 qqmusic_key)。Cookie 加密保存在本机,不会回传到页面。</span>
|
||||||
|
<button class="btn sm primary" type="submit" :disabled="savingCookie || !cookieInput.trim()">保存并校验</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p v-if="showCookieForm" class="faint small note">也可以把 Cookie 写入 config.json 中 qq.cookieFile 指向的文件,文件更新后重启即会自动导入。</p>
|
||||||
|
<QRLoginDialog v-if="showQR" @close="showQR = false" @success="onQRSuccess" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 下载 -->
|
||||||
|
<section v-if="settings" class="group">
|
||||||
|
<h3>下载</h3>
|
||||||
|
<div class="field">
|
||||||
|
<div class="label">默认音质<span class="faint">不可用时自动降级;QQ 音乐最高 Hi-Res</span></div>
|
||||||
|
<select class="input" :value="settings.quality" @change="save({ quality: ($event.target as HTMLSelectElement).value as QualityKey })">
|
||||||
|
<option v-for="q in qualityOptions" :key="q.key" :value="q.key">{{ QUALITY_LABEL[q.key] }} · {{ QUALITY_BADGE[q.key] }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<div class="label">目录结构<span class="faint">{{ meta?.musicDir }}</span></div>
|
||||||
|
<div class="seg" role="tablist">
|
||||||
|
<button type="button" role="tab" :aria-selected="settings.layout === 'album'" @click="save({ layout: 'album' })">歌手 / 专辑 / 01. 歌名</button>
|
||||||
|
<button type="button" role="tab" :aria-selected="settings.layout === 'flat'" @click="save({ layout: 'flat' })">歌手 - 歌名</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<div class="label">同时下载<span class="faint">并发任务数</span></div>
|
||||||
|
<div class="stepper">
|
||||||
|
<button type="button" :disabled="settings.concurrency <= 1" @click="save({ concurrency: settings.concurrency - 1 })">−</button>
|
||||||
|
<span class="mono">{{ settings.concurrency }}</span>
|
||||||
|
<button type="button" :disabled="settings.concurrency >= 6" @click="save({ concurrency: settings.concurrency + 1 })">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<div class="label">失败重试<span class="faint">每首最多尝试次数</span></div>
|
||||||
|
<div class="stepper">
|
||||||
|
<button type="button" :disabled="settings.retries <= 1" @click="save({ retries: settings.retries - 1 })">−</button>
|
||||||
|
<span class="mono">{{ settings.retries }}</span>
|
||||||
|
<button type="button" :disabled="settings.retries >= 10" @click="save({ retries: settings.retries + 1 })">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<div class="label">任务间隔<span class="faint">避免请求过快被限流</span></div>
|
||||||
|
<select class="input" :value="settings.gapMs" @change="save({ gapMs: Number(($event.target as HTMLSelectElement).value) })">
|
||||||
|
<option v-for="ms in [0, 200, 400, 800, 1500, 3000]" :key="ms" :value="ms">{{ ms ? `${ms} ms` : '不间隔' }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="toggles">
|
||||||
|
<label v-for="opt in [
|
||||||
|
{ key: 'lyric', label: '保存 .lrc 歌词' },
|
||||||
|
{ key: 'translatedLyric', label: '额外保存合并翻译歌词' },
|
||||||
|
{ key: 'embedTags', label: '写入封面与标签' },
|
||||||
|
{ key: 'albumCover', label: '专辑目录保存 cover.jpg' },
|
||||||
|
] as const" :key="opt.key" class="toggle">
|
||||||
|
<input type="checkbox" :checked="settings[opt.key]" @change="save({ [opt.key]: ($event.target as HTMLInputElement).checked })" />
|
||||||
|
<span class="sw" />
|
||||||
|
{{ opt.label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p v-if="ffmpeg === false" class="warn small">未检测到 ffmpeg:FLAC 等非 MP3 文件无法写入标签。请安装 ffmpeg 并加入 PATH。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 访问密码 -->
|
||||||
|
<section class="group">
|
||||||
|
<h3>访问密码 <span class="faint state">{{ auth.required ? '已开启' : '未开启' }}</span></h3>
|
||||||
|
<p class="faint small">在 NAS 上暴露到局域网 / 公网时建议开启。</p>
|
||||||
|
<form class="pw" @submit.prevent="savePassword()">
|
||||||
|
<input v-if="auth.required" v-model="pwCurrent" class="input" type="password" placeholder="当前密码" autocomplete="current-password" />
|
||||||
|
<input v-model="pwNext" class="input" type="password" :placeholder="auth.required ? '新密码' : '设置密码(至少 4 位)'" autocomplete="new-password" />
|
||||||
|
<button class="btn sm primary" type="submit" :disabled="pwNext.length < 4">{{ auth.required ? '修改' : '开启' }}</button>
|
||||||
|
<button v-if="auth.required" class="btn sm ghost danger" type="button" :disabled="!pwCurrent" @click="savePassword(true)">关闭密码</button>
|
||||||
|
<button v-if="auth.required" class="btn sm ghost" type="button" @click="logoutSite">退出登录</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 外观 -->
|
||||||
|
<section class="group">
|
||||||
|
<h3>外观</h3>
|
||||||
|
<div class="themes">
|
||||||
|
<button v-for="t in themes" :key="t.key" type="button" class="theme-opt" :class="{ active: themeMode === t.key }" @click="themeMode = t.key">
|
||||||
|
<span class="swatch" :data-mode="t.key" />
|
||||||
|
{{ t.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="faint small foot">声匣 · 数据目录中的 shengxia.db 保存历史、队列与设置 · {{ formatDate(Date.now()) }}</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.narrow {
|
||||||
|
max-width: 780px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
padding: 24px 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 17px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
font: 400 12px var(--font-sans);
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-left: 3px solid var(--qq);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account.off {
|
||||||
|
border-left-color: var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar.ph {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.acc-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acc-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font: 600 16px var(--font-serif);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vip {
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font: 600 10.5px/18px var(--font-sans);
|
||||||
|
color: var(--qq);
|
||||||
|
border: 1px solid color-mix(in oklab, var(--qq) 50%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bad {
|
||||||
|
font: 500 12px var(--font-sans);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.acc-ops {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cred {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 88px 1fr;
|
||||||
|
gap: 6px 14px;
|
||||||
|
margin: 14px 0 6px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cred dt {
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cred dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cred .ok {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cred .bad {
|
||||||
|
font: inherit;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link.small {
|
||||||
|
margin: 8px 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cookie-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.area {
|
||||||
|
height: auto;
|
||||||
|
padding: 10px 12px;
|
||||||
|
resize: vertical;
|
||||||
|
font: 12px/1.6 var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label .faint {
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper button {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper button:hover:not(:disabled) {
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper button:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper span {
|
||||||
|
min-width: 34px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggles {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px 24px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw {
|
||||||
|
position: relative;
|
||||||
|
width: 32px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--line-strong);
|
||||||
|
transition: background var(--dur) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--surface);
|
||||||
|
transition: transform var(--dur) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:checked + .sw {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:checked + .sw::after {
|
||||||
|
transform: translateX(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:focus-visible + .sw {
|
||||||
|
outline: 2px solid color-mix(in oklab, var(--accent) 60%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warn {
|
||||||
|
color: var(--gold);
|
||||||
|
margin: 14px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pw {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pw .input {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.themes {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-opt {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 16px 10px 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-opt.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
background: linear-gradient(135deg, #f4f1ea 50%, #121315 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch[data-mode='light'] {
|
||||||
|
background: #f4f1ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch[data-mode='dark'] {
|
||||||
|
background: #121315;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foot {
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.account {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggles {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user