v2: SQLite + 多路流 + 鉴权 + 导入/删除 + 资源本地化 + 模块化重构
This commit is contained in:
1 parent
fa6eeb25b9
commit
65b6f8cf62
30 files changed
+3506
-2231
No files matched your search
+8
-1
@@ -32,4 +32,11 @@ npm-debug.log*
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Thumbs.db
|
||||
# 音频文件(体积大,不入库)
|
||||
audio/*.mp3
|
||||
audio/*.m4a
|
||||
audio/*.flac
|
||||
audio/*.wav
|
||||
audio/*.aac
|
||||
audio/*.ogg
|
||||
@@ -1,252 +1,123 @@
|
||||
# 音乐在线电台
|
||||
# 音乐在线电台 · v2
|
||||
|
||||
把 `audio/` 目录里的歌曲用 FFmpeg 切成 HLS 切片,拼成一条永不停播的直播流,并提供 Web 控制面板管理播放列表、从网易云导入音源。前后端一体的 Node.js 应用,无数据库依赖。
|
||||
把 `audio/` 目录里的歌曲用 FFmpeg 切成 HLS 切片,拼成**多路**永不停播的直播流,并提供 Web 控制面板管理播放列表、导入音源(网易云 / 本地上传)。前后端一体的 Node.js 应用。
|
||||
|
||||
## 功能特性
|
||||
## v2 相比 v1 的变化
|
||||
|
||||
- 🎵 音频文件自动转码切片(HLS),拼成 24 小时不间断的直播流
|
||||
- 📋 拖拽排序的播放列表(支持添加、移除、清空、切歌)
|
||||
- 🔥 热度统计 + 缓存自动清理(上限 1GB,按热度淘汰)
|
||||
- ☁️ 网易云音源导入:输入歌曲 ID,一键下载入库并显示封面/歌名/作者
|
||||
- 🖼️ 资源卡片展示:封面图高斯模糊背景 + 时长/大小信息(hover 显示)
|
||||
- 🔒 可选 API 鉴权(Bearer Token)
|
||||
- **数据库**:JSON(playlist/heat/metadata)→ **SQLite**(better-sqlite3,WAL 模式),支持多路流隔离
|
||||
- **鉴权**:可选 Bearer Token → **Cookie 会话登录**(面板 + API 全保护,HLS 流不锁)
|
||||
- **多路流**:单流 → **多路独立直播流**,各自播放列表,`/hls/{streamId}/index.m3u8`
|
||||
- **导入**:仅网易云下载 → 增加**本地上传导入**(自动匹配作者/封面)
|
||||
- **删除**:无 → **级联删除**(音频 + 切片缓存 + DB + 播放列表引用)
|
||||
- **本地化**:CDN 依赖 + 封面图 → **全部本地**(`public/vendor/`、`public/covers/`),系统字体
|
||||
- **模块化**:1233 行单文件 → `src/` 分模块
|
||||
|
||||
## 环境要求
|
||||
|
||||
| 依赖 | 版本要求 |
|
||||
|------|----------|
|
||||
| Node.js | >= 14.14 |
|
||||
| npm | 随 Node 附带 |
|
||||
| FFmpeg | 任意较新版本(需包含 `ffmpeg` 与 `ffprobe`,且在系统 PATH 中) |
|
||||
| 依赖 | 版本 |
|
||||
|------|------|
|
||||
| Node.js | >= 18 |
|
||||
| FFmpeg | 含 `ffmpeg` 与 `ffprobe`,在 PATH 中 |
|
||||
|
||||
> ⚠️ FFmpeg 是硬性依赖。缺少它程序能启动但无法转码切片,直播流不会有内容。安装后请在终端运行 `ffmpeg -version` 与 `ffprobe -version` 确认可用。
|
||||
|
||||
## 安装
|
||||
|
||||
### 1. 安装 Node.js
|
||||
|
||||
**Windows**
|
||||
|
||||
- 前往 <https://nodejs.org> 下载 LTS 版本安装包(`.msi`),一路下一步即可。
|
||||
- 安装完成后重新打开终端,运行 `node -v` 应显示版本号(≥ v14.14)。
|
||||
|
||||
**Linux(Debian/Ubuntu 示例)**
|
||||
|
||||
```bash
|
||||
# 使用 NodeSource 源安装新版 Node
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
node -v
|
||||
```
|
||||
|
||||
> 其它发行版请参考 Node.js 官方安装文档,或使用包管理器安装 `nodejs` / `npm`。
|
||||
|
||||
### 2. 安装 FFmpeg
|
||||
|
||||
**Windows(推荐)**
|
||||
|
||||
方式一:用 winget(Windows 11 自带):
|
||||
|
||||
```powershell
|
||||
winget install Gyan.FFmpeg
|
||||
```
|
||||
|
||||
方式二:手动下载
|
||||
|
||||
1. 打开 <https://www.gyan.dev/ffmpeg/builds/>,下载 `release essentials` 的 zip 包。
|
||||
2. 解压到固定目录(如 `C:\ffmpeg`)。
|
||||
3. 把解压后 **`bin` 子目录**的完整路径(如 `C:\ffmpeg\bin`)加入系统环境变量 `Path`:
|
||||
- `Win` 键 → 搜索「编辑系统环境变量」→ 「环境变量」→ 双击「Path」→ 新建 → 粘贴路径 → 确定。
|
||||
4. **重新打开终端**,运行 `ffmpeg -version` 验证。
|
||||
|
||||
方式三:用 Chocolatey / Scoop:
|
||||
|
||||
```powershell
|
||||
choco install ffmpeg # 或
|
||||
scoop install ffmpeg
|
||||
```
|
||||
|
||||
**Linux**
|
||||
|
||||
```bash
|
||||
# Debian / Ubuntu
|
||||
sudo apt update && sudo apt install -y ffmpeg
|
||||
|
||||
# CentOS / RHEL / Fedora
|
||||
sudo dnf install -y ffmpeg # 部分发行版需先启用 EPEL 或 RPM Fusion 源
|
||||
|
||||
# Arch
|
||||
sudo pacman -S ffmpeg
|
||||
```
|
||||
|
||||
验证安装:
|
||||
|
||||
```bash
|
||||
ffmpeg -version
|
||||
ffprobe -version
|
||||
```
|
||||
|
||||
### 3. 安装项目依赖
|
||||
|
||||
在项目根目录(`demo/`)下:
|
||||
## 安装与启动
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
启动成功后控制台会输出:
|
||||
首次启动若未设密码,控制台会打印随机登录密码:
|
||||
|
||||
```
|
||||
[INFO] 资源: N 个文件
|
||||
[CACHE] 启动扫描: ...
|
||||
[LIVE] 已启动 | 切片 5s | 窗口 15 段 ≈75s | ...
|
||||
[HTTP] 面板: http://127.0.0.1:3000
|
||||
[HTTP] 直播: http://127.0.0.1:3000/hls/index.m3u8
|
||||
[AUTH] 未设置 PANEL_PASSWORD,已生成随机登录密码: xxxxxx
|
||||
```
|
||||
|
||||
- **控制面板**:浏览器打开 <http://127.0.0.1:3000>
|
||||
- **直播流地址**:<http://127.0.0.1:3000/hls/index.m3u8>(可用 VLC、PotPlayer、ffplay 等播放器打开)
|
||||
建议固定密码:
|
||||
|
||||
停止服务:在终端按 `Ctrl + C`。
|
||||
```bash
|
||||
# Windows PowerShell
|
||||
$env:PANEL_PASSWORD="你的密码"; npm start
|
||||
# Windows CMD
|
||||
set PANEL_PASSWORD=你的密码 && npm start
|
||||
# Linux / macOS
|
||||
PANEL_PASSWORD=你的密码 npm start
|
||||
```
|
||||
|
||||
> 💡 Windows 下如果端口被旧进程占用导致启动失败(`EADDRINUSE`),见下文「常见问题」。
|
||||
访问 `http://127.0.0.1:3000`,用密码登录。
|
||||
|
||||
## 使用指南
|
||||
|
||||
### 添加音频
|
||||
|
||||
把音频文件(支持 `.mp3` / `.m4a` / `.flac` / `.wav`)直接放进 `audio/` 目录。程序用 chokidar 监听目录,**无需重启**,新文件会自动扫描并开始转码切片。
|
||||
|
||||
> 文件名建议用「作者 - 歌名.mp3」格式,程序会据此自动解析出作者与歌名,用于面板展示。
|
||||
|
||||
### 网易云音源导入
|
||||
|
||||
1. 在控制面板左侧「资源列表」顶部输入框填入网易云歌曲 ID。
|
||||
2. 点「查询」,预览区会显示封面、歌名、作者。
|
||||
3. 点「下载」,音频会自动保存到 `audio/` 并入库切片,封面/歌名/作者写入元数据。
|
||||
|
||||
**网易云歌曲 ID 在哪找?** 打开网易云网页版某首歌的页面,地址栏 `https://music.163.com/song?id=33894312` 中 `id=` 后面的数字即为歌曲 ID。
|
||||
|
||||
> ⚠️ 版权限制:只有免费、可播的歌曲能下载。VIP / 灰色无版权歌曲会被拦截并提示「无版权或需 VIP」,这是网易云服务端的限制,无法绕过。
|
||||
|
||||
### 管理播放列表
|
||||
|
||||
控制面板右侧「播放列表」支持:
|
||||
|
||||
- ➕ 点资源卡片右侧的加号把歌曲加入播放列表
|
||||
- 🖱️ 拖拽排序(SortableJS)
|
||||
- ❌ 移除单曲
|
||||
- 🗑️ 清空列表(清空后自动随机补一首,保证直播不断流)
|
||||
- 歌曲播完自动从列表移除,列表空了自动随机补一首
|
||||
|
||||
播放列表与热度会持久化到磁盘,重启后保留。
|
||||
|
||||
## 目录结构与数据文件
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
demo/
|
||||
├── index.js # 主程序(后端 + HLS 直播 + HTTP API)
|
||||
├── package.json # 依赖与启动脚本
|
||||
├── public/ # 前端控制面板(静态文件,含 favicon)
|
||||
│ ├── index.html
|
||||
│ └── favicon.png
|
||||
├── audio/ # 【你的音频】放这里,程序自动监听
|
||||
└── (以下为运行时自动生成,可随时删除,重启自动重建)
|
||||
├── hls/ # 直播流滑动窗口 index.m3u8
|
||||
├── hls_cache/ # 每首歌的 HLS 切片缓存(上限 1GB,自动清理)
|
||||
├── heat.json # 歌曲热度(缓存清理优先级)
|
||||
├── playlist.json # 播放列表(持久化)
|
||||
└── metadata.json # 歌曲元数据(歌名/作者/封面图/时长)
|
||||
├── index.js # 入口,委托 src/server.js
|
||||
├── src/
|
||||
│ ├── server.js # 主程序:装配路由、启动服务
|
||||
│ ├── config.js # 配置(环境变量)
|
||||
│ ├── logger.js # 日志(分级 + 落盘 + 面板可查)
|
||||
│ ├── db.js # SQLite 初始化 + schema
|
||||
│ ├── store.js # 数据访问层(全部 SQL 收敛于此)
|
||||
│ ├── util.js # 工具函数
|
||||
│ ├── media.js # ffprobe / FFmpeg 切片 / 并发队列
|
||||
│ ├── metadata.js # 网易云 API / 封面本地化 / 元数据补全
|
||||
│ ├── livestream.js # LiveStream 类 + 多路流管理 + 缓存
|
||||
│ ├── scanner.js # audio/ 目录监听与同步
|
||||
│ ├── auth.js # 会话鉴权
|
||||
│ ├── sse.js # SSE 推送
|
||||
│ └── routes/ # API 路由(auth / music / resources / streams / system)
|
||||
├── public/ # 前端(index.html / login.html / css / js / vendor / covers)
|
||||
├── scripts/fetch_vendor.py # 一次性下载前端 CDN 资源到本地
|
||||
├── audio/ # 音频文件
|
||||
├── data.db # SQLite 数据库(自动生成)
|
||||
└── hls/ hls_cache/ # 直播流与切片缓存(自动生成)
|
||||
```
|
||||
|
||||
## API 一览
|
||||
|
||||
所有 `/api/*`(除 `/api/auth/*`)需登录。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/api/auth/login` | 登录(body: `{password}`) |
|
||||
| POST | `/api/auth/logout` | 登出 |
|
||||
| GET | `/api/resources` | 资源列表 |
|
||||
| GET | `/api/music/search?keyword=` | 网易云搜索 |
|
||||
| POST | `/api/music/detail` | 网易云详情 |
|
||||
| POST | `/api/music/download` | 网易云下载入库 |
|
||||
| POST | `/api/music/import` | 本地上传导入(multipart) |
|
||||
| POST | `/api/music/delete` | 删除音频(级联) |
|
||||
| POST | `/api/music/delete-batch` | 批量删除 |
|
||||
| POST | `/api/music/enrich` | 重新匹配元数据 |
|
||||
| GET | `/api/streams` | 流列表 |
|
||||
| POST | `/api/streams` | 创建流 |
|
||||
| DELETE | `/api/streams/:id` | 删除流 |
|
||||
| POST | `/api/streams/:id/start\|stop` | 启停流 |
|
||||
| GET | `/api/streams/:id/playlist` | 播放列表 |
|
||||
| POST | `/api/streams/:id/playlist/add\|remove\|reorder\|clear\|next\|prev\|jump` | 播放列表操作 |
|
||||
| GET | `/api/system/cache` | 缓存信息 |
|
||||
| POST | `/api/system/cache/cleanup` | 手动清理缓存 |
|
||||
| GET | `/api/system/logs?tail=` | 查看日志 |
|
||||
| GET | `/api/events` | SSE 实时推送 |
|
||||
|
||||
## 直播流地址
|
||||
|
||||
- 默认流:`http://127.0.0.1:3000/hls/main/index.m3u8`
|
||||
- 其它流:`http://127.0.0.1:3000/hls/{streamId}/index.m3u8`
|
||||
|
||||
可用 VLC、PotPlayer、ffplay 打开。
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `AUTH_TOKEN` | (空) | 设置后,所有 `/api/*` 接口需携带请求头 `Authorization: Bearer <token>` |
|
||||
| `USE_DISCONTINUITY` | `false` | 设为 `true` 时在歌曲切换处插入 `#EXT-X-DISCONTINUITY` 标签(兼容性更好则保持关闭) |
|
||||
| `PORT` | `3000` | HTTP 服务端口 |
|
||||
| `HLS_TIME` | `5` | 单个 HLS 切片时长(秒),同时决定直播 tick 间隔 |
|
||||
| `SLIDE_WINDOW` | `15` | 直播滑动窗口段数(≈ 窗口秒数 = HLS_TIME × 该值) |
|
||||
| `MAX_CACHE_MB` | `1000` | HLS 缓存上限(MB),超出按热度淘汰 |
|
||||
| `FFMPEG_CONCURRENCY` | `3` | FFmpeg 转码并发数 |
|
||||
| `MAX_RETRIES` | `3` | 单文件转码失败重试上限 |
|
||||
| `FFMPEG_TIMEOUT_MS` | `300000` | 单次 FFmpeg 转码超时(毫秒) |
|
||||
|
||||
设置方式(三平台):
|
||||
|
||||
**Windows PowerShell**
|
||||
|
||||
```powershell
|
||||
$env:AUTH_TOKEN="your_token"; npm start
|
||||
```
|
||||
|
||||
**Windows CMD**
|
||||
|
||||
```cmd
|
||||
set AUTH_TOKEN=your_token && npm start
|
||||
```
|
||||
|
||||
**Linux / macOS**
|
||||
|
||||
```bash
|
||||
AUTH_TOKEN=your_token npm start
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 端口 3000 被占用
|
||||
|
||||
**Windows**
|
||||
|
||||
```powershell
|
||||
# 找到占用 3000 端口的 PID
|
||||
netstat -ano | findstr :3000
|
||||
# 结束该进程(把 <PID> 换成上一条命令查到的数字)
|
||||
taskkill /PID <PID> /F
|
||||
```
|
||||
|
||||
**Linux**
|
||||
|
||||
```bash
|
||||
# 查看占用进程
|
||||
sudo lsof -i :3000
|
||||
# 或
|
||||
sudo ss -tlnp | grep 3000
|
||||
# 结束进程(把 <PID> 换成实际值)
|
||||
kill <PID>
|
||||
```
|
||||
|
||||
### 启动成功但直播流没声音 / 一直缓冲
|
||||
|
||||
1. 确认 `audio/` 目录里确有音频文件。
|
||||
2. 确认 FFmpeg 已正确安装并在 PATH 中:`ffmpeg -version`。
|
||||
3. 看启动日志里 `[PRESEG]` 是否报错——若某首歌转码失败会打印 `[PRESEG] 失败`,可检查该文件是否损坏。
|
||||
4. 首次启动需对所有歌曲转码,耗时随歌曲数量与长度增加,请耐心等待日志出现 `[PRESEG] 完成`。
|
||||
|
||||
### 网易云下载报「无版权或需 VIP」
|
||||
|
||||
正常现象,见上文「网易云音源导入」的版权说明。换一首免费歌曲即可。
|
||||
|
||||
### 局域网内其它设备访问不到
|
||||
|
||||
- 确认目标设备能 ping 通本机 IP。
|
||||
- **Windows**:需在防火墙中放行 Node.js(或放行 3000 端口)。首次启动时若弹出防火墙授权,点「允许」;否则在「Windows 安全中心 → 防火墙 → 允许应用通过防火墙」中手动添加 node.exe。
|
||||
- **Linux**:若有 `ufw` / `firewalld` 需放行端口:
|
||||
|
||||
```bash
|
||||
sudo ufw allow 3000 # ufw
|
||||
sudo firewall-cmd --add-port=3000/tcp --permanent && sudo firewall-cmd --reload # firewalld
|
||||
```
|
||||
|
||||
然后用本机局域网 IP 访问:`http://<本机IP>:3000`。
|
||||
| 变量 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| `PANEL_PASSWORD` | 随机 | 面板登录密码 |
|
||||
| `PORT` | 3000 | 端口 |
|
||||
| `HLS_TIME` | 5 | 切片时长(秒) |
|
||||
| `SLIDE_WINDOW` | 15 | 滑动窗口段数 |
|
||||
| `MAX_CACHE_MB` | 1000 | 缓存上限 |
|
||||
| `FFMPEG_CONCURRENCY` | 3 | 转码并发 |
|
||||
| `SESSION_TTL_HOURS` | 72 | 登录有效期(小时) |
|
||||
| `LOG_LEVEL` | info | 日志级别 |
|
||||
|
||||
## 技术栈
|
||||
|
||||
Node.js · Express · Chokidar · FFmpeg(HLS 切片)· SortableJS · Bootstrap Icons
|
||||
Node.js · Express · better-sqlite3 · Multer · Chokidar · FFmpeg(HLS)· SortableJS · Bootstrap Icons
|
||||
Generated
+67
-1
@@ -8,8 +8,10 @@
|
||||
"name": "music-online-demo",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"chokidar": "^3.6.0",
|
||||
"express": "^4.21.2"
|
||||
"express": "^4.21.2",
|
||||
"multer": "^2.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.14.0"
|
||||
@@ -41,12 +43,30 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/append-field/-/append-field-1.0.0.tgz",
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "13.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
|
||||
"integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@@ -95,6 +115,17 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmmirror.com/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -661,6 +692,24 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/multer/-/multer-2.4.0.tgz",
|
||||
"integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"type-is": "^1.6.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz",
|
||||
@@ -670,6 +719,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "8.9.2",
|
||||
"resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-8.9.2.tgz",
|
||||
"integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
@@ -957,6 +1015,14 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
|
||||
+7
-5
@@ -1,16 +1,18 @@
|
||||
{
|
||||
"name": "music-online-demo",
|
||||
"version": "1.0.0",
|
||||
"description": "音乐在线电台:FFmpeg HLS 直播流服务",
|
||||
"version": "2.0.0",
|
||||
"description": "音乐在线电台:FFmpeg HLS 直播流服务(多路流 + SQLite + 导入 + 鉴权)",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.14.0"
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"chokidar": "^3.6.0",
|
||||
"express": "^4.21.2"
|
||||
"express": "^4.21.2",
|
||||
"multer": "^2.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1748,3 +1748,65 @@
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== v2 新增:频道选择器 / 导入卡片 / 播放中标记 ========== */
|
||||
.stream-select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: rgba(255,255,255,.6) url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6'><path d='M0 0l5 6 5-6z' fill='%23666'/></svg>") no-repeat right 8px center;
|
||||
border: 1px solid rgba(0,0,0,.08);
|
||||
border-radius: 20px;
|
||||
padding: 5px 26px 5px 12px;
|
||||
font-size: .78rem;
|
||||
color: #444;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
max-width: 140px;
|
||||
}
|
||||
.stream-select:hover { background-color: rgba(255,255,255,.85); }
|
||||
|
||||
.import-card {
|
||||
margin-top: 18px;
|
||||
padding: 18px 20px;
|
||||
background: rgba(255,255,255,.5);
|
||||
border: 1px solid rgba(0,0,0,.06);
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.import-header h3 {
|
||||
margin: 0 0 2px;
|
||||
font-size: .95rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--accent, #1f6feb);
|
||||
}
|
||||
.import-header p { margin: 0 0 4px; font-size: .75rem; color: #888; }
|
||||
.import-input {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(0,0,0,.1);
|
||||
border-radius: 9px;
|
||||
font-size: .85rem;
|
||||
outline: none;
|
||||
background: #fff;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.import-input:focus { border-color: var(--accent, #1f6feb); }
|
||||
.import-progress { font-size: .78rem; color: #888; min-height: 16px; }
|
||||
|
||||
.now-badge {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 7px;
|
||||
font-size: .62rem;
|
||||
border-radius: 8px;
|
||||
background: var(--accent, #1f6feb);
|
||||
color: #fff;
|
||||
vertical-align: middle;
|
||||
animation: nowPulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes nowPulse { 0%,100% { opacity: 1; } 50% { opacity: .55; } }
|
||||
|
||||
.btn-icon.danger:hover { color: #f85149; }
|
||||
+22
-24
@@ -9,21 +9,9 @@
|
||||
<meta name="theme-color" content="#f4f5f9" />
|
||||
<title>Xiao の 电台</title>
|
||||
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="vendor/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="vendor/bootstrap-icons.css" />
|
||||
<script src="vendor/Sortable.min.js"></script>
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
@@ -48,15 +36,13 @@
|
||||
>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<span class="cache-pill" id="cacheInfo"
|
||||
><i class="bi bi-database"></i> --</span
|
||||
>
|
||||
<a
|
||||
href="/hls/index.m3u8"
|
||||
class="btn-link-sm"
|
||||
target="_blank"
|
||||
><i class="bi bi-link-45deg"></i> 直播流</a
|
||||
<span class="cache-pill" id="cacheInfo"><i class="bi bi-database"></i> --</span>
|
||||
<select class="stream-select" id="streamSelect" title="切换频道"></select>
|
||||
<a href="#" id="liveLink" class="btn-link-sm" target="_blank" title="打开直播流"
|
||||
><i class="bi bi-link-45deg"></i></a
|
||||
>
|
||||
<button class="btn-link-sm" id="btnLogout" title="退出登录"
|
||||
><i class="bi bi-box-arrow-right"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -137,6 +123,18 @@
|
||||
在网易云音乐中复制歌曲 ID 或完整链接<br />粘贴到上方输入框即可自动识别
|
||||
</div>
|
||||
</div>
|
||||
<!-- Import local audio -->
|
||||
<div class="import-card" id="importCard">
|
||||
<div class="import-header">
|
||||
<h3><i class="bi bi-upload"></i> 本地导入</h3>
|
||||
<p>上传音频文件,自动识别作者与封面(封面存本地)</p>
|
||||
</div>
|
||||
<input type="file" id="importFile" accept="audio/*,.mp3,.m4a,.flac,.wav,.aac,.ogg" hidden />
|
||||
<input type="text" id="importTitle" class="import-input" placeholder="歌曲名(选填,留空则用文件名)" />
|
||||
<input type="text" id="importArtist" class="import-input" placeholder="作者(选填,留空自动匹配)" />
|
||||
<button class="btn-download" id="btnImport"><i class="bi bi-upload"></i> 选择文件并导入</button>
|
||||
<div class="import-progress" id="importProgress"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -332,7 +330,7 @@
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="about-version">
|
||||
v1.0.0 · Built with ♥ ·
|
||||
v2.0.0 · Built with ♥ ·
|
||||
<a
|
||||
href="https://github.com/Xiao-no-love/"
|
||||
target="_blank"
|
||||
|
||||
+572
-745
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,142 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>登录 · 音乐在线电台</title>
|
||||
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--panel: #161b22;
|
||||
--border: #30363d;
|
||||
--text: #e6edf3;
|
||||
--muted: #8b949e;
|
||||
--accent: #1f6feb;
|
||||
--accent-hover: #388bfd;
|
||||
--danger: #f85149;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: radial-gradient(circle at 30% 20%, #1a2332, var(--bg) 60%);
|
||||
color: var(--text);
|
||||
font-family: "Segoe UI", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 40px 32px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,.5);
|
||||
}
|
||||
.logo {
|
||||
text-align: center;
|
||||
font-size: 40px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
h1 {
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
margin: 0 0 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sub {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
input[type="password"]:focus { border-color: var(--accent); }
|
||||
button {
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
padding: 12px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
}
|
||||
button:hover { background: var(--accent-hover); }
|
||||
button:disabled { opacity: .6; cursor: default; }
|
||||
.error {
|
||||
margin-top: 14px;
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
min-height: 18px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">🎵</div>
|
||||
<h1>音乐在线电台</h1>
|
||||
<div class="sub">请输入访问密码</div>
|
||||
<form id="loginForm">
|
||||
<label for="password">密码</label>
|
||||
<input type="password" id="password" autocomplete="current-password" autofocus />
|
||||
<button type="submit" id="submitBtn">登录</button>
|
||||
</form>
|
||||
<div class="error" id="error"></div>
|
||||
</div>
|
||||
<script>
|
||||
const form = document.getElementById("loginForm");
|
||||
const input = document.getElementById("password");
|
||||
const btn = document.getElementById("submitBtn");
|
||||
const err = document.getElementById("error");
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const password = input.value;
|
||||
if (!password) return;
|
||||
btn.disabled = true;
|
||||
err.textContent = "";
|
||||
try {
|
||||
const r = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (r.ok && data.ok) {
|
||||
window.location.href = "/";
|
||||
} else {
|
||||
err.textContent = data.error || "登录失败";
|
||||
btn.disabled = false;
|
||||
}
|
||||
} catch (e) {
|
||||
err.textContent = "网络错误,请重试";
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""一次性脚本:把前端 CDN 资源下载到 public/vendor/,实现完全本地化。"""
|
||||
import os, re, sys, urllib.request, urllib.parse
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
VENDOR = os.path.join(ROOT, "public", "vendor")
|
||||
os.makedirs(VENDOR, exist_ok=True)
|
||||
|
||||
UA = {"User-Agent": "Mozilla/5.0"}
|
||||
|
||||
def download(url, dest):
|
||||
req = urllib.request.Request(url, headers=UA)
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
data = r.read()
|
||||
with open(dest, "wb") as f:
|
||||
f.write(data)
|
||||
print(f" {os.path.relpath(dest, ROOT)} ({len(data)} B)")
|
||||
return data
|
||||
|
||||
def main():
|
||||
print("下载 Bootstrap CSS...")
|
||||
download(
|
||||
"https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css",
|
||||
os.path.join(VENDOR, "bootstrap.min.css"),
|
||||
)
|
||||
|
||||
print("下载 Bootstrap Icons CSS...")
|
||||
css = download(
|
||||
"https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css",
|
||||
os.path.join(VENDOR, "bootstrap-icons.css"),
|
||||
).decode("utf-8", "ignore")
|
||||
|
||||
# 找出字体 url 并本地化
|
||||
CSS_URL = "https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css"
|
||||
urls = re.findall(r'url\(["\']?([^"\')]+)["\']?\)', css)
|
||||
for u in set(urls):
|
||||
if u.startswith("data:"):
|
||||
continue
|
||||
full = urllib.parse.urljoin(CSS_URL, u)
|
||||
fname = os.path.basename(full.split("?")[0])
|
||||
print(f"下载字体 {fname}...")
|
||||
download(full, os.path.join(VENDOR, fname))
|
||||
css = css.replace(u, "./" + fname)
|
||||
|
||||
with open(os.path.join(VENDOR, "bootstrap-icons.css"), "w", encoding="utf-8") as f:
|
||||
f.write(css)
|
||||
print(" 已重写 bootstrap-icons.css 字体路径")
|
||||
|
||||
print("下载 SortableJS...")
|
||||
download(
|
||||
"https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js",
|
||||
os.path.join(VENDOR, "Sortable.min.js"),
|
||||
)
|
||||
|
||||
print("\n完成。vendor 目录:")
|
||||
for f in sorted(os.listdir(VENDOR)):
|
||||
print(" ", f)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
"use strict";
|
||||
const crypto = require("crypto");
|
||||
const config = require("./config");
|
||||
const logger = require("./logger");
|
||||
const store = require("./store");
|
||||
|
||||
const COOKIE_NAME = "mr_session";
|
||||
|
||||
// 密码来源:环境变量 PANEL_PASSWORD;未设置则首次启动随机生成并打印
|
||||
let panelPassword = config.PANEL_PASSWORD;
|
||||
let generated = false;
|
||||
if (!panelPassword) {
|
||||
panelPassword = crypto.randomBytes(6).toString("hex");
|
||||
generated = true;
|
||||
}
|
||||
|
||||
function getPassword() {
|
||||
return panelPassword;
|
||||
}
|
||||
function isGenerated() {
|
||||
return generated;
|
||||
}
|
||||
|
||||
function parseCookies(header) {
|
||||
const out = {};
|
||||
if (!header) return out;
|
||||
for (const part of header.split(";")) {
|
||||
const idx = part.indexOf("=");
|
||||
if (idx > -1) out[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function createSession() {
|
||||
const token = crypto.randomBytes(24).toString("hex");
|
||||
const expiresAt = Date.now() + config.SESSION_TTL_MS;
|
||||
store.createSession(token, expiresAt);
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
function verifyLogin(password) {
|
||||
// 定长比较,防时序攻击
|
||||
const a = Buffer.from(String(password));
|
||||
const b = Buffer.from(panelPassword);
|
||||
if (a.length !== b.length) return false;
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
// 从请求里取有效会话
|
||||
function getValidSession(req) {
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
const token = cookies[COOKIE_NAME];
|
||||
if (!token) return null;
|
||||
return store.getSession(token);
|
||||
}
|
||||
|
||||
function isAuthed(req) {
|
||||
return !!getValidSession(req);
|
||||
}
|
||||
|
||||
function setSessionCookie(res, token, expiresAt) {
|
||||
const maxAge = Math.floor((expiresAt - Date.now()) / 1000);
|
||||
res.setHeader(
|
||||
"Set-Cookie",
|
||||
`${COOKIE_NAME}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`
|
||||
);
|
||||
}
|
||||
function clearSessionCookie(res) {
|
||||
res.setHeader("Set-Cookie", `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
|
||||
}
|
||||
|
||||
// 中间件:保护 /api(登录相关除外)与面板
|
||||
function requireAuth(req, res, next) {
|
||||
if (isAuthed(req)) return next();
|
||||
const isApi = (req.originalUrl || req.url || "").startsWith("/api/");
|
||||
if (isApi) return res.status(401).json({ ok: false, error: "未登录" });
|
||||
return res.redirect("/login");
|
||||
}
|
||||
|
||||
function logout(req) {
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
const token = cookies[COOKIE_NAME];
|
||||
if (token) store.deleteSession(token);
|
||||
}
|
||||
|
||||
function initPasswordLog() {
|
||||
if (generated) {
|
||||
logger.warn("========================================");
|
||||
logger.warn(`[AUTH] 未设置 PANEL_PASSWORD,已生成随机登录密码: ${panelPassword}`);
|
||||
logger.warn("[AUTH] 请用此密码登录面板,或在环境变量中设置 PANEL_PASSWORD 固定密码");
|
||||
logger.warn("========================================");
|
||||
} else {
|
||||
logger.info("[AUTH] 已启用面板鉴权(PANEL_PASSWORD 已设置)");
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
COOKIE_NAME,
|
||||
getPassword,
|
||||
isGenerated,
|
||||
createSession,
|
||||
verifyLogin,
|
||||
getValidSession,
|
||||
isAuthed,
|
||||
setSessionCookie,
|
||||
clearSessionCookie,
|
||||
requireAuth,
|
||||
logout,
|
||||
initPasswordLog,
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
"use strict";
|
||||
const path = require("path");
|
||||
|
||||
const ROOT = path.join(__dirname, "..");
|
||||
|
||||
function envInt(name, fallback) {
|
||||
const v = parseInt(process.env[name], 10);
|
||||
return Number.isFinite(v) && v > 0 ? v : fallback;
|
||||
}
|
||||
function envStr(name, fallback) {
|
||||
const v = process.env[name];
|
||||
return v === undefined || v === "" ? fallback : v;
|
||||
}
|
||||
function envBool(name, fallback) {
|
||||
const v = process.env[name];
|
||||
if (v === undefined) return fallback;
|
||||
return v === "true" || v === "1";
|
||||
}
|
||||
|
||||
const config = {
|
||||
ROOT,
|
||||
AUDIO_DIR: path.join(ROOT, "audio"),
|
||||
HLS_CACHE_DIR: path.join(ROOT, "hls_cache"),
|
||||
HLS_OUTPUT_DIR: path.join(ROOT, "hls"),
|
||||
PUBLIC_DIR: path.join(ROOT, "public"),
|
||||
COVERS_DIR: path.join(ROOT, "public", "covers"),
|
||||
VENDOR_DIR: path.join(ROOT, "public", "vendor"),
|
||||
DB_FILE: path.join(ROOT, "data.db"),
|
||||
UPLOAD_TMP_DIR: path.join(ROOT, "tmp", "uploads"),
|
||||
|
||||
HTTP_PORT: envInt("PORT", 3000),
|
||||
HLS_TIME: envInt("HLS_TIME", 5),
|
||||
SLIDE_WINDOW_SIZE: envInt("SLIDE_WINDOW", 15),
|
||||
MAX_CACHE_SIZE: envInt("MAX_CACHE_MB", 1000) * 1024 * 1024,
|
||||
MAX_FFMPEG_CONCURRENT: envInt("FFMPEG_CONCURRENCY", 3),
|
||||
MAX_RETRIES: envInt("MAX_RETRIES", 3),
|
||||
FFMPEG_TIMEOUT: envInt("FFMPEG_TIMEOUT_MS", 5 * 60 * 1000),
|
||||
USE_DISCONTINUITY: envBool("USE_DISCONTINUITY", false),
|
||||
|
||||
LOG_LEVEL: envStr("LOG_LEVEL", "info"),
|
||||
LOG_FILE: path.join(ROOT, "server.log"),
|
||||
|
||||
PANEL_PASSWORD: envStr("PANEL_PASSWORD", ""),
|
||||
SESSION_TTL_MS: envInt("SESSION_TTL_HOURS", 72) * 3600 * 1000,
|
||||
};
|
||||
|
||||
config.LIVE_TICK_INTERVAL = config.HLS_TIME * 1000;
|
||||
|
||||
module.exports = config;
|
||||
@@ -0,0 +1,91 @@
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const Database = require("better-sqlite3");
|
||||
const config = require("./config");
|
||||
const logger = require("./logger");
|
||||
|
||||
let db = null;
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS songs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
file TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
artist TEXT NOT NULL DEFAULT '',
|
||||
cover TEXT NOT NULL DEFAULT '',
|
||||
duration REAL,
|
||||
file_size INTEGER NOT NULL DEFAULT 0,
|
||||
heat INTEGER NOT NULL DEFAULT 0,
|
||||
cache_status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS streams (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
current_pos INTEGER NOT NULL DEFAULT 0,
|
||||
media_seq INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stream_playlist (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
stream_id TEXT NOT NULL,
|
||||
song_id INTEGER NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
FOREIGN KEY(stream_id) REFERENCES streams(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(song_id) REFERENCES songs(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sp_stream ON stream_playlist(stream_id, position);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_exp ON sessions(expires_at);
|
||||
`;
|
||||
|
||||
function init() {
|
||||
fs.mkdirSync(path.dirname(config.DB_FILE), { recursive: true });
|
||||
db = new Database(config.DB_FILE);
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("synchronous = NORMAL");
|
||||
db.pragma("foreign_keys = ON");
|
||||
db.pragma("busy_timeout = 5000");
|
||||
db.exec(SCHEMA);
|
||||
logger.info(`[DB] 已打开 ${config.DB_FILE} (WAL)`);
|
||||
|
||||
ensureDefaultStream();
|
||||
purgeExpiredSessions();
|
||||
return db;
|
||||
}
|
||||
|
||||
function getDb() {
|
||||
if (!db) throw new Error("DB 未初始化,请先调用 db.init()");
|
||||
return db;
|
||||
}
|
||||
|
||||
function ensureDefaultStream() {
|
||||
const row = db.prepare("SELECT id FROM streams WHERE id = ?").get("main");
|
||||
if (!row) {
|
||||
db.prepare(
|
||||
"INSERT INTO streams (id, name, enabled, created_at) VALUES (?, ?, 1, ?)"
|
||||
).run("main", "主电台", Date.now());
|
||||
logger.info("[DB] 创建默认流 main");
|
||||
}
|
||||
}
|
||||
|
||||
function purgeExpiredSessions() {
|
||||
const r = db.prepare("DELETE FROM sessions WHERE expires_at < ?").run(Date.now());
|
||||
if (r.changes > 0) logger.info(`[DB] 清理过期会话 ${r.changes} 条`);
|
||||
}
|
||||
|
||||
module.exports = { init, getDb };
|
||||
@@ -0,0 +1,447 @@
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const config = require("./config");
|
||||
const logger = require("./logger");
|
||||
const store = require("./store");
|
||||
const media = require("./media");
|
||||
const { fileHash, atomicWriteSync } = require("./util");
|
||||
|
||||
/* ==================== 缓存状态管理(全局共享,因为切片只依赖歌曲) ==================== */
|
||||
const cacheStatus = new Map(); // file -> 'pending'|'ready'|'error'
|
||||
const retryCount = new Map(); // file -> number
|
||||
const segmentsCache = new Map(); // file -> segments[]
|
||||
const inFlight = new Set(); // 正在转码中的 file(避免重复入队)
|
||||
|
||||
let cacheTotalSize = 0;
|
||||
let onCacheChange = null; // 回调:cache-status 变化广播
|
||||
|
||||
function setCacheChangeListener(fn) {
|
||||
onCacheChange = fn;
|
||||
}
|
||||
function getCacheTotalSize() {
|
||||
return cacheTotalSize;
|
||||
}
|
||||
function addCacheSize(n) {
|
||||
cacheTotalSize += n;
|
||||
}
|
||||
|
||||
function setStatus(file, status) {
|
||||
cacheStatus.set(file, status);
|
||||
store.setCacheStatus(file, status);
|
||||
if (onCacheChange) onCacheChange(file, status);
|
||||
}
|
||||
function getStatus(file) {
|
||||
return cacheStatus.get(file) || "unknown";
|
||||
}
|
||||
|
||||
function getSegments(file) {
|
||||
if (segmentsCache.has(file)) return segmentsCache.get(file);
|
||||
const segs = media.loadSongSegments(file);
|
||||
if (segs) segmentsCache.set(file, segs);
|
||||
return segs;
|
||||
}
|
||||
function invalidateSegments(file) {
|
||||
segmentsCache.delete(file);
|
||||
}
|
||||
|
||||
// 触发预生成(带去重与重试上限)
|
||||
// 说明:pending 表示“待生成”,不阻止入队;用 inFlight 防止重复入队
|
||||
function triggerPreGenerate(file) {
|
||||
if (cacheStatus.get(file) === "ready") return;
|
||||
if (inFlight.has(file)) return;
|
||||
const retries = retryCount.get(file) || 0;
|
||||
if (retries >= config.MAX_RETRIES) {
|
||||
logger.warn(`[PRESEG] 跳过(已重试 ${config.MAX_RETRIES} 次): ${file}`);
|
||||
setStatus(file, "error");
|
||||
return;
|
||||
}
|
||||
retryCount.set(file, retries + 1);
|
||||
inFlight.add(file);
|
||||
setStatus(file, "pending");
|
||||
|
||||
const filePath = path.join(config.AUDIO_DIR, file);
|
||||
media
|
||||
.triggerPreGenerate(filePath, file)
|
||||
.then((res) => {
|
||||
inFlight.delete(file);
|
||||
if (res && res.size) cacheTotalSize += res.size;
|
||||
invalidateSegments(file);
|
||||
retryCount.delete(file);
|
||||
setStatus(file, "ready");
|
||||
if (onCacheSizeCheck) onCacheSizeCheck();
|
||||
})
|
||||
.catch(() => {
|
||||
inFlight.delete(file);
|
||||
setStatus(file, "error");
|
||||
});
|
||||
}
|
||||
|
||||
let onCacheSizeCheck = null;
|
||||
function setCacheSizeCheck(fn) {
|
||||
onCacheSizeCheck = fn;
|
||||
}
|
||||
|
||||
// 启动时扫描已有缓存
|
||||
function initCaches(songs) {
|
||||
cacheTotalSize = media.scanExistingCaches(songs);
|
||||
for (const song of songs) {
|
||||
const dir = media.getCacheDir(song.file);
|
||||
if (fs.existsSync(dir)) {
|
||||
const segs = media.loadSongSegments(song.file);
|
||||
if (segs) {
|
||||
cacheStatus.set(song.file, "ready");
|
||||
store.setCacheStatus(song.file, "ready");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
cacheStatus.set(song.file, "pending");
|
||||
store.setCacheStatus(song.file, "pending");
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 缓存清理 ==================== */
|
||||
function enforceCacheSizeLimit(allFiles) {
|
||||
if (cacheTotalSize <= config.MAX_CACHE_SIZE) return;
|
||||
logger.info(`[CACHE] ${(cacheTotalSize / 1024 / 1024).toFixed(1)}MB 超限,开始清理`);
|
||||
|
||||
// 收集所有流正在播放/窗口内的歌曲,作为保护集
|
||||
const protectedFiles = new Set();
|
||||
for (const s of streams.values()) {
|
||||
for (const f of s.protectedFiles()) protectedFiles.add(f);
|
||||
}
|
||||
|
||||
const items = [];
|
||||
const dirs = fs.readdirSync(config.HLS_CACHE_DIR);
|
||||
for (const entry of dirs) {
|
||||
const dir = path.join(config.HLS_CACHE_DIR, entry);
|
||||
try {
|
||||
if (!fs.statSync(dir).isDirectory()) continue;
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
let origFile = allFiles.find((f) => fileHash(f) === entry);
|
||||
const size = require("./util").getDirSize(dir);
|
||||
const song = origFile ? store.getSongByFile(origFile) : null;
|
||||
items.push({
|
||||
dir,
|
||||
size,
|
||||
origFile,
|
||||
isProtected: origFile ? protectedFiles.has(origFile) : false,
|
||||
heat: song ? song.heat || 0 : 0,
|
||||
});
|
||||
}
|
||||
items.sort((a, b) => {
|
||||
if (a.isProtected !== b.isProtected) return a.isProtected ? 1 : -1;
|
||||
return a.heat - b.heat;
|
||||
});
|
||||
for (const item of items) {
|
||||
if (cacheTotalSize <= config.MAX_CACHE_SIZE) break;
|
||||
logger.info(`[CACHE] 清理: ${path.basename(item.dir)} (${(item.size / 1024 / 1024).toFixed(1)}MB)`);
|
||||
fs.rmSync(item.dir, { recursive: true, force: true });
|
||||
cacheTotalSize = Math.max(0, cacheTotalSize - item.size);
|
||||
if (item.origFile) {
|
||||
invalidateSegments(item.origFile);
|
||||
setStatus(item.origFile, "pending");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== LiveStream ==================== */
|
||||
const streams = new Map(); // id -> LiveStream
|
||||
|
||||
class LiveStream {
|
||||
constructor(id, name) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.liveSegments = []; // { duration, path, songId(file), songFile }
|
||||
this.mediaSequence = 0;
|
||||
this.currentIdx = 0; // 在播放列表行中的索引
|
||||
this.currentSongSegmentIdx = 0;
|
||||
this.timer = null;
|
||||
this.outDir = path.join(config.HLS_OUTPUT_DIR, id);
|
||||
fs.mkdirSync(this.outDir, { recursive: true });
|
||||
}
|
||||
|
||||
get m3u8Path() {
|
||||
return path.join(this.outDir, "index.m3u8");
|
||||
}
|
||||
|
||||
// 当前受保护的歌曲文件(窗口内 + 接下来2首)
|
||||
protectedFiles() {
|
||||
const set = new Set();
|
||||
for (const seg of this.liveSegments) set.add(seg.songFile);
|
||||
const rows = store.getPlaylistRows(this.id);
|
||||
for (let i = 0; i < Math.min(3, rows.length); i++) {
|
||||
const idx = (this.currentIdx + i) % rows.length;
|
||||
if (rows[idx]) set.add(rows[idx].file);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
playlistRows() {
|
||||
return store.getPlaylistRows(this.id);
|
||||
}
|
||||
|
||||
ensureNotEmpty() {
|
||||
let rows = this.playlistRows();
|
||||
if (rows.length === 0) {
|
||||
const songs = store.getAllSongs();
|
||||
if (songs.length > 0) {
|
||||
const pick = songs[Math.floor(Math.random() * songs.length)];
|
||||
store.addToPlaylist(this.id, pick.id);
|
||||
this.currentIdx = 0;
|
||||
this.currentSongSegmentIdx = 0;
|
||||
rows = this.playlistRows();
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
clampIdx(rows) {
|
||||
if (rows.length === 0) {
|
||||
this.currentIdx = 0;
|
||||
this.currentSongSegmentIdx = 0;
|
||||
return;
|
||||
}
|
||||
if (this.currentIdx < 0) this.currentIdx = 0;
|
||||
if (this.currentIdx >= rows.length) {
|
||||
this.currentIdx = 0;
|
||||
this.currentSongSegmentIdx = 0;
|
||||
}
|
||||
}
|
||||
|
||||
advance() {
|
||||
const allSongs = store.getAllSongs();
|
||||
if (allSongs.length === 0) return;
|
||||
|
||||
let rows = this.ensureNotEmpty();
|
||||
if (rows.length === 0) return;
|
||||
|
||||
let safety = rows.length + 2;
|
||||
while (safety-- > 0) {
|
||||
this.clampIdx(rows);
|
||||
const row = rows[this.currentIdx];
|
||||
if (!row) return;
|
||||
const file = row.file;
|
||||
|
||||
const status = getStatus(file);
|
||||
if (status !== "ready") {
|
||||
if (status !== "pending") triggerPreGenerate(file);
|
||||
this.currentIdx = (this.currentIdx + 1) % rows.length;
|
||||
this.currentSongSegmentIdx = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
const segments = getSegments(file);
|
||||
if (!segments || segments.length === 0) {
|
||||
this.currentIdx = (this.currentIdx + 1) % rows.length;
|
||||
this.currentSongSegmentIdx = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 歌曲播完 → 从播放列表移除该条目
|
||||
if (this.currentSongSegmentIdx >= segments.length) {
|
||||
const finishedName = file;
|
||||
store.removePlaylistEntry(this.id, row.entry_id);
|
||||
invalidateSegments(file);
|
||||
this.currentSongSegmentIdx = 0;
|
||||
|
||||
rows = this.ensureNotEmpty();
|
||||
if (rows.length === 0) return;
|
||||
this.clampIdx(rows);
|
||||
if (onPlaylistChange) onPlaylistChange(this.id);
|
||||
if (onSongFinished) onSongFinished(this.id, finishedName);
|
||||
|
||||
const nf = rows[this.currentIdx] && rows[this.currentIdx].file;
|
||||
if (nf && getStatus(nf) !== "ready") triggerPreGenerate(nf);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 正常追加切片
|
||||
const seg = segments[this.currentSongSegmentIdx];
|
||||
const songId = fileHash(file);
|
||||
this.liveSegments.push({
|
||||
duration: seg.duration,
|
||||
path: path.posix.join("/hls_cache", songId, seg.fileName),
|
||||
songId,
|
||||
songFile: file,
|
||||
});
|
||||
while (this.liveSegments.length > config.SLIDE_WINDOW_SIZE) {
|
||||
this.liveSegments.shift();
|
||||
this.mediaSequence++;
|
||||
}
|
||||
this.currentSongSegmentIdx++;
|
||||
this.writePlaylist();
|
||||
store.setStreamState(this.id, { media_seq: this.mediaSequence });
|
||||
return;
|
||||
}
|
||||
logger.warn(`[LIVE:${this.id}] 所有歌曲均不可用`);
|
||||
}
|
||||
|
||||
writePlaylist() {
|
||||
if (this.liveSegments.length === 0) {
|
||||
atomicWriteSync(
|
||||
this.m3u8Path,
|
||||
`#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:${config.HLS_TIME + 1}\n` +
|
||||
`#EXT-X-INDEPENDENT-SEGMENTS\n#EXT-X-MEDIA-SEQUENCE:0\n`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const maxDur = this.liveSegments.reduce((m, s) => Math.max(m, s.duration), config.HLS_TIME);
|
||||
const targetDur = Math.max(config.HLS_TIME + 1, Math.ceil(maxDur) + 1);
|
||||
let content = "#EXTM3U\n#EXT-X-VERSION:3\n";
|
||||
content += `#EXT-X-TARGETDURATION:${targetDur}\n`;
|
||||
content += "#EXT-X-INDEPENDENT-SEGMENTS\n";
|
||||
content += `#EXT-X-MEDIA-SEQUENCE:${this.mediaSequence}\n`;
|
||||
for (let i = 0; i < this.liveSegments.length; i++) {
|
||||
if (config.USE_DISCONTINUITY && i > 0 && this.liveSegments[i].songId !== this.liveSegments[i - 1].songId) {
|
||||
content += "#EXT-X-DISCONTINUITY\n";
|
||||
}
|
||||
content += `#EXTINF:${this.liveSegments[i].duration.toFixed(3)},\n${this.liveSegments[i].path}\n`;
|
||||
}
|
||||
atomicWriteSync(this.m3u8Path, content);
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
let attempts = 0;
|
||||
const target = Math.floor(config.SLIDE_WINDOW_SIZE / 2);
|
||||
while (this.liveSegments.length < target && attempts < config.SLIDE_WINDOW_SIZE * 2) {
|
||||
const prev = this.liveSegments.length;
|
||||
this.advance();
|
||||
attempts++;
|
||||
if (this.liveSegments.length === prev && this.liveSegments.length === 0) break;
|
||||
}
|
||||
this.timer = setInterval(() => this.advance(), config.LIVE_TICK_INTERVAL);
|
||||
logger.info(
|
||||
`[LIVE:${this.id}] 已启动 | 切片 ${config.HLS_TIME}s | 窗口 ${config.SLIDE_WINDOW_SIZE} 段 | 预填充 ${this.liveSegments.length} 段`
|
||||
);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
logger.info(`[LIVE:${this.id}] 已停止`);
|
||||
}
|
||||
|
||||
resetCursor() {
|
||||
this.currentSongSegmentIdx = 0;
|
||||
}
|
||||
|
||||
next() {
|
||||
const rows = this.playlistRows();
|
||||
if (rows.length === 0) return null;
|
||||
this.currentIdx = (this.currentIdx + 1) % rows.length;
|
||||
this.currentSongSegmentIdx = 0;
|
||||
const f = rows[this.currentIdx].file;
|
||||
if (getStatus(f) !== "ready") triggerPreGenerate(f);
|
||||
return f;
|
||||
}
|
||||
prev() {
|
||||
const rows = this.playlistRows();
|
||||
if (rows.length === 0) return null;
|
||||
this.currentIdx = (this.currentIdx - 1 + rows.length) % rows.length;
|
||||
this.currentSongSegmentIdx = 0;
|
||||
const f = rows[this.currentIdx].file;
|
||||
if (getStatus(f) !== "ready") triggerPreGenerate(f);
|
||||
return f;
|
||||
}
|
||||
jump(index) {
|
||||
const rows = this.playlistRows();
|
||||
if (index < 0 || index >= rows.length) return null;
|
||||
this.currentIdx = index;
|
||||
this.currentSongSegmentIdx = 0;
|
||||
const f = rows[this.currentIdx].file;
|
||||
if (getStatus(f) !== "ready") triggerPreGenerate(f);
|
||||
return f;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 回调 ==================== */
|
||||
let onPlaylistChange = null;
|
||||
let onSongFinished = null;
|
||||
function setStreamCallbacks({ playlistChange, songFinished }) {
|
||||
onPlaylistChange = playlistChange;
|
||||
onSongFinished = songFinished;
|
||||
}
|
||||
|
||||
/* ==================== 管理器 ==================== */
|
||||
function loadStreams() {
|
||||
const rows = store.listStreams();
|
||||
for (const r of rows) {
|
||||
const s = new LiveStream(r.id, r.name);
|
||||
s.currentIdx = r.current_pos || 0;
|
||||
s.mediaSequence = r.media_seq || 0;
|
||||
streams.set(r.id, s);
|
||||
}
|
||||
logger.info(`[LIVE] 载入 ${streams.size} 路流: ${[...streams.keys()].join(", ")}`);
|
||||
}
|
||||
|
||||
function createStream(id, name) {
|
||||
if (streams.has(id)) throw new Error("流已存在");
|
||||
store.createStream(id, name);
|
||||
const s = new LiveStream(id, name);
|
||||
streams.set(id, s);
|
||||
s.start();
|
||||
return s;
|
||||
}
|
||||
|
||||
function deleteStream(id) {
|
||||
if (id === "main") throw new Error("默认流不可删除");
|
||||
const s = streams.get(id);
|
||||
if (s) {
|
||||
s.stop();
|
||||
streams.delete(id);
|
||||
}
|
||||
store.deleteStream(id);
|
||||
// 删输出目录
|
||||
const dir = path.join(config.HLS_OUTPUT_DIR, id);
|
||||
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function getStream(id) {
|
||||
return streams.get(id);
|
||||
}
|
||||
function listStreams() {
|
||||
return [...streams.values()].map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
running: !!s.timer,
|
||||
m3u8: `/hls/${s.id}/index.m3u8`,
|
||||
}));
|
||||
}
|
||||
|
||||
function startAll() {
|
||||
for (const s of streams.values()) s.start();
|
||||
}
|
||||
function stopAll() {
|
||||
for (const s of streams.values()) s.stop();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LiveStream,
|
||||
streams,
|
||||
loadStreams,
|
||||
createStream,
|
||||
deleteStream,
|
||||
getStream,
|
||||
listStreams,
|
||||
startAll,
|
||||
stopAll,
|
||||
// cache API
|
||||
getStatus,
|
||||
setStatus,
|
||||
getSegments,
|
||||
invalidateSegments,
|
||||
triggerPreGenerate,
|
||||
initCaches,
|
||||
enforceCacheSizeLimit,
|
||||
getCacheTotalSize,
|
||||
addCacheSize,
|
||||
setCacheChangeListener,
|
||||
setCacheSizeCheck,
|
||||
setStreamCallbacks,
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
const config = require("./config");
|
||||
|
||||
const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 };
|
||||
const levelNum = LEVELS[config.LOG_LEVEL] || LEVELS.info;
|
||||
|
||||
let stream = null;
|
||||
try {
|
||||
stream = fs.createWriteStream(config.LOG_FILE, { flags: "a" });
|
||||
} catch (_) {}
|
||||
|
||||
const ring = [];
|
||||
const RING_MAX = 500;
|
||||
|
||||
function fmt(level, args) {
|
||||
const ts = new Date().toISOString();
|
||||
const msg = args
|
||||
.map((a) => {
|
||||
if (typeof a === "string") return a;
|
||||
if (a instanceof Error) return a.stack || a.message;
|
||||
try {
|
||||
return JSON.stringify(a);
|
||||
} catch (_) {
|
||||
return String(a);
|
||||
}
|
||||
})
|
||||
.join(" ");
|
||||
return `[${ts}] [${level.toUpperCase()}] ${msg}`;
|
||||
}
|
||||
|
||||
function emit(level, args) {
|
||||
if (LEVELS[level] < levelNum) return;
|
||||
const line = fmt(level, args);
|
||||
const fn =
|
||||
level === "error" ? console.error : level === "warn" ? console.warn : console.log;
|
||||
fn(line);
|
||||
if (stream) stream.write(line + "\n");
|
||||
ring.push(line);
|
||||
if (ring.length > RING_MAX) ring.shift();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
debug: (...a) => emit("debug", a),
|
||||
info: (...a) => emit("info", a),
|
||||
warn: (...a) => emit("warn", a),
|
||||
error: (...a) => emit("error", a),
|
||||
tail: (n = 100) => ring.slice(-n),
|
||||
};
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawn } = require("child_process");
|
||||
const config = require("./config");
|
||||
const logger = require("./logger");
|
||||
const { fileHash, getDirSize } = require("./util");
|
||||
|
||||
// 追踪活跃的 FFmpeg/ffprobe 子进程,便于优雅退出时统一 kill
|
||||
const activeProcs = new Set();
|
||||
function trackProc(proc) {
|
||||
activeProcs.add(proc);
|
||||
proc.on("close", () => activeProcs.delete(proc));
|
||||
proc.on("error", () => activeProcs.delete(proc));
|
||||
return proc;
|
||||
}
|
||||
function killAllProcs() {
|
||||
let n = 0;
|
||||
for (const p of activeProcs) {
|
||||
try {
|
||||
p.kill("SIGKILL");
|
||||
n++;
|
||||
} catch (_) {}
|
||||
}
|
||||
activeProcs.clear();
|
||||
if (n) logger.warn(`[MEDIA] 已终止 ${n} 个残留 FFmpeg 进程`);
|
||||
}
|
||||
class TaskQueue {
|
||||
constructor(max) {
|
||||
this.max = max;
|
||||
this.running = 0;
|
||||
this.queue = [];
|
||||
}
|
||||
push(fn) {
|
||||
this.queue.push(fn);
|
||||
this._drain();
|
||||
}
|
||||
_drain() {
|
||||
while (this.running < this.max && this.queue.length > 0) {
|
||||
this.running++;
|
||||
const task = this.queue.shift();
|
||||
task()
|
||||
.catch((e) => logger.error("[QUEUE]", e.message))
|
||||
.finally(() => {
|
||||
this.running--;
|
||||
this._drain();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const ffmpegQueue = new TaskQueue(config.MAX_FFMPEG_CONCURRENT);
|
||||
|
||||
/* ==================== 缓存目录 ==================== */
|
||||
function getCacheDir(file) {
|
||||
return path.join(config.HLS_CACHE_DIR, fileHash(file));
|
||||
}
|
||||
|
||||
/* ==================== ffprobe ==================== */
|
||||
function probeAudio(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = trackProc(spawn("ffprobe", [
|
||||
"-v", "error",
|
||||
"-select_streams", "a:0",
|
||||
"-show_entries", "stream=codec_name,sample_rate,channels:format=duration",
|
||||
"-of", "json",
|
||||
filePath,
|
||||
]));
|
||||
let out = "", err = "";
|
||||
proc.stdout.on("data", (d) => (out += d));
|
||||
proc.stderr.on("data", (d) => (err += d));
|
||||
proc.on("close", (code) => {
|
||||
if (code !== 0) return reject(new Error(err));
|
||||
try {
|
||||
const data = JSON.parse(out);
|
||||
const s = data.streams?.[0];
|
||||
if (!s) return reject(new Error("无音频流"));
|
||||
const dur =
|
||||
data.format && data.format.duration ? parseFloat(data.format.duration) : null;
|
||||
resolve({
|
||||
codec: s.codec_name,
|
||||
sampleRate: parseInt(s.sample_rate, 10),
|
||||
channels: s.channels,
|
||||
duration: dur,
|
||||
});
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
proc.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
/* ==================== 解析切片 m3u8 ==================== */
|
||||
function loadSongSegments(file) {
|
||||
const m3u8 = path.join(getCacheDir(file), "index.m3u8");
|
||||
if (!fs.existsSync(m3u8)) return null;
|
||||
try {
|
||||
const lines = fs
|
||||
.readFileSync(m3u8, "utf8")
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
const segments = [];
|
||||
let dur = config.HLS_TIME;
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("#EXTINF:")) {
|
||||
dur = parseFloat(line.split(":")[1].replace(",", ""));
|
||||
} else if (!line.startsWith("#") && line.endsWith(".ts")) {
|
||||
segments.push({ duration: dur, fileName: path.basename(line) });
|
||||
}
|
||||
}
|
||||
return segments.length ? segments : null;
|
||||
} catch (e) {
|
||||
logger.error("[SEG]", file, e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 预生成切片 ==================== */
|
||||
let onCacheReady = null;
|
||||
let onCacheError = null;
|
||||
function setCacheCallbacks({ ready, error }) {
|
||||
onCacheReady = ready;
|
||||
onCacheError = error;
|
||||
}
|
||||
|
||||
async function buildAudioArgs(filePath) {
|
||||
try {
|
||||
const probe = await probeAudio(filePath);
|
||||
if (probe.codec === "aac" && probe.sampleRate === 44100 && probe.channels <= 2) {
|
||||
logger.info(`[PRESEG] copy: ${path.basename(filePath)}`);
|
||||
return { args: ["-c:a", "copy"], duration: probe.duration };
|
||||
}
|
||||
logger.info(
|
||||
`[PRESEG] 转码: ${path.basename(filePath)} (${probe.codec} ${probe.sampleRate}Hz)`
|
||||
);
|
||||
return {
|
||||
args: ["-c:a", "aac", "-b:a", "192k", "-ar", "44100", "-ac", "2", "-profile:a", "aac_low"],
|
||||
duration: probe.duration,
|
||||
};
|
||||
} catch (_) {
|
||||
return {
|
||||
args: ["-c:a", "aac", "-b:a", "192k", "-ar", "44100", "-ac", "2", "-profile:a", "aac_low"],
|
||||
duration: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 真正执行一次转码(内部用,返回 Promise<{size}>);调用方负责队列并发
|
||||
function doGenerate(filePath, file) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const cacheDir = getCacheDir(file);
|
||||
if (fs.existsSync(cacheDir)) fs.rmSync(cacheDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
|
||||
const { args: audioArgs } = await buildAudioArgs(filePath);
|
||||
const outputM3u8 = path.join(cacheDir, "index.m3u8");
|
||||
const args = [
|
||||
"-y",
|
||||
"-fflags", "+genpts+discardcorrupt",
|
||||
"-i", filePath,
|
||||
...audioArgs,
|
||||
"-vn",
|
||||
"-avoid_negative_ts", "make_zero",
|
||||
"-hls_time", String(config.HLS_TIME),
|
||||
"-hls_list_size", "0",
|
||||
"-hls_segment_type", "mpegts",
|
||||
"-hls_flags", "independent_segments",
|
||||
"-max_muxing_queue_size", "2048",
|
||||
"-f", "hls",
|
||||
outputM3u8,
|
||||
];
|
||||
const proc = trackProc(spawn("ffmpeg", args));
|
||||
let stderrBuf = "";
|
||||
proc.stderr.on("data", (d) => {
|
||||
stderrBuf += d.toString();
|
||||
if (stderrBuf.length > 4096) stderrBuf = stderrBuf.slice(-4096);
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill("SIGKILL");
|
||||
logger.error(`[PRESEG] 超时终止: ${path.basename(filePath)}`);
|
||||
}, config.FFMPEG_TIMEOUT);
|
||||
|
||||
proc.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) {
|
||||
const size = getDirSize(cacheDir);
|
||||
logger.info(`[PRESEG] 完成: ${path.basename(filePath)}`);
|
||||
if (onCacheReady) onCacheReady(file, size);
|
||||
resolve({ size });
|
||||
} else {
|
||||
if (fs.existsSync(cacheDir)) fs.rmSync(cacheDir, { recursive: true, force: true });
|
||||
logger.error(`[PRESEG] 失败 code=${code}: ${path.basename(filePath)}`);
|
||||
if (onCacheError) onCacheError(file, stderrBuf);
|
||||
reject(new Error(`FFmpeg exit ${code}`));
|
||||
}
|
||||
});
|
||||
proc.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
if (onCacheError) onCacheError(file, err.message);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 外部调用:把 doGenerate 丢进并发队列,返回真正在完成后 resolve 的 Promise
|
||||
function triggerPreGenerate(filePath, file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
ffmpegQueue.push(() => doGenerate(filePath, file).then(resolve, reject));
|
||||
});
|
||||
}
|
||||
|
||||
/* ==================== 启动扫描 ==================== */
|
||||
function scanExistingCaches(songs) {
|
||||
let total = 0;
|
||||
let valid = 0;
|
||||
for (const song of songs) {
|
||||
const dir = getCacheDir(song.file);
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
const segs = loadSongSegments(song.file);
|
||||
if (segs && segs.length > 0) {
|
||||
const allValid = segs.every((s) => {
|
||||
const p = path.join(dir, s.fileName);
|
||||
try {
|
||||
return fs.existsSync(p) && fs.statSync(p).size > 0;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (allValid) {
|
||||
total += getDirSize(dir);
|
||||
valid++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
logger.info(`[CACHE] 启动扫描: ${valid}/${songs.length} 个缓存有效,总 ${(total / 1024 / 1024).toFixed(1)}MB`);
|
||||
return total;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ffmpegQueue,
|
||||
getCacheDir,
|
||||
probeAudio,
|
||||
loadSongSegments,
|
||||
triggerPreGenerate,
|
||||
scanExistingCaches,
|
||||
setCacheCallbacks,
|
||||
killAllProcs,
|
||||
};
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const http = require("http");
|
||||
const https = require("https");
|
||||
const config = require("./config");
|
||||
const logger = require("./logger");
|
||||
const store = require("./store");
|
||||
const media = require("./media");
|
||||
const { fileHash, sanitizeFileName, parseFileName, isAudioFile } = require("./util");
|
||||
|
||||
const NETEASE_UA =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36";
|
||||
const NETEASE_REFERER = "http://music.163.com/";
|
||||
|
||||
/* ==================== HTTP 工具 ==================== */
|
||||
function request(url, redirects = 0) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const lib = url.startsWith("https:") ? https : http;
|
||||
const req = lib.get(
|
||||
url,
|
||||
{ headers: { "User-Agent": NETEASE_UA, Referer: NETEASE_REFERER } },
|
||||
(res) => {
|
||||
const code = res.statusCode;
|
||||
if ([301, 302, 303, 307, 308].includes(code) && res.headers.location) {
|
||||
res.resume();
|
||||
const loc = res.headers.location;
|
||||
if (loc.includes("/404")) return reject(new Error("该歌曲无版权或需 VIP,无法下载"));
|
||||
if (redirects >= 5) return reject(new Error("重定向次数过多"));
|
||||
return resolve(request(new URL(loc, url).toString(), redirects + 1));
|
||||
}
|
||||
if (code !== 200) {
|
||||
res.resume();
|
||||
return reject(new Error(`HTTP ${code}`));
|
||||
}
|
||||
resolve(res);
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.setTimeout(15000, () => req.destroy(new Error("请求超时")));
|
||||
});
|
||||
}
|
||||
|
||||
function readBody(res) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (d) => (body += d));
|
||||
res.on("end", () => resolve(body));
|
||||
res.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
/* ==================== 网易云 API ==================== */
|
||||
async function fetchNetEaseDetail(id) {
|
||||
const url = `http://music.163.com/api/song/detail/?id=${id}&ids=[${id}]`;
|
||||
const res = await request(url);
|
||||
const json = JSON.parse((await readBody(res)).replace(/^/, ""));
|
||||
const song = json.songs && json.songs[0];
|
||||
if (!song) throw new Error("未找到歌曲(可能是无版权/灰色歌曲)");
|
||||
return {
|
||||
name: song.name,
|
||||
artist: (song.artists && song.artists[0] && song.artists[0].name) || "",
|
||||
albumImage: (song.album && song.album.blurPicUrl) || "",
|
||||
};
|
||||
}
|
||||
|
||||
async function searchNetEase(keyword, offset = 0, limit = 20) {
|
||||
const url =
|
||||
"http://music.163.com/api/search/get/web?csrf_token=hlpretag=&hlposttag=" +
|
||||
"&s=" + encodeURIComponent(keyword) +
|
||||
"&type=1&offset=" + offset + "&total=true&limit=" + limit;
|
||||
const res = await request(url);
|
||||
const json = JSON.parse((await readBody(res)).replace(/^/, ""));
|
||||
const songs = (json.result && json.result.songs) || [];
|
||||
const list = songs
|
||||
.filter((s) => Number(s.fee) !== 1)
|
||||
.map((s) => {
|
||||
const item = {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
artists: (s.artists || []).map((a) => a.name).filter(Boolean),
|
||||
duration: s.duration,
|
||||
};
|
||||
if (s.alias && s.alias.length) item.alias = s.alias;
|
||||
return item;
|
||||
});
|
||||
return { total: list.length, list };
|
||||
}
|
||||
|
||||
// 用 歌名(+作者) 匹配网易云,返回 {artist, albumImage, name}
|
||||
async function matchNetEase(name, guessArtist) {
|
||||
const q = guessArtist ? `${name} ${guessArtist}` : name;
|
||||
try {
|
||||
const r = await searchNetEase(q, 0, 10);
|
||||
if (!r.list.length) return null;
|
||||
const hit = r.list[0];
|
||||
const artist = (hit.artists && hit.artists[0]) || guessArtist || "";
|
||||
// 用详情拿封面
|
||||
let coverUrl = "";
|
||||
try {
|
||||
const d = await fetchNetEaseDetail(hit.id);
|
||||
coverUrl = d.albumImage || "";
|
||||
return { name: hit.name, artist: d.artist || artist, albumImage: coverUrl };
|
||||
} catch (_) {
|
||||
return { name: hit.name, artist, albumImage: "" };
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn("[MATCH] 网易云匹配失败:", e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 封面本地化 ==================== */
|
||||
function extFromContentType(ct) {
|
||||
if (!ct) return ".jpg";
|
||||
if (ct.includes("png")) return ".png";
|
||||
if (ct.includes("webp")) return ".webp";
|
||||
return ".jpg";
|
||||
}
|
||||
|
||||
// 下载远程图片到 public/covers/{key}{ext},返回 web 路径 /covers/xxx
|
||||
function downloadCover(url, key) {
|
||||
return new Promise((resolve) => {
|
||||
if (!url) return resolve("");
|
||||
fs.mkdirSync(config.COVERS_DIR, { recursive: true });
|
||||
const lib = url.startsWith("https:") ? https : http;
|
||||
const req = lib.get(
|
||||
url,
|
||||
{ headers: { "User-Agent": NETEASE_UA, Referer: NETEASE_REFERER } },
|
||||
(res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
res.resume();
|
||||
return resolve("");
|
||||
}
|
||||
const ext = extFromContentType(res.headers["content-type"]);
|
||||
const fname = `${key}${ext}`;
|
||||
const dest = path.join(config.COVERS_DIR, fname);
|
||||
const ws = fs.createWriteStream(dest);
|
||||
res.pipe(ws);
|
||||
ws.on("finish", () => resolve(`/covers/${fname}`));
|
||||
ws.on("error", () => resolve(""));
|
||||
}
|
||||
);
|
||||
req.on("error", () => resolve(""));
|
||||
req.setTimeout(15000, () => req.destroy());
|
||||
});
|
||||
}
|
||||
|
||||
/* ==================== 元数据补全 ==================== */
|
||||
// 为某个已入库音频补全元数据(歌名/作者/封面/时长)
|
||||
// 策略:文件名解析打底 → 用户提供优先 → 缺作者/封面时匹配网易云
|
||||
async function enrichSong(file, opts = {}) {
|
||||
let song = store.getSongByFile(file);
|
||||
const filePath = path.join(config.AUDIO_DIR, file);
|
||||
const parsed = parseFileName(file);
|
||||
const fields = {};
|
||||
|
||||
// 歌名:用户提供 > 已存 > 文件名解析
|
||||
if (opts.name) fields.name = opts.name;
|
||||
else if (!song || !song.name) fields.name = parsed.name;
|
||||
|
||||
// 作者:用户提供 > 文件名解析 > (稍后匹配)
|
||||
if (opts.artist) fields.artist = opts.artist;
|
||||
else if (!song || !song.artist) {
|
||||
if (parsed.artist) fields.artist = parsed.artist;
|
||||
}
|
||||
|
||||
// 时长
|
||||
try {
|
||||
const probe = await media.probeAudio(filePath);
|
||||
if (probe.duration != null) fields.duration = probe.duration;
|
||||
} catch (_) {}
|
||||
try {
|
||||
fields.file_size = fs.statSync(filePath).size;
|
||||
} catch (_) {}
|
||||
|
||||
// 若缺作者或缺封面,尝试网易云匹配
|
||||
const needArtist = !(fields.artist || (song && song.artist));
|
||||
const needCover = !(song && song.cover) && !opts.cover;
|
||||
if ((needArtist || needCover) && !opts.skipMatch) {
|
||||
const guessArtist = fields.artist || (song && song.artist) || parsed.artist || "";
|
||||
const hit = await matchNetEase(fields.name || (song && song.name) || parsed.name, guessArtist);
|
||||
if (hit) {
|
||||
if (needArtist && hit.artist) fields.artist = hit.artist;
|
||||
if (needCover && hit.albumImage) {
|
||||
const cover = await downloadCover(hit.albumImage, fileHash(file));
|
||||
if (cover) fields.cover = cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (song) {
|
||||
store.updateSong(file, fields);
|
||||
return store.getSongByFile(file);
|
||||
} else {
|
||||
return store.upsertSong({ file, ...fields });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
request,
|
||||
fetchNetEaseDetail,
|
||||
searchNetEase,
|
||||
matchNetEase,
|
||||
downloadCover,
|
||||
enrichSong,
|
||||
sanitizeFileName,
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
"use strict";
|
||||
const express = require("express");
|
||||
const auth = require("../auth");
|
||||
const logger = require("../logger");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post("/login", (req, res) => {
|
||||
const { password } = req.body || {};
|
||||
if (!password) return res.status(400).json({ ok: false, error: "请输入密码" });
|
||||
if (!auth.verifyLogin(password)) {
|
||||
logger.warn(`[AUTH] 登录失败 from ${req.ip}`);
|
||||
return res.status(401).json({ ok: false, error: "密码错误" });
|
||||
}
|
||||
const { token, expiresAt } = auth.createSession();
|
||||
auth.setSessionCookie(res, token, expiresAt);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post("/logout", (req, res) => {
|
||||
auth.logout(req);
|
||||
auth.clearSessionCookie(res);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get("/me", (req, res) => {
|
||||
res.json({ ok: true, authed: auth.isAuthed(req) });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,233 @@
|
||||
"use strict";
|
||||
const express = require("express");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const multer = require("multer");
|
||||
const config = require("../config");
|
||||
const logger = require("../logger");
|
||||
const store = require("../store");
|
||||
const media = require("../media");
|
||||
const live = require("../livestream");
|
||||
const meta = require("../metadata");
|
||||
const { sanitizeFileName, isValidFileName, isAudioFile, fileHash, getDirSize } = require("../util");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/* ==================== 网易云搜索/详情 ==================== */
|
||||
router.get("/search", async (req, res) => {
|
||||
const { keyword } = req.query;
|
||||
if (!keyword || !String(keyword).trim())
|
||||
return res.status(400).json({ ok: false, error: "缺少搜索关键词" });
|
||||
try {
|
||||
const r = await meta.searchNetEase(String(keyword).trim());
|
||||
res.json({ ok: true, ...r });
|
||||
} catch (e) {
|
||||
res.status(400).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/detail", async (req, res) => {
|
||||
const { id } = req.body || {};
|
||||
if (!id || !/^\d+$/.test(String(id)))
|
||||
return res.status(400).json({ ok: false, error: "无效 id" });
|
||||
try {
|
||||
res.json({ ok: true, ...(await meta.fetchNetEaseDetail(String(id))) });
|
||||
} catch (e) {
|
||||
res.status(400).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
/* ==================== 网易云下载入库 ==================== */
|
||||
async function downloadFromNetEase(id, meta_in) {
|
||||
let m = {
|
||||
name: meta_in.name,
|
||||
artist: meta_in.artist || "",
|
||||
albumImage: meta_in.albumImage || "",
|
||||
};
|
||||
if (!m.name) {
|
||||
const info = await meta.fetchNetEaseDetail(String(id));
|
||||
m = { name: info.name, artist: info.artist || "", albumImage: info.albumImage || "" };
|
||||
}
|
||||
const filename = sanitizeFileName(m.artist ? `${m.artist} - ${m.name}` : m.name);
|
||||
if (!filename) throw new Error("缺少歌名");
|
||||
const file = filename + ".mp3";
|
||||
if (fs.existsSync(path.join(config.AUDIO_DIR, file))) throw new Error(`已存在: ${file}`);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const url = `http://music.163.com/song/media/outer/url?id=${id}.mp3`;
|
||||
meta
|
||||
.request(url)
|
||||
.then((res) => {
|
||||
const filePath = path.join(config.AUDIO_DIR, file);
|
||||
const ws = fs.createWriteStream(filePath);
|
||||
ws.on("finish", () => {
|
||||
const ctype = res.headers["content-type"] || "";
|
||||
if (!ctype.startsWith("audio")) {
|
||||
fs.unlinkSync(filePath);
|
||||
return reject(new Error(`返回非音频内容 (${ctype}),歌曲可能无版权`));
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
ws.on("error", reject);
|
||||
res.on("error", reject);
|
||||
res.pipe(ws);
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
|
||||
// 封面本地化
|
||||
let cover = "";
|
||||
if (m.albumImage) cover = await meta.downloadCover(m.albumImage, fileHash(file));
|
||||
store.upsertSong({ file, name: m.name, artist: m.artist, cover });
|
||||
logger.info(`[IMPORT] 网易云下载完成: ${file}`);
|
||||
return file;
|
||||
}
|
||||
|
||||
router.post("/download", async (req, res) => {
|
||||
const { id, name, artist, albumImage } = req.body || {};
|
||||
if (!id || !/^\d+$/.test(String(id)))
|
||||
return res.status(400).json({ ok: false, error: "无效 id" });
|
||||
try {
|
||||
const file = await downloadFromNetEase(String(id), { name, artist, albumImage });
|
||||
res.json({ ok: true, file });
|
||||
} catch (e) {
|
||||
res.status(400).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
/* ==================== 本地音频导入(上传) ==================== */
|
||||
fs.mkdirSync(config.UPLOAD_TMP_DIR, { recursive: true });
|
||||
const upload = multer({
|
||||
dest: config.UPLOAD_TMP_DIR,
|
||||
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
const ok = isAudioFile(file.originalname) || file.mimetype.startsWith("audio");
|
||||
cb(ok ? null : new Error("仅支持音频文件"), ok);
|
||||
},
|
||||
});
|
||||
|
||||
router.post("/import", upload.single("audio"), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ ok: false, error: "未收到文件" });
|
||||
const tmpPath = req.file.path;
|
||||
try {
|
||||
const userTitle = (req.body.title || "").trim();
|
||||
const userArtist = (req.body.artist || "").trim();
|
||||
const origName = Buffer.from(req.file.originalname, "latin1").toString("utf8");
|
||||
const ext = (path.extname(origName) || ".mp3").toLowerCase();
|
||||
|
||||
// 目标文件名:作者 - 歌名(若有),否则用原始名
|
||||
let baseName;
|
||||
if (userTitle) {
|
||||
baseName = sanitizeFileName(userArtist ? `${userArtist} - ${userTitle}` : userTitle);
|
||||
} else {
|
||||
baseName = sanitizeFileName(path.basename(origName, path.extname(origName)));
|
||||
}
|
||||
let finalFile = baseName + ext;
|
||||
// 重名加序号
|
||||
let n = 1;
|
||||
while (fs.existsSync(path.join(config.AUDIO_DIR, finalFile))) {
|
||||
finalFile = `${baseName} (${n++})${ext}`;
|
||||
}
|
||||
|
||||
const dest = path.join(config.AUDIO_DIR, finalFile);
|
||||
fs.renameSync(tmpPath, dest);
|
||||
logger.info(`[IMPORT] 本地导入: ${finalFile}`);
|
||||
|
||||
// 元数据补全(用户歌名优先,缺作者/封面则匹配网易云)
|
||||
const song = await meta.enrichSong(finalFile, {
|
||||
name: userTitle || undefined,
|
||||
artist: userArtist || undefined,
|
||||
});
|
||||
res.json({ ok: true, file: finalFile, song });
|
||||
} catch (e) {
|
||||
try {
|
||||
if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
|
||||
} catch (_) {}
|
||||
logger.error("[IMPORT] 失败:", e.message);
|
||||
res.status(400).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
/* ==================== 删除音频(级联清理) ==================== */
|
||||
router.post("/delete", (req, res) => {
|
||||
const { file } = req.body || {};
|
||||
if (!isValidFileName(file)) return res.status(400).json({ ok: false, error: "无效文件名" });
|
||||
|
||||
const song = store.getSongByFile(file);
|
||||
const audioPath = path.join(config.AUDIO_DIR, file);
|
||||
|
||||
// 1. 删音频本体
|
||||
try {
|
||||
if (fs.existsSync(audioPath)) fs.unlinkSync(audioPath);
|
||||
} catch (e) {
|
||||
return res.status(500).json({ ok: false, error: "删除文件失败: " + e.message });
|
||||
}
|
||||
|
||||
// 2. 删切片缓存目录
|
||||
const cacheDir = media.getCacheDir(file);
|
||||
if (fs.existsSync(cacheDir)) {
|
||||
const sz = getDirSize(cacheDir);
|
||||
fs.rmSync(cacheDir, { recursive: true, force: true });
|
||||
live.addCacheSize(-sz);
|
||||
}
|
||||
live.invalidateSegments(file);
|
||||
|
||||
// 3. 删 DB 记录(外键 CASCADE 会清掉 stream_playlist 引用)
|
||||
store.deleteSongByFile(file);
|
||||
|
||||
// 4. 各流若播放列表被清空,补一首
|
||||
for (const s of live.streams.values()) {
|
||||
s.ensureNotEmpty();
|
||||
s.clampIdx(s.playlistRows());
|
||||
}
|
||||
|
||||
logger.info(`[DELETE] 已删除: ${file}`);
|
||||
res.json({ ok: true, file });
|
||||
});
|
||||
|
||||
/* ==================== 批量删除 ==================== */
|
||||
router.post("/delete-batch", (req, res) => {
|
||||
const { files } = req.body || {};
|
||||
if (!Array.isArray(files)) return res.status(400).json({ ok: false, error: "无效数据" });
|
||||
const results = [];
|
||||
for (const file of files) {
|
||||
if (!isValidFileName(file)) {
|
||||
results.push({ file, ok: false, error: "无效文件名" });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const audioPath = path.join(config.AUDIO_DIR, file);
|
||||
if (fs.existsSync(audioPath)) fs.unlinkSync(audioPath);
|
||||
const cacheDir = media.getCacheDir(file);
|
||||
if (fs.existsSync(cacheDir)) {
|
||||
live.addCacheSize(-getDirSize(cacheDir));
|
||||
fs.rmSync(cacheDir, { recursive: true, force: true });
|
||||
}
|
||||
live.invalidateSegments(file);
|
||||
store.deleteSongByFile(file);
|
||||
results.push({ file, ok: true });
|
||||
} catch (e) {
|
||||
results.push({ file, ok: false, error: e.message });
|
||||
}
|
||||
}
|
||||
for (const s of live.streams.values()) {
|
||||
s.ensureNotEmpty();
|
||||
s.clampIdx(s.playlistRows());
|
||||
}
|
||||
res.json({ ok: true, results });
|
||||
});
|
||||
|
||||
/* ==================== 元数据重匹配(补封面/作者) ==================== */
|
||||
router.post("/enrich", async (req, res) => {
|
||||
const { file, skipMatch } = req.body || {};
|
||||
if (!isValidFileName(file)) return res.status(400).json({ ok: false, error: "无效文件名" });
|
||||
if (!store.getSongByFile(file)) return res.status(404).json({ ok: false, error: "歌曲不存在" });
|
||||
try {
|
||||
const song = await meta.enrichSong(file, { skipMatch: !!skipMatch });
|
||||
res.json({ ok: true, song });
|
||||
} catch (e) {
|
||||
res.status(400).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,34 @@
|
||||
"use strict";
|
||||
const express = require("express");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const config = require("../config");
|
||||
const store = require("../store");
|
||||
const live = require("../livestream");
|
||||
const { getDirSize } = require("../util");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/", (req, res) => {
|
||||
const songs = store.getAllSongs();
|
||||
res.json({
|
||||
ok: true,
|
||||
list: songs.map((s) => ({
|
||||
id: s.id,
|
||||
file: s.file,
|
||||
title: s.name || s.file,
|
||||
artist: s.artist || "",
|
||||
albumImage: s.cover || "",
|
||||
duration: s.duration || null,
|
||||
fileSize: s.file_size || 0,
|
||||
heat: s.heat || 0,
|
||||
cacheStatus: live.getStatus(s.file),
|
||||
cacheSize: (() => {
|
||||
const d = path.join(config.HLS_CACHE_DIR, require("../util").fileHash(s.file));
|
||||
return fs.existsSync(d) ? getDirSize(d) : 0;
|
||||
})(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,161 @@
|
||||
"use strict";
|
||||
const express = require("express");
|
||||
const store = require("../store");
|
||||
const live = require("../livestream");
|
||||
const logger = require("../logger");
|
||||
const sse = require("../sse");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/* ==================== 流管理 ==================== */
|
||||
router.get("/", (req, res) => {
|
||||
res.json({ ok: true, list: live.listStreams() });
|
||||
});
|
||||
|
||||
function validStreamId(id) {
|
||||
return typeof id === "string" && /^[a-zA-Z0-9_-]{1,32}$/.test(id);
|
||||
}
|
||||
|
||||
router.post("/", (req, res) => {
|
||||
const { id, name } = req.body || {};
|
||||
if (!validStreamId(id))
|
||||
return res.status(400).json({ ok: false, error: "流 ID 仅允许字母数字_-,1~32位" });
|
||||
if (live.getStream(id)) return res.status(409).json({ ok: false, error: "流已存在" });
|
||||
try {
|
||||
const s = live.createStream(id, name || id);
|
||||
logger.info(`[STREAM] 创建流 ${id}`);
|
||||
res.json({ ok: true, stream: { id: s.id, name: s.name, m3u8: `/hls/${s.id}/index.m3u8` } });
|
||||
} catch (e) {
|
||||
res.status(400).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/:id", (req, res) => {
|
||||
const { id } = req.params;
|
||||
if (id === "main") return res.status(400).json({ ok: false, error: "默认流不可删除" });
|
||||
if (!live.getStream(id)) return res.status(404).json({ ok: false, error: "流不存在" });
|
||||
live.deleteStream(id);
|
||||
logger.info(`[STREAM] 删除流 ${id}`);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post("/:id/start", (req, res) => {
|
||||
const s = live.getStream(req.params.id);
|
||||
if (!s) return res.status(404).json({ ok: false, error: "流不存在" });
|
||||
s.start();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
router.post("/:id/stop", (req, res) => {
|
||||
const s = live.getStream(req.params.id);
|
||||
if (!s) return res.status(404).json({ ok: false, error: "流不存在" });
|
||||
s.stop();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
/* ==================== 播放列表(按流) ==================== */
|
||||
function streamGuard(req, res) {
|
||||
const s = live.getStream(req.params.id);
|
||||
if (!s) {
|
||||
res.status(404).json({ ok: false, error: "流不存在" });
|
||||
return null;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function snapshot(streamId) {
|
||||
const rows = store.getPlaylistRows(streamId);
|
||||
const s = live.getStream(streamId);
|
||||
return rows.map((r, idx) => ({
|
||||
entryId: r.entry_id,
|
||||
position: r.position,
|
||||
file: r.file,
|
||||
title: r.name || r.file,
|
||||
artist: r.artist || "",
|
||||
albumImage: r.cover || "",
|
||||
duration: r.duration || null,
|
||||
heat: r.heat || 0,
|
||||
cacheStatus: live.getStatus(r.file),
|
||||
isCurrent: s ? idx === s.currentIdx : false,
|
||||
}));
|
||||
}
|
||||
|
||||
router.get("/:id/playlist", (req, res) => {
|
||||
const s = streamGuard(req, res);
|
||||
if (!s) return;
|
||||
res.json({ ok: true, list: snapshot(req.params.id) });
|
||||
});
|
||||
|
||||
router.post("/:id/playlist/add", (req, res) => {
|
||||
const s = streamGuard(req, res);
|
||||
if (!s) return;
|
||||
const { file } = req.body || {};
|
||||
const song = store.getSongByFile(file);
|
||||
if (!song) return res.status(404).json({ ok: false, error: "资源不存在" });
|
||||
store.addToPlaylist(req.params.id, song.id);
|
||||
store.incrementHeat(file);
|
||||
if (live.getStatus(file) !== "ready") live.triggerPreGenerate(file);
|
||||
sse.broadcast("playlist-update", { streamId: req.params.id });
|
||||
res.json({ ok: true, list: snapshot(req.params.id) });
|
||||
});
|
||||
|
||||
router.post("/:id/playlist/remove", (req, res) => {
|
||||
const s = streamGuard(req, res);
|
||||
if (!s) return;
|
||||
const { entryId } = req.body || {};
|
||||
if (typeof entryId !== "number")
|
||||
return res.status(400).json({ ok: false, error: "无效 entryId" });
|
||||
const rows = store.getPlaylistRows(req.params.id);
|
||||
const idx = rows.findIndex((r) => r.entry_id === entryId);
|
||||
if (idx === -1) return res.status(404).json({ ok: false, error: "条目不存在" });
|
||||
if (idx === s.currentIdx) s.resetCursor();
|
||||
else if (idx < s.currentIdx) s.currentIdx--;
|
||||
store.removePlaylistEntry(req.params.id, entryId);
|
||||
s.ensureNotEmpty();
|
||||
s.clampIdx(s.playlistRows());
|
||||
res.json({ ok: true, list: snapshot(req.params.id) });
|
||||
});
|
||||
|
||||
router.post("/:id/playlist/reorder", (req, res) => {
|
||||
const s = streamGuard(req, res);
|
||||
if (!s) return;
|
||||
const { order } = req.body || {};
|
||||
if (!Array.isArray(order)) return res.status(400).json({ ok: false, error: "无效数据" });
|
||||
const rows = store.getPlaylistRows(req.params.id);
|
||||
const validIds = new Set(rows.map((r) => r.entry_id));
|
||||
const filtered = order.filter((id) => validIds.has(id));
|
||||
store.setPlaylistOrder(req.params.id, filtered);
|
||||
res.json({ ok: true, list: snapshot(req.params.id) });
|
||||
});
|
||||
|
||||
router.post("/:id/playlist/clear", (req, res) => {
|
||||
const s = streamGuard(req, res);
|
||||
if (!s) return;
|
||||
store.clearPlaylist(req.params.id);
|
||||
s.currentIdx = 0;
|
||||
s.resetCursor();
|
||||
s.ensureNotEmpty();
|
||||
res.json({ ok: true, list: snapshot(req.params.id) });
|
||||
});
|
||||
|
||||
router.post("/:id/playlist/next", (req, res) => {
|
||||
const s = streamGuard(req, res);
|
||||
if (!s) return;
|
||||
const f = s.next();
|
||||
res.json({ ok: true, current: f, list: snapshot(req.params.id) });
|
||||
});
|
||||
router.post("/:id/playlist/prev", (req, res) => {
|
||||
const s = streamGuard(req, res);
|
||||
if (!s) return;
|
||||
const f = s.prev();
|
||||
res.json({ ok: true, current: f, list: snapshot(req.params.id) });
|
||||
});
|
||||
router.post("/:id/playlist/jump", (req, res) => {
|
||||
const s = streamGuard(req, res);
|
||||
if (!s) return;
|
||||
const { index } = req.body || {};
|
||||
const f = s.jump(index);
|
||||
if (f === null) return res.status(400).json({ ok: false, error: "无效索引" });
|
||||
res.json({ ok: true, current: f, list: snapshot(req.params.id) });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,52 @@
|
||||
"use strict";
|
||||
const express = require("express");
|
||||
const config = require("../config");
|
||||
const logger = require("../logger");
|
||||
const store = require("../store");
|
||||
const live = require("../livestream");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/cache", (req, res) => {
|
||||
const total = live.getCacheTotalSize();
|
||||
res.json({
|
||||
ok: true,
|
||||
total,
|
||||
limit: config.MAX_CACHE_SIZE,
|
||||
usedPercent: ((total / config.MAX_CACHE_SIZE) * 100).toFixed(2),
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/cache/cleanup", (req, res) => {
|
||||
const all = store.getAllSongs().map((s) => s.file);
|
||||
live.enforceCacheSizeLimit(all);
|
||||
res.json({ ok: true, total: live.getCacheTotalSize(), limit: config.MAX_CACHE_SIZE });
|
||||
});
|
||||
|
||||
router.get("/logs", (req, res) => {
|
||||
const tail = parseInt(req.query.tail, 10) || 100;
|
||||
res.json({ ok: true, lines: logger.tail(tail) });
|
||||
});
|
||||
|
||||
router.get("/config", (req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
config: {
|
||||
HLS_TIME: config.HLS_TIME,
|
||||
SLIDE_WINDOW_SIZE: config.SLIDE_WINDOW_SIZE,
|
||||
MAX_CACHE_SIZE: config.MAX_CACHE_SIZE,
|
||||
MAX_FFMPEG_CONCURRENT: config.MAX_FFMPEG_CONCURRENT,
|
||||
HTTP_PORT: config.HTTP_PORT,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/settings", (req, res) => {
|
||||
res.json({ ok: true, settings: store.getSetting("app", {}) });
|
||||
});
|
||||
router.post("/settings", (req, res) => {
|
||||
store.setSetting("app", req.body || {});
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,81 @@
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const chokidar = require("chokidar");
|
||||
const config = require("./config");
|
||||
const logger = require("./logger");
|
||||
const store = require("./store");
|
||||
const live = require("./livestream");
|
||||
const meta = require("./metadata");
|
||||
const { isAudioFile } = require("./util");
|
||||
|
||||
let watcher = null;
|
||||
let debounce = null;
|
||||
|
||||
// 扫描 audio 目录,同步 DB:新增入库、删除清理
|
||||
function scanAudioFiles() {
|
||||
const files = fs.readdirSync(config.AUDIO_DIR).filter(isAudioFile);
|
||||
const existing = new Set(store.getAllSongs().map((s) => s.file));
|
||||
|
||||
// 新增
|
||||
for (const f of files) {
|
||||
if (!existing.has(f)) {
|
||||
const parsed = meta.sanitizeFileName;
|
||||
const p = require("./util").parseFileName(f);
|
||||
store.upsertSong({ file: f, name: p.name, artist: p.artist });
|
||||
logger.info(`[SCAN] 新增资源: ${f}`);
|
||||
}
|
||||
}
|
||||
// 移除(文件已不在磁盘)
|
||||
const onDisk = new Set(files);
|
||||
for (const f of existing) {
|
||||
if (!onDisk.has(f)) {
|
||||
store.deleteSongByFile(f);
|
||||
logger.info(`[SCAN] 移除失效资源: ${f}`);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function scheduleScan() {
|
||||
if (debounce) clearTimeout(debounce);
|
||||
debounce = setTimeout(async () => {
|
||||
try {
|
||||
scanAudioFiles();
|
||||
const songs = store.getAllSongs();
|
||||
// 补元数据 + 预生成缺失缓存
|
||||
for (const s of songs) {
|
||||
if (!s.duration || !s.cover || !s.artist) {
|
||||
await meta.enrichSong(s.file).catch(() => {});
|
||||
}
|
||||
if (live.getStatus(s.file) !== "ready" && live.getStatus(s.file) !== "pending") {
|
||||
live.triggerPreGenerate(s.file);
|
||||
}
|
||||
}
|
||||
live.enforceCacheSizeLimit(songs.map((s) => s.file));
|
||||
for (const st of live.streams.values()) {
|
||||
st.ensureNotEmpty();
|
||||
st.clampIdx(st.playlistRows());
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("[SCAN] 出错:", e.message);
|
||||
}
|
||||
}, 800);
|
||||
}
|
||||
|
||||
function startWatcher() {
|
||||
watcher = chokidar.watch(config.AUDIO_DIR, {
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: { stabilityThreshold: 500 },
|
||||
});
|
||||
watcher.on("add", scheduleScan);
|
||||
watcher.on("unlink", scheduleScan);
|
||||
logger.info("[SCAN] 目录监听已启动");
|
||||
}
|
||||
|
||||
function closeWatcher() {
|
||||
if (watcher) return watcher.close();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
module.exports = { scanAudioFiles, startWatcher, closeWatcher, scheduleScan };
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
"use strict";
|
||||
const express = require("express");
|
||||
const path = require("path");
|
||||
const config = require("./config");
|
||||
const logger = require("./logger");
|
||||
const db = require("./db");
|
||||
const store = require("./store");
|
||||
const live = require("./livestream");
|
||||
const media = require("./media");
|
||||
const sse = require("./sse");
|
||||
const auth = require("./auth");
|
||||
const scanner = require("./scanner");
|
||||
|
||||
const authRoutes = require("./routes/auth");
|
||||
const musicRoutes = require("./routes/music");
|
||||
const resourceRoutes = require("./routes/resources");
|
||||
const streamRoutes = require("./routes/streams");
|
||||
const systemRoutes = require("./routes/system");
|
||||
|
||||
function bootstrap() {
|
||||
// 目录
|
||||
for (const dir of [config.AUDIO_DIR, config.HLS_CACHE_DIR, config.HLS_OUTPUT_DIR, config.PUBLIC_DIR, config.COVERS_DIR, config.UPLOAD_TMP_DIR]) {
|
||||
require("fs").mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// DB
|
||||
db.init();
|
||||
auth.initPasswordLog();
|
||||
|
||||
// 扫描资源入库
|
||||
scanner.scanAudioFiles();
|
||||
|
||||
// 初始化缓存状态
|
||||
const songs = store.getAllSongs();
|
||||
live.initCaches(songs);
|
||||
|
||||
// 缓存回调
|
||||
live.setCacheChangeListener((file, status) => {
|
||||
sse.broadcast("cache-status", { file, status });
|
||||
if (status === "ready") {
|
||||
const song = store.getSongByFile(file);
|
||||
if (song && song.duration == null) {
|
||||
media.probeAudio(path.join(config.AUDIO_DIR, file)).then((p) => {
|
||||
if (p.duration) store.updateSong(file, { duration: p.duration });
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
live.setCacheSizeCheck(() => {
|
||||
live.enforceCacheSizeLimit(store.getAllSongs().map((s) => s.file));
|
||||
});
|
||||
live.setStreamCallbacks({
|
||||
playlistChange: (streamId) => sse.broadcast("playlist-update", { streamId }),
|
||||
songFinished: (streamId, file) => sse.broadcast("song-finished", { streamId, file }),
|
||||
});
|
||||
|
||||
// 预生成缺失缓存
|
||||
for (const s of songs) {
|
||||
if (live.getStatus(s.file) !== "ready") live.triggerPreGenerate(s.file);
|
||||
}
|
||||
|
||||
// 载入并启动流
|
||||
live.loadStreams();
|
||||
live.startAll();
|
||||
|
||||
// 目录监听
|
||||
scanner.startWatcher();
|
||||
|
||||
return songs;
|
||||
}
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use((req, res, next) => {
|
||||
res.header("Access-Control-Allow-Origin", "*");
|
||||
res.header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
||||
res.header("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
res.header("Access-Control-Allow-Credentials", "true");
|
||||
if (req.method === "OPTIONS") return res.sendStatus(200);
|
||||
next();
|
||||
});
|
||||
app.use(express.json());
|
||||
|
||||
// 登录页(静态)
|
||||
app.get("/login", (req, res) => {
|
||||
res.sendFile(path.join(config.PUBLIC_DIR, "login.html"));
|
||||
});
|
||||
|
||||
// 登录相关 API(不需要鉴权)
|
||||
app.use("/api/auth", authRoutes);
|
||||
|
||||
// 登出后重定向
|
||||
app.get("/logout", (req, res) => {
|
||||
auth.logout(req);
|
||||
auth.clearSessionCookie(res);
|
||||
res.redirect("/login");
|
||||
});
|
||||
|
||||
// SSE(放在鉴权前,前端用 EventSource 无法带 header,靠 cookie)
|
||||
app.get("/api/events", (req, res) => {
|
||||
if (!auth.isAuthed(req)) return res.status(401).end();
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
res.write(":ok\n\n");
|
||||
sse.addClient(res);
|
||||
});
|
||||
|
||||
// HLS 流(不锁)
|
||||
app.use("/hls", express.static(config.HLS_OUTPUT_DIR, {
|
||||
setHeaders: (res, filePath) => {
|
||||
if (filePath.endsWith(".m3u8")) {
|
||||
res.set("Content-Type", "application/vnd.apple.mpegurl; charset=utf-8");
|
||||
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
}
|
||||
},
|
||||
}));
|
||||
app.use("/hls_cache", express.static(config.HLS_CACHE_DIR));
|
||||
|
||||
// 封面/静态资源(不锁,浏览器加载图片)
|
||||
// 但 index.html 需要鉴权,避免未登录直接拿到面板
|
||||
app.get(["/", "/index.html"], (req, res) => {
|
||||
if (!auth.isAuthed(req)) return res.redirect("/login");
|
||||
res.sendFile(path.join(config.PUBLIC_DIR, "index.html"));
|
||||
});
|
||||
app.use(express.static(config.PUBLIC_DIR, { index: false }));
|
||||
|
||||
// 受保护的 API
|
||||
app.use("/api/music", auth.requireAuth, musicRoutes);
|
||||
app.use("/api/resources", auth.requireAuth, resourceRoutes);
|
||||
app.use("/api/streams", auth.requireAuth, streamRoutes);
|
||||
app.use("/api/system", auth.requireAuth, systemRoutes);
|
||||
|
||||
// 其余 /api 需要登录
|
||||
app.use("/api", auth.requireAuth);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
function start() {
|
||||
bootstrap();
|
||||
const app = createApp();
|
||||
const server = app.listen(config.HTTP_PORT, () => {
|
||||
logger.info(`[HTTP] 面板: http://127.0.0.1:${config.HTTP_PORT}`);
|
||||
for (const s of live.listStreams()) {
|
||||
logger.info(`[HTTP] 直播[${s.id}]: http://127.0.0.1:${config.HTTP_PORT}${s.m3u8}`);
|
||||
}
|
||||
});
|
||||
|
||||
function shutdown() {
|
||||
logger.info("[EXIT] 正在关闭...");
|
||||
live.stopAll();
|
||||
try { media.killAllProcs(); } catch (_) {}
|
||||
sse.closeAll();
|
||||
scanner.closeWatcher().then(() => {
|
||||
server.close(() => process.exit(0));
|
||||
});
|
||||
setTimeout(() => process.exit(0), 3000);
|
||||
}
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("uncaughtException", (err) => logger.error("[FATAL]", err.message, err.stack));
|
||||
process.on("unhandledRejection", (r) => logger.warn("[WARN]", r));
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
module.exports = { bootstrap, createApp, start };
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
"use strict";
|
||||
const clients = new Set();
|
||||
|
||||
function addClient(res) {
|
||||
clients.add(res);
|
||||
res.on("close", () => clients.delete(res));
|
||||
}
|
||||
|
||||
function broadcast(event, data) {
|
||||
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
for (const c of clients) {
|
||||
try {
|
||||
c.write(msg);
|
||||
} catch (_) {
|
||||
clients.delete(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeAll() {
|
||||
for (const c of clients) {
|
||||
try {
|
||||
c.end();
|
||||
} catch (_) {}
|
||||
}
|
||||
clients.clear();
|
||||
}
|
||||
|
||||
module.exports = { addClient, broadcast, closeAll };
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
"use strict";
|
||||
const { getDb } = require("./db");
|
||||
const { naturalCompare } = require("./util");
|
||||
|
||||
/* ==================== songs ==================== */
|
||||
function getSongByFile(file) {
|
||||
return getDb().prepare("SELECT * FROM songs WHERE file = ?").get(file);
|
||||
}
|
||||
function getSongById(id) {
|
||||
return getDb().prepare("SELECT * FROM songs WHERE id = ?").get(id);
|
||||
}
|
||||
function getAllSongs() {
|
||||
const rows = getDb().prepare("SELECT * FROM songs").all();
|
||||
return rows.sort((a, b) => naturalCompare(a.file, b.file));
|
||||
}
|
||||
function upsertSong(s) {
|
||||
const d = getDb();
|
||||
const ex = getSongByFile(s.file);
|
||||
if (ex) {
|
||||
d.prepare(
|
||||
`UPDATE songs SET name=?, artist=?, cover=?, duration=?, file_size=? WHERE file=?`
|
||||
).run(
|
||||
s.name != null ? s.name : ex.name,
|
||||
s.artist != null ? s.artist : ex.artist,
|
||||
s.cover != null ? s.cover : ex.cover,
|
||||
s.duration != null ? s.duration : ex.duration,
|
||||
s.file_size != null ? s.file_size : ex.file_size,
|
||||
s.file
|
||||
);
|
||||
return getSongByFile(s.file);
|
||||
}
|
||||
d.prepare(
|
||||
`INSERT INTO songs (file, name, artist, cover, duration, file_size, heat, cache_status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, 'pending', ?)`
|
||||
).run(
|
||||
s.file,
|
||||
s.name || "",
|
||||
s.artist || "",
|
||||
s.cover || "",
|
||||
s.duration != null ? s.duration : null,
|
||||
s.file_size || 0,
|
||||
Date.now()
|
||||
);
|
||||
return getSongByFile(s.file);
|
||||
}
|
||||
function updateSong(file, fields) {
|
||||
const keys = Object.keys(fields);
|
||||
if (!keys.length) return;
|
||||
const set = keys.map((k) => `${k}=?`).join(", ");
|
||||
getDb()
|
||||
.prepare(`UPDATE songs SET ${set} WHERE file=?`)
|
||||
.run(...keys.map((k) => fields[k]), file);
|
||||
}
|
||||
function deleteSongByFile(file) {
|
||||
return getDb().prepare("DELETE FROM songs WHERE file = ?").run(file);
|
||||
}
|
||||
function setCacheStatus(file, status) {
|
||||
getDb().prepare("UPDATE songs SET cache_status=? WHERE file=?").run(status, file);
|
||||
}
|
||||
function incrementHeat(file) {
|
||||
getDb().prepare("UPDATE songs SET heat = heat + 1 WHERE file=?").run(file);
|
||||
return getSongByFile(file)?.heat || 0;
|
||||
}
|
||||
|
||||
/* ==================== streams ==================== */
|
||||
function createStream(id, name) {
|
||||
getDb()
|
||||
.prepare(
|
||||
"INSERT INTO streams (id, name, enabled, current_pos, media_seq, created_at) VALUES (?, ?, 1, 0, 0, ?)"
|
||||
)
|
||||
.run(id, name || id, Date.now());
|
||||
return getStream(id);
|
||||
}
|
||||
function listStreams() {
|
||||
return getDb().prepare("SELECT * FROM streams ORDER BY created_at ASC").all();
|
||||
}
|
||||
function getStream(id) {
|
||||
return getDb().prepare("SELECT * FROM streams WHERE id = ?").get(id);
|
||||
}
|
||||
function deleteStream(id) {
|
||||
return getDb().prepare("DELETE FROM streams WHERE id = ?").run(id);
|
||||
}
|
||||
function setStreamState(id, fields) {
|
||||
const keys = Object.keys(fields);
|
||||
if (!keys.length) return;
|
||||
const set = keys.map((k) => `${k}=?`).join(", ");
|
||||
getDb()
|
||||
.prepare(`UPDATE streams SET ${set} WHERE id=?`)
|
||||
.run(...keys.map((k) => fields[k]), id);
|
||||
}
|
||||
function setStreamEnabled(id, enabled) {
|
||||
setStreamState(id, { enabled: enabled ? 1 : 0 });
|
||||
}
|
||||
|
||||
/* ==================== stream_playlist ==================== */
|
||||
function getPlaylistRows(streamId) {
|
||||
return getDb()
|
||||
.prepare(
|
||||
`SELECT sp.id AS entry_id, sp.position, s.*
|
||||
FROM stream_playlist sp JOIN songs s ON s.id = sp.song_id
|
||||
WHERE sp.stream_id = ? ORDER BY sp.position ASC`
|
||||
)
|
||||
.all(streamId);
|
||||
}
|
||||
function playlistCount(streamId) {
|
||||
return getDb()
|
||||
.prepare("SELECT COUNT(*) c FROM stream_playlist WHERE stream_id=?")
|
||||
.get(streamId).c;
|
||||
}
|
||||
function addToPlaylist(streamId, songId) {
|
||||
const d = getDb();
|
||||
const max =
|
||||
d.prepare("SELECT COALESCE(MAX(position),-1) m FROM stream_playlist WHERE stream_id=?")
|
||||
.get(streamId).m + 1;
|
||||
d.prepare(
|
||||
"INSERT INTO stream_playlist (stream_id, song_id, position) VALUES (?, ?, ?)"
|
||||
).run(streamId, songId, max);
|
||||
}
|
||||
function removePlaylistEntry(streamId, entryId) {
|
||||
const d = getDb();
|
||||
const row = d.prepare("SELECT position FROM stream_playlist WHERE id=? AND stream_id=?").get(entryId, streamId);
|
||||
if (!row) return false;
|
||||
d.prepare("DELETE FROM stream_playlist WHERE id=?").run(entryId);
|
||||
d.prepare("UPDATE stream_playlist SET position = position - 1 WHERE stream_id=? AND position > ?")
|
||||
.run(streamId, row.position);
|
||||
return true;
|
||||
}
|
||||
function setPlaylistOrder(streamId, entryIds) {
|
||||
const d = getDb();
|
||||
const stmt = d.prepare("UPDATE stream_playlist SET position=? WHERE id=? AND stream_id=?");
|
||||
const tx = d.transaction((ids) => {
|
||||
ids.forEach((id, i) => stmt.run(i, id, streamId));
|
||||
});
|
||||
tx(entryIds);
|
||||
}
|
||||
function clearPlaylist(streamId) {
|
||||
getDb().prepare("DELETE FROM stream_playlist WHERE stream_id=?").run(streamId);
|
||||
}
|
||||
|
||||
/* ==================== sessions ==================== */
|
||||
function createSession(token, expiresAt) {
|
||||
getDb()
|
||||
.prepare("INSERT INTO sessions (token, created_at, expires_at) VALUES (?, ?, ?)")
|
||||
.run(token, Date.now(), expiresAt);
|
||||
}
|
||||
function getSession(token) {
|
||||
const s = getDb().prepare("SELECT * FROM sessions WHERE token=?").get(token);
|
||||
if (!s) return null;
|
||||
if (s.expires_at < Date.now()) {
|
||||
deleteSession(token);
|
||||
return null;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
function deleteSession(token) {
|
||||
getDb().prepare("DELETE FROM sessions WHERE token=?").run(token);
|
||||
}
|
||||
function purgeExpiredSessions() {
|
||||
return getDb().prepare("DELETE FROM sessions WHERE expires_at < ?").run(Date.now()).changes;
|
||||
}
|
||||
|
||||
/* ==================== settings ==================== */
|
||||
function getSetting(key, def = null) {
|
||||
const r = getDb().prepare("SELECT value FROM settings WHERE key=?").get(key);
|
||||
if (!r) return def;
|
||||
try {
|
||||
return JSON.parse(r.value);
|
||||
} catch (_) {
|
||||
return r.value;
|
||||
}
|
||||
}
|
||||
function setSetting(key, value) {
|
||||
getDb()
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value"
|
||||
)
|
||||
.run(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getSongByFile, getSongById, getAllSongs, upsertSong, updateSong,
|
||||
deleteSongByFile, setCacheStatus, incrementHeat,
|
||||
createStream, listStreams, getStream, deleteStream, setStreamState, setStreamEnabled,
|
||||
getPlaylistRows, playlistCount, addToPlaylist, removePlaylistEntry,
|
||||
setPlaylistOrder, clearPlaylist,
|
||||
createSession, getSession, deleteSession, purgeExpiredSessions,
|
||||
getSetting, setSetting,
|
||||
};
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
"use strict";
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
|
||||
function fileHash(fp) {
|
||||
return crypto.createHash("md5").update(fp).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
function naturalCompare(a, b) {
|
||||
return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
|
||||
}
|
||||
|
||||
function getDirSize(dirPath) {
|
||||
let size = 0;
|
||||
if (!fs.existsSync(dirPath)) return size;
|
||||
for (const entry of fs.readdirSync(dirPath)) {
|
||||
const full = path.join(dirPath, entry);
|
||||
try {
|
||||
const st = fs.statSync(full);
|
||||
size += st.isDirectory() ? getDirSize(full) : st.size;
|
||||
} catch (_) {}
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
function atomicWriteSync(filePath, data) {
|
||||
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.writeFileSync(tmp, data, "utf8");
|
||||
fs.renameSync(tmp, filePath);
|
||||
}
|
||||
|
||||
function sanitizeFileName(name) {
|
||||
return String(name)
|
||||
.replace(/[\\/:*?"<>|]/g, "_")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isValidFileName(f) {
|
||||
return (
|
||||
typeof f === "string" &&
|
||||
f.length > 0 &&
|
||||
f.length < 500 &&
|
||||
!f.includes("..") &&
|
||||
!f.includes("/") &&
|
||||
!f.includes("\\") &&
|
||||
!f.includes("\0")
|
||||
);
|
||||
}
|
||||
|
||||
function parseFileName(fileName) {
|
||||
const stem = fileName.replace(/\.[^.]+$/, "");
|
||||
const idx = stem.indexOf(" - ");
|
||||
if (idx > 0) {
|
||||
return { artist: stem.slice(0, idx).trim(), name: stem.slice(idx + 3).trim() };
|
||||
}
|
||||
return { artist: "", name: stem.trim() };
|
||||
}
|
||||
|
||||
const AUDIO_EXTS = [".mp3", ".m4a", ".flac", ".wav", ".aac", ".ogg"];
|
||||
function isAudioFile(name) {
|
||||
return AUDIO_EXTS.includes(path.extname(name).toLowerCase());
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fileHash,
|
||||
naturalCompare,
|
||||
getDirSize,
|
||||
atomicWriteSync,
|
||||
sanitizeFileName,
|
||||
isValidFileName,
|
||||
parseFileName,
|
||||
isAudioFile,
|
||||
AUDIO_EXTS,
|
||||
};
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import urllib.request, urllib.error, http.cookiejar, json, sys, time
|
||||
|
||||
BASE = "http://127.0.0.1:3000"
|
||||
|
||||
def opener_with_cookies():
|
||||
cj = http.cookiejar.CookieJar()
|
||||
return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
|
||||
|
||||
def call(op, path, method="GET", data=None):
|
||||
url = BASE + path
|
||||
body = json.dumps(data).encode() if data is not None else None
|
||||
req = urllib.request.Request(url, data=body, method=method)
|
||||
if body:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
r = op.open(req, timeout=10)
|
||||
return r.status, r.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read()
|
||||
|
||||
anon = opener_with_cookies()
|
||||
auth = opener_with_cookies()
|
||||
results = []
|
||||
def check(name, cond, extra=""):
|
||||
results.append((name, cond)); print(("PASS" if cond else "FAIL"), name, extra)
|
||||
|
||||
# 鉴权
|
||||
st, _ = call(anon, "/api/resources"); check("未登录API=401", st == 401, f"got {st}")
|
||||
st, body = call(anon, "/login"); check("登录页200", st == 200, f"got {st}")
|
||||
st, body = call(anon, "/"); check("未登录/=登录页", st == 200 and b"loginForm" in body, f"got {st}")
|
||||
st, _ = call(anon, "/index.html"); check("未登录index.html=登录页", st == 200 and b"loginForm" in _, f"got {st}")
|
||||
st, _ = call(auth, "/api/auth/login", "POST", {"password": "wrong"}); check("错密码401", st == 401, f"got {st}")
|
||||
st, body = call(auth, "/api/auth/login", "POST", {"password": "test123"}); check("登录成功", st == 200 and json.loads(body).get("ok"), f"got {st}")
|
||||
|
||||
# 资源与流
|
||||
st, body = call(auth, "/api/resources"); d = json.loads(body)
|
||||
check("资源列表5项", st == 200 and len(d["list"]) == 5, f"{len(d.get('list',[]))}项")
|
||||
first = d["list"][0]["file"]
|
||||
|
||||
tid = "t%d" % int(time.time())
|
||||
st, body = call(auth, "/api/streams", "POST", {"id": tid, "name": "临时测试流"})
|
||||
check("创建流", st == 200 and json.loads(body).get("ok"), body[:80].decode("utf-8","ignore"))
|
||||
st, body = call(anon, "/hls/%s/index.m3u8" % tid); check("新流m3u8可达", st == 200, f"got {st}")
|
||||
st, body = call(auth, "/api/streams/%s/playlist/add" % tid, "POST", {"file": first})
|
||||
check("新流加歌", st == 200, f"got {st}")
|
||||
st, body = call(auth, "/api/streams"); d = json.loads(body)
|
||||
check("流列表含新流", any(s["id"] == tid for s in d["list"]), "")
|
||||
# 清理
|
||||
st, body = call(auth, "/api/streams/%s" % tid, "DELETE", {})
|
||||
check("删除流", st == 200, f"got {st}")
|
||||
|
||||
# 缓存/日志
|
||||
st, body = call(auth, "/api/system/cache"); d = json.loads(body)
|
||||
check("缓存信息", st == 200 and "total" in d, f"{d.get('total')}B")
|
||||
st, body = call(auth, "/api/system/logs?tail=5")
|
||||
check("日志接口", st == 200 and len(json.loads(body).get("lines", [])) > 0, "")
|
||||
|
||||
# 登出
|
||||
st, _ = call(auth, "/api/auth/logout", "POST"); check("登出", st == 200, f"got {st}")
|
||||
st, _ = call(auth, "/api/resources"); check("登出后401", st == 401, f"got {st}")
|
||||
|
||||
print("\n=== 汇总:", sum(1 for _, c in results if c), "/", len(results), "通过 ===")
|
||||
sys.exit(0 if all(c for _, c in results) else 1)
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import urllib.request, urllib.error, http.cookiejar, json, sys, re
|
||||
|
||||
BASE = "http://127.0.0.1:3000"
|
||||
cj = http.cookiejar.CookieJar()
|
||||
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
|
||||
|
||||
def call(path, method="GET", data=None, raw=False):
|
||||
body = json.dumps(data).encode() if data is not None else None
|
||||
req = urllib.request.Request(BASE + path, data=body, method=method)
|
||||
if body: req.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
r = op.open(req, timeout=10)
|
||||
return r.status, r.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read()
|
||||
|
||||
results = []
|
||||
def check(name, cond, extra=""):
|
||||
results.append((name, cond)); print(("PASS" if cond else "FAIL"), name, extra)
|
||||
|
||||
# 静态资源可达(不需要登录)
|
||||
for path in ["/vendor/bootstrap.min.css", "/vendor/bootstrap-icons.css",
|
||||
"/vendor/Sortable.min.js", "/vendor/bootstrap-icons.woff2",
|
||||
"/css/style.css", "/js/app.js", "/login.html", "/favicon.ico"]:
|
||||
st, _ = call(path)
|
||||
check(f"静态 {path}", st == 200, f"got {st}")
|
||||
|
||||
# 登录
|
||||
st, body = call("/api/auth/login", "POST", {"password": "test123"})
|
||||
check("登录", st == 200 and json.loads(body).get("ok"), f"got {st}")
|
||||
|
||||
# 首页返回面板(含 vendor 引用)
|
||||
st, body = call("/")
|
||||
html = body.decode("utf-8", "ignore")
|
||||
check("首页返回面板", st == 200 and "pageSearch" in html and "loginForm" not in html, f"got {st}")
|
||||
check("面板引用本地vendor", "vendor/bootstrap.min.css" in html and "cdn.jsdelivr" not in html)
|
||||
|
||||
# 资源列表
|
||||
st, body = call("/api/resources")
|
||||
d = json.loads(body)
|
||||
check("资源列表", st == 200 and len(d["list"]) == 5, f"{len(d.get('list',[]))}")
|
||||
# 检查封面字段(可能为空,本地化后是 /covers/ 或空)
|
||||
covers = [x["albumImage"] for x in d["list"]]
|
||||
print(" 封面字段样例:", covers[:2])
|
||||
|
||||
# 流列表
|
||||
st, body = call("/api/streams")
|
||||
d = json.loads(body)
|
||||
check("流列表", st == 200 and len(d["list"]) >= 1, str([s["id"] for s in d["list"]]))
|
||||
|
||||
# 播放列表
|
||||
st, body = call("/api/streams/main/playlist")
|
||||
d = json.loads(body)
|
||||
check("主频道播放列表", st == 200 and "list" in d, f"{len(d.get('list',[]))}项")
|
||||
|
||||
# 检查 index.html 是否有 import/stream 元素
|
||||
check("HTML含频道选择器", 'id="streamSelect"' in html)
|
||||
check("HTML含导入卡片", 'id="importCard"' in html or 'id="importFile"' in html)
|
||||
check("HTML含登出按钮", 'id="btnLogout"' in html)
|
||||
|
||||
print("\n=== 汇总:", sum(1 for _,c in results if c), "/", len(results), "通过 ===")
|
||||
sys.exit(0 if all(c for _,c in results) else 1)
|
||||
@@ -0,0 +1,85 @@
|
||||
import urllib.request, urllib.error, http.cookiejar, json, sys, os, io, uuid
|
||||
|
||||
BASE = "http://127.0.0.1:3000"
|
||||
cj = http.cookiejar.CookieJar()
|
||||
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
|
||||
|
||||
def call(path, method="GET", data=None):
|
||||
body = json.dumps(data).encode() if data is not None else None
|
||||
req = urllib.request.Request(BASE + path, data=body, method=method)
|
||||
if body: req.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
r = op.open(req, timeout=30)
|
||||
return r.status, r.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read()
|
||||
|
||||
results = []
|
||||
def check(name, cond, extra=""):
|
||||
results.append((name, cond)); print(("PASS" if cond else "FAIL"), name, extra)
|
||||
|
||||
# 登录
|
||||
call("/api/auth/login", "POST", {"password": "test123"})
|
||||
|
||||
# ---------- 导入测试 ----------
|
||||
tmp_file = r"F:\music_online\demo\tmp\test_import.mp3"
|
||||
boundary = "----test" + uuid.uuid4().hex
|
||||
with open(tmp_file, "rb") as f:
|
||||
audio_data = f.read()
|
||||
|
||||
title = "测试导入歌"
|
||||
artist = "测试作者"
|
||||
parts = []
|
||||
def add_field(name, value):
|
||||
parts.append(("--" + boundary + "\r\n").encode())
|
||||
parts.append(('Content-Disposition: form-data; name="%s"\r\n\r\n' % name).encode())
|
||||
parts.append((value + "\r\n").encode())
|
||||
def add_file(name, filename, content):
|
||||
parts.append(("--" + boundary + "\r\n").encode())
|
||||
parts.append(('Content-Disposition: form-data; name="%s"; filename="%s"\r\n' % (name, filename)).encode())
|
||||
parts.append(("Content-Type: audio/mpeg\r\n\r\n").encode())
|
||||
parts.append(content)
|
||||
parts.append(b"\r\n")
|
||||
|
||||
add_field("title", title)
|
||||
add_field("artist", artist)
|
||||
add_file("audio", "测试导入歌.mp3", audio_data)
|
||||
parts.append(("--" + boundary + "--\r\n").encode())
|
||||
body = b"".join(parts)
|
||||
|
||||
req = urllib.request.Request(BASE + "/api/music/import", data=body, method="POST")
|
||||
req.add_header("Content-Type", "multipart/form-data; boundary=" + boundary)
|
||||
try:
|
||||
r = op.open(req, timeout=60)
|
||||
d = json.loads(r.read())
|
||||
check("导入音频", r.status == 200 and d.get("ok"), str(d)[:120])
|
||||
imported_file = d.get("file")
|
||||
print(" 导入文件名:", imported_file)
|
||||
print(" 元数据:", json.dumps(d.get("song", {}), ensure_ascii=False)[:200])
|
||||
except urllib.error.HTTPError as e:
|
||||
check("导入音频", False, f"HTTP {e.code}: {e.read()[:200]}")
|
||||
imported_file = None
|
||||
|
||||
# 确认资源数增加
|
||||
st, body = call("/api/resources")
|
||||
d = json.loads(body)
|
||||
check("资源数变为6", len(d["list"]) == 6, f"{len(d['list'])}")
|
||||
|
||||
# ---------- 删除测试 ----------
|
||||
if imported_file:
|
||||
st, body = call("/api/music/delete", "POST", {"file": imported_file})
|
||||
d = json.loads(body)
|
||||
check("删除音频", st == 200 and d.get("ok"), str(d)[:120])
|
||||
|
||||
# 确认文件没了
|
||||
fp = os.path.join(r"F:\music_online\demo\audio", imported_file)
|
||||
check("音频文件已删", not os.path.exists(fp))
|
||||
# 确认资源数恢复
|
||||
st, body = call("/api/resources")
|
||||
d = json.loads(body)
|
||||
check("资源数恢复为5", len(d["list"]) == 5, f"{len(d['list'])}")
|
||||
else:
|
||||
check("删除音频", False, "无导入文件")
|
||||
|
||||
print("\n=== 汇总:", sum(1 for _,c in results if c), "/", len(results), "通过 ===")
|
||||
sys.exit(0 if all(c for _,c in results) else 1)
|
||||
Reference in new issue
Block a user