v2: SQLite + 多路流 + 鉴权 + 导入/删除 + 资源本地化 + 模块化重构

This commit is contained in:
dev committed 2026-09-24 20:34:39 +08:00
1 parent fa6eeb25b9
commit 65b6f8cf62
30 files changed
+3506 -2231

No files matched your search

+8 -1
View File
@@ -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
+93 -222
View File
@@ -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
+3 -1233
View File
File diff suppressed because it is too large. Load diff
+67 -1
View File
@@ -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
View File
@@ -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"
}
}
}
+62
View File
@@ -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
View File
@@ -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 &middot; Built with &hearts; &middot;
v2.0.0 &middot; Built with &hearts; &middot;
<a
href="https://github.com/Xiao-no-love/"
target="_blank"
+572 -745
View File
File diff suppressed because it is too large. Load diff
+142
View File
@@ -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>
+60
View File
@@ -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
View File
@@ -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,
};
+49
View File
@@ -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;
+91
View File
@@ -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 };
+447
View File
@@ -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,
};
+49
View File
@@ -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
View File
@@ -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
View File
@@ -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,
};
+30
View File
@@ -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;
+233
View File
@@ -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;
+34
View File
@@ -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;
+161
View File
@@ -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;
+52
View File
@@ -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;
+81
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+85
View File
@@ -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)