diff --git a/.gitignore b/.gitignore index a1b9b63..e8a1a62 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,11 @@ npm-debug.log* .vscode/ .idea/ .DS_Store -Thumbs.db \ No newline at end of file +Thumbs.db +# 音频文件(体积大,不入库) +audio/*.mp3 +audio/*.m4a +audio/*.flac +audio/*.wav +audio/*.aac +audio/*.ogg \ No newline at end of file diff --git a/README.md b/README.md index de14c0c..dcde758 100644 --- a/README.md +++ b/README.md @@ -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** - -- 前往 下载 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. 打开 ,下载 `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 ``` -- **控制面板**:浏览器打开 -- **直播流地址**:(可用 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 ` | -| `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 -# 结束该进程(把 换成上一条命令查到的数字) -taskkill /PID /F -``` - -**Linux** - -```bash -# 查看占用进程 -sudo lsof -i :3000 -# 或 -sudo ss -tlnp | grep 3000 -# 结束进程(把 换成实际值) -kill -``` - -### 启动成功但直播流没声音 / 一直缓冲 - -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 \ No newline at end of file diff --git a/index.js b/index.js index a8c6789..b486b9a 100644 --- a/index.js +++ b/index.js @@ -1,1233 +1,3 @@ -const fs = require("fs"); -const path = require("path"); -const crypto = require("crypto"); -const http = require("http"); -const https = require("https"); -const { spawn } = require("child_process"); -const chokidar = require("chokidar"); -const express = require("express"); - -// ==================== 路径常量 ==================== -const AUDIO_DIR = path.join(__dirname, "audio"); -const HLS_CACHE_DIR = path.join(__dirname, "hls_cache"); -const HLS_OUTPUT_DIR = path.join(__dirname, "hls"); -const PLAYLIST_FILE = path.join(__dirname, "playlist.json"); -const HEAT_FILE = path.join(__dirname, "heat.json"); -const METADATA_FILE = path.join(__dirname, "metadata.json"); -const PUBLIC_DIR = path.join(__dirname, "public"); - -for (const dir of [AUDIO_DIR, HLS_CACHE_DIR, HLS_OUTPUT_DIR, PUBLIC_DIR]) { - fs.mkdirSync(dir, { recursive: true }); -} - -// ==================== 配置 ==================== -// 环境变量解析助手:非法或缺失时回退默认值 -function envInt(name, fallback) { - const v = parseInt(process.env[name], 10); - return Number.isFinite(v) && v > 0 ? v : fallback; -} - -const MAX_CACHE_SIZE = envInt("MAX_CACHE_MB", 1000) * 1024 * 1024; -const HLS_TIME = envInt("HLS_TIME", 5); -const SLIDE_WINDOW_SIZE = envInt("SLIDE_WINDOW", 15); -const LIVE_TICK_INTERVAL = HLS_TIME * 1000; // 与切片时长对齐,避免累积漂移 -const HTTP_PORT = envInt("PORT", 3000); -const MAX_FFMPEG_CONCURRENT = envInt("FFMPEG_CONCURRENCY", 3); -const MAX_RETRIES = envInt("MAX_RETRIES", 3); -const FFMPEG_TIMEOUT = envInt("FFMPEG_TIMEOUT_MS", 5 * 60 * 1000); -const AUTH_TOKEN = process.env.AUTH_TOKEN || ""; - -// [FIX] DISCONTINUITY 默认关闭 -// 设为 true:严格合规,但部分播放器会在标签处插入静音 -// 设为 false:让播放器自然处理 PTS 跳变,兼容性更好 -const USE_DISCONTINUITY = process.env.USE_DISCONTINUITY === "true"; - -// ==================== 全局状态 ==================== -let audioFiles = []; -let playlist = []; -let heatMap = new Map(); -let metadataMap = new Map(); -let cacheStatus = new Map(); // 'ready' | 'pending' | 'error' -let server = null; -let cacheTotalSize = 0; // 增量追踪 - -let liveSegments = []; // { duration, path, songId } -let mediaSequence = 0; -let currentPlaylistIdx = 0; -let currentSongSegmentIdx = 0; -let songSegmentsCache = new Map(); -let liveTimer = null; - -let retryCount = new Map(); -let heatSaveTimer = null; -let scanDebounceTimer = null; -const sseClients = new Set(); - -// ==================== 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) => console.error("[QUEUE]", e.message)) - .finally(() => { - this.running--; - this._drain(); - }); - } - } -} -const ffmpegQueue = new TaskQueue(MAX_FFMPEG_CONCURRENT); - -// ==================== 工具函数 ==================== -function fileHash(fp) { - return crypto.createHash("md5").update(fp).digest("hex").slice(0, 16); -} -function getCacheDir(fp) { - return path.join(HLS_CACHE_DIR, fileHash(fp)); -} -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 clampPlaylistIndex() { - if (playlist.length === 0) { - currentPlaylistIdx = 0; - currentSongSegmentIdx = 0; - return; - } - if (currentPlaylistIdx < 0) currentPlaylistIdx = 0; - if (currentPlaylistIdx >= playlist.length) { - currentPlaylistIdx = 0; - currentSongSegmentIdx = 0; - } -} - -// [FIX] 原子写入:先写临时文件再 rename,播放器不会读到半截 M3U8 -function atomicWriteSync(filePath, data) { - const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`; - fs.writeFileSync(tmp, data, "utf8"); - fs.renameSync(tmp, filePath); -} - -// ==================== 网易云音源导入 ==================== -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/"; - -function neteaseRequest(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("重定向次数过多")); - const next = new URL(loc, url).toString(); - return resolve(neteaseRequest(next, 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 readResponseBody(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); - }); -} - -function sanitizeFileName(name) { - return name.replace(/[\\/:*?"<>|]/g, "_").trim(); -} - -async function fetchNetEaseDetail(id) { - const url = `http://music.163.com/api/song/detail/?id=${id}&ids=[${id}]`; - const res = await neteaseRequest(url); - const body = await readResponseBody(res); - const json = JSON.parse(body.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) || "", - }; -} - -function downloadNetEase(id, filename) { - return new Promise((resolve, reject) => { - const url = `http://music.163.com/song/media/outer/url?id=${id}.mp3`; - neteaseRequest(url) - .then((res) => { - const filePath = path.join(AUDIO_DIR, filename + ".mp3"); - 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(filePath); - }); - ws.on("error", reject); - res.on("error", reject); - res.pipe(ws); - }) - .catch(reject); - }); -} - -// 网易云音源:歌名搜索(返回精简列表,过滤 fee=1) -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 neteaseRequest(url); - const body = await readResponseBody(res); - const json = JSON.parse(body.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, - }; - const alias = s.alias || []; - if (alias.length) item.alias = alias; - return item; - }); - return { total: list.length, list }; -} - -// ==================== SSE 推送 ==================== -function broadcastSSE(event, data) { - const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; - for (const c of sseClients) { - try { - c.write(msg); - } catch (_) { - sseClients.delete(c); - } - } -} -function getPlaylistSnapshot() { - return playlist.map((file, idx) => ({ - name: path.basename(file), - path: file, - heat: heatMap.get(file) || 0, - cacheStatus: cacheStatus.get(file) || "unknown", - isCurrent: idx === currentPlaylistIdx, - })); -} - -// ==================== 热度管理(防抖落盘)==================== -function loadHeatMap() { - try { - heatMap = new Map( - Object.entries(JSON.parse(fs.readFileSync(HEAT_FILE, "utf8"))), - ); - } catch (_) { - heatMap = new Map(); - } -} -function saveHeatMap() { - try { - fs.writeFileSync( - HEAT_FILE, - JSON.stringify(Object.fromEntries(heatMap), null, 2), - ); - } catch (_) {} -} -function scheduleHeatSave() { - if (heatSaveTimer) clearTimeout(heatSaveTimer); - heatSaveTimer = setTimeout(saveHeatMap, 5000); -} -function incrementHeat(fp) { - heatMap.set(fp, (heatMap.get(fp) || 0) + 1); - scheduleHeatSave(); -} - -// ==================== 播放列表 ==================== -function loadPlaylist() { - try { - playlist = JSON.parse(fs.readFileSync(PLAYLIST_FILE, "utf8")).filter((p) => - fs.existsSync(p), - ); - } catch (_) { - playlist = []; - } -} -function savePlaylist() { - try { - fs.writeFileSync(PLAYLIST_FILE, JSON.stringify(playlist, null, 2)); - } catch (_) {} -} - -// ==================== 切片元数据解析 ==================== -function loadSongSegments(fp) { - if (songSegmentsCache.has(fp)) return songSegmentsCache.get(fp); - const m3u8 = path.join(getCacheDir(fp), "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 = 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) }); - } - } - if (segments.length === 0) return null; - songSegmentsCache.set(fp, segments); - return segments; - } catch (e) { - console.error("[SEG]", path.basename(fp), e.message); - return null; - } -} - -// ==================== 音频探测 ==================== -function probeAudio(fp) { - return new Promise((resolve, reject) => { - const proc = spawn("ffprobe", [ - "-v", - "error", - "-select_streams", - "a:0", - "-show_entries", - "stream=codec_name,sample_rate,channels:format=duration", - "-of", - "json", - fp, - ]); - 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); - }); -} - -// ==================== 音频元数据管理 ==================== -function loadMetadata() { - try { - metadataMap = new Map( - Object.entries(JSON.parse(fs.readFileSync(METADATA_FILE, "utf8"))), - ); - } catch (_) { - metadataMap = new Map(); - } -} -function saveMetadata() { - try { - fs.writeFileSync( - METADATA_FILE, - JSON.stringify(Object.fromEntries(metadataMap), null, 2), - ); - } catch (_) {} -} -// 从「作者 - 歌名.mp3」文件名解析出作者与歌名 -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() }; -} -// 补齐某音频的元数据(歌名/作者/封面/时长);已有则跳过,仅补缺失的时长 -async function ensureMetadata(filePath) { - if (metadataMap.has(filePath)) { - const m = metadataMap.get(filePath); - if (m.duration == null) { - try { - m.duration = (await probeAudio(filePath)).duration; - saveMetadata(); - } catch (_) {} - } - return m; - } - const parsed = parseFileName(path.basename(filePath)); - const meta = { - name: parsed.name, - artist: parsed.artist, - albumImage: "", - duration: null, - }; - try { - meta.duration = (await probeAudio(filePath)).duration; - } catch (_) {} - metadataMap.set(filePath, meta); - saveMetadata(); - return meta; -} - -// ==================== [FIX] 启动缓存扫描 ==================== -// 在 preGenerateAll 之前调用,识别已有缓存,避免重复生成 -function scanExistingCaches() { - const hashMap = new Map(); - for (const file of audioFiles) hashMap.set(fileHash(file), file); - - let validCount = 0; - for (const entry of fs.readdirSync(HLS_CACHE_DIR)) { - const dir = path.join(HLS_CACHE_DIR, entry); - try { - if (!fs.statSync(dir).isDirectory()) continue; - } catch (_) { - continue; - } - - const originalFile = hashMap.get(entry); - if (!originalFile) continue; // 孤立缓存,后续由 enforceCacheSizeLimit 清理 - - // 检查 index.m3u8 是否存在且可解析 - const segments = loadSongSegments(originalFile); - if (segments && segments.length > 0) { - // 检查所有 .ts 文件是否完整存在且非空 - const allValid = segments.every((seg) => { - const segPath = path.join(dir, seg.fileName); - try { - return fs.existsSync(segPath) && fs.statSync(segPath).size > 0; - } catch (_) { - return false; - } - }); - - if (allValid) { - cacheStatus.set(originalFile, "ready"); - cacheTotalSize += getDirSize(dir); - validCount++; - } else { - console.log( - `[CACHE] 不完整,将重新生成: ${path.basename(originalFile)}`, - ); - const oldSize = getDirSize(dir); - fs.rmSync(dir, { recursive: true, force: true }); - cacheTotalSize = Math.max(0, cacheTotalSize - oldSize); - songSegmentsCache.delete(originalFile); - } - } else { - // index.m3u8 不存在或无法解析 - const oldSize = getDirSize(dir); - fs.rmSync(dir, { recursive: true, force: true }); - cacheTotalSize = Math.max(0, cacheTotalSize - oldSize); - } - } - console.log( - `[CACHE] 启动扫描: ${validCount}/${audioFiles.length} 个缓存有效,` + - `总大小 ${(cacheTotalSize / 1024 / 1024).toFixed(1)}MB`, - ); -} - -// ==================== 缓存清理 ==================== -function enforceCacheSizeLimit() { - if (cacheTotalSize <= MAX_CACHE_SIZE) return; - console.log( - `[CACHE] ${(cacheTotalSize / 1024 / 1024).toFixed(1)}MB 超限,开始清理`, - ); - - // 保护当前 + 接下来 2 首 - const protectedFiles = new Set(); - for (let i = 0; i < Math.min(3, playlist.length); i++) { - const idx = (currentPlaylistIdx + i) % playlist.length; - if (playlist[idx]) protectedFiles.add(playlist[idx]); - } - - // 保护直播滑动窗口中的歌曲(即使已从播放列表移除) - const liveHashes = new Set(liveSegments.map((s) => s.songId)); - - const items = []; - for (const entry of fs.readdirSync(HLS_CACHE_DIR)) { - const dir = path.join(HLS_CACHE_DIR, entry); - try { - if (!fs.statSync(dir).isDirectory()) continue; - } catch (_) { - continue; - } - let origFile = null; - for (const a of audioFiles) { - if (fileHash(a) === entry) { - origFile = a; - break; - } - } - const size = getDirSize(dir); - items.push({ - dir, - size, - origFile, - inLiveWindow: liveHashes.has(entry), - isProtected: origFile ? protectedFiles.has(origFile) : false, - inPlaylist: origFile ? playlist.includes(origFile) : false, - heat: origFile ? heatMap.get(origFile) || 0 : 0, - }); - } - - // 清理优先级:不受保护 → 不在播放列表 → 不在直播窗口 → 热度低 - items.sort((a, b) => { - if (a.inLiveWindow !== b.inLiveWindow) return a.inLiveWindow ? 1 : -1; - if (a.isProtected !== b.isProtected) return a.isProtected ? 1 : -1; - if (a.inPlaylist !== b.inPlaylist) return a.inPlaylist ? 1 : -1; - return a.heat - b.heat; - }); - - for (const item of items) { - if (cacheTotalSize <= MAX_CACHE_SIZE) break; - console.log( - `[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) { - cacheStatus.set(item.origFile, "pending"); - songSegmentsCache.delete(item.origFile); - if (!item.isProtected) triggerPreGenerate(item.origFile); - } - } -} - -// ==================== HLS 预生成 ==================== -function triggerPreGenerate(filePath) { - const status = cacheStatus.get(filePath); - if (status === "pending" || status === "ready") return; - - const retries = retryCount.get(filePath) || 0; - if (retries >= MAX_RETRIES) { - console.warn( - `[PRESEG] 跳过(已重试 ${MAX_RETRIES} 次): ${path.basename(filePath)}`, - ); - return; - } - retryCount.set(filePath, retries + 1); - cacheStatus.set(filePath, "pending"); - - const cacheDir = getCacheDir(filePath); - // [FIX] 仅清理残留缓存(正常路径不会走到这里,因为 scanExistingCaches 已处理) - if (fs.existsSync(cacheDir)) { - cacheTotalSize = Math.max(0, cacheTotalSize - getDirSize(cacheDir)); - fs.rmSync(cacheDir, { recursive: true, force: true }); - } - fs.mkdirSync(cacheDir, { recursive: true }); - - ffmpegQueue.push(async () => { - // 探测源格式:AAC 44.1kHz 立体声 → copy 模式避免二次压缩损耗 - let audioArgs; - try { - const probe = await probeAudio(filePath); - if ( - probe.codec === "aac" && - probe.sampleRate === 44100 && - probe.channels <= 2 - ) { - audioArgs = ["-c:a", "copy"]; - console.log(`[PRESEG] copy: ${path.basename(filePath)}`); - } else { - audioArgs = [ - "-c:a", - "aac", - "-b:a", - "192k", - "-ar", - "44100", - "-ac", - "2", - "-profile:a", - "aac_low", - ]; - console.log( - `[PRESEG] 转码: ${path.basename(filePath)} (${probe.codec} ${probe.sampleRate}Hz)`, - ); - } - } catch (_) { - audioArgs = [ - "-c:a", - "aac", - "-b:a", - "192k", - "-ar", - "44100", - "-ac", - "2", - "-profile:a", - "aac_low", - ]; - } - - return new Promise((resolve, reject) => { - 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(HLS_TIME), - "-hls_list_size", - "0", - "-hls_segment_type", - "mpegts", - "-hls_flags", - "independent_segments", - "-max_muxing_queue_size", - "2048", - "-f", - "hls", - outputM3u8, - ]; - - const proc = spawn("ffmpeg", args); - let stderrBuf = ""; - proc.stderr.on("data", (d) => { - stderrBuf += d.toString(); - if (stderrBuf.length > 4096) stderrBuf = stderrBuf.slice(-4096); - }); - - // [FIX] 超时保护:防止 FFmpeg 挂死阻塞队列 - const timer = setTimeout(() => { - proc.kill("SIGKILL"); - console.error(`[PRESEG] 超时终止: ${path.basename(filePath)}`); - }, FFMPEG_TIMEOUT); - - proc.on("close", (code) => { - clearTimeout(timer); - if (code === 0) { - console.log(`[PRESEG] 完成: ${path.basename(filePath)}`); - cacheStatus.set(filePath, "ready"); - songSegmentsCache.delete(filePath); - retryCount.delete(filePath); - cacheTotalSize += getDirSize(cacheDir); - enforceCacheSizeLimit(); - broadcastSSE("cache-status", { - file: path.basename(filePath), - status: "ready", - }); - resolve(); - } else { - cacheStatus.set(filePath, "error"); - if (fs.existsSync(cacheDir)) - fs.rmSync(cacheDir, { recursive: true, force: true }); - console.error( - `[PRESEG] 失败 code=${code}: ${path.basename(filePath)}`, - ); - reject(new Error(`FFmpeg exit ${code}`)); - } - }); - - proc.on("error", (err) => { - clearTimeout(timer); - cacheStatus.set(filePath, "error"); - reject(err); - }); - }); - }); -} - -function preGenerateAll() { - for (const file of audioFiles) { - const status = cacheStatus.get(file); - if (!status || status === "error") triggerPreGenerate(file); - } -} - -// ==================== 直播流核心 ==================== -function generateLivePlaylist() { - if (liveSegments.length === 0) { - atomicWriteSync( - path.join(HLS_OUTPUT_DIR, "index.m3u8"), - `#EXTM3U\n#EXT-X-VERSION:3\n` + - `#EXT-X-TARGETDURATION:${HLS_TIME + 1}\n` + - `#EXT-X-INDEPENDENT-SEGMENTS\n` + - `#EXT-X-MEDIA-SEQUENCE:0\n`, - ); - return; - } - - // TARGETDURATION = 所有切片最大时长向上取整 + 1(安全余量) - const maxDur = liveSegments.reduce( - (m, s) => Math.max(m, s.duration), - HLS_TIME, - ); - const targetDur = Math.max(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:${mediaSequence}\n`; - - for (let i = 0; i < liveSegments.length; i++) { - // 可选:仅在启用时插入不连续性标记 - if ( - USE_DISCONTINUITY && - i > 0 && - liveSegments[i].songId !== liveSegments[i - 1].songId - ) { - content += "#EXT-X-DISCONTINUITY\n"; - } - content += `#EXTINF:${liveSegments[i].duration.toFixed(3)},\n${liveSegments[i].path}\n`; - } - - // [FIX] 原子写入 - atomicWriteSync(path.join(HLS_OUTPUT_DIR, "index.m3u8"), content); -} - -function advanceLiveStream() { - if (audioFiles.length === 0) return; - - if (playlist.length === 0) { - playlist.push(audioFiles[Math.floor(Math.random() * audioFiles.length)]); - savePlaylist(); - currentPlaylistIdx = 0; - currentSongSegmentIdx = 0; - broadcastSSE("playlist-update", getPlaylistSnapshot()); - } - - let safety = playlist.length + 2; - while (safety-- > 0) { - clampPlaylistIndex(); - - const currentSong = playlist[currentPlaylistIdx]; - const status = cacheStatus.get(currentSong); - - // 未就绪 → 跳下一首 - if (status !== "ready") { - if (status !== "pending") triggerPreGenerate(currentSong); - currentPlaylistIdx = (currentPlaylistIdx + 1) % playlist.length; - currentSongSegmentIdx = 0; - continue; - } - - const segments = loadSongSegments(currentSong); - if (!segments || segments.length === 0) { - currentPlaylistIdx = (currentPlaylistIdx + 1) % playlist.length; - currentSongSegmentIdx = 0; - continue; - } - - // ===== 歌曲播放完毕 → 从列表中移除 ===== - if (currentSongSegmentIdx >= segments.length) { - const finishedName = path.basename(currentSong); - // splice 只移除当前位置的一个实例,同歌其他实例保留 - playlist.splice(currentPlaylistIdx, 1); - songSegmentsCache.delete(currentSong); - currentSongSegmentIdx = 0; - - // 列表空了 → 自动补一首 - if (playlist.length === 0 && audioFiles.length > 0) { - playlist.push( - audioFiles[Math.floor(Math.random() * audioFiles.length)], - ); - currentPlaylistIdx = 0; - } - - savePlaylist(); - clampPlaylistIndex(); - broadcastSSE("song-finished", { name: finishedName }); - broadcastSSE("playlist-update", getPlaylistSnapshot()); - - // 确保下一首缓存就绪 - if ( - playlist.length > 0 && - cacheStatus.get(playlist[currentPlaylistIdx]) !== "ready" - ) { - triggerPreGenerate(playlist[currentPlaylistIdx]); - } - - continue; // 立即尝试追加下一首的切片,不留间隙 - } - - // ===== 正常追加切片 ===== - const seg = segments[currentSongSegmentIdx]; - const songId = fileHash(currentSong); - liveSegments.push({ - duration: seg.duration, - path: path.posix.join("/hls_cache", songId, seg.fileName), - songId, - }); - - while (liveSegments.length > SLIDE_WINDOW_SIZE) { - liveSegments.shift(); - mediaSequence++; - } - - currentSongSegmentIdx++; - generateLivePlaylist(); - return; // 成功追加一个切片,等下一个 tick - } - - console.warn("[LIVE] 所有歌曲均不可用"); -} - -function startLiveStream() { - if (liveTimer) clearInterval(liveTimer); - - // 预填充窗口(以实际长度为终止条件) - let attempts = 0; - const target = Math.floor(SLIDE_WINDOW_SIZE / 2); - while (liveSegments.length < target && attempts < SLIDE_WINDOW_SIZE * 2) { - const prev = liveSegments.length; - advanceLiveStream(); - attempts++; - if (liveSegments.length === prev && liveSegments.length === 0) break; - } - - liveTimer = setInterval(advanceLiveStream, LIVE_TICK_INTERVAL); - console.log( - `[LIVE] 已启动 | 切片 ${HLS_TIME}s | 窗口 ${SLIDE_WINDOW_SIZE} 段 ≈${SLIDE_WINDOW_SIZE * HLS_TIME}s | 预填充 ${liveSegments.length} 段`, - ); -} - -// ==================== 资源扫描 ==================== -function scanAudioFiles() { - const exts = [".mp3", ".m4a", ".flac", ".wav"]; - audioFiles = fs - .readdirSync(AUDIO_DIR) - .filter((n) => exts.includes(path.extname(n).toLowerCase())) - .map((n) => path.join(AUDIO_DIR, n)) - .sort(naturalCompare); - console.log(`[INFO] 资源: ${audioFiles.length} 个文件`); -} - -function sanitizePlaylist() { - const valid = new Set(audioFiles); - for (const p of playlist) { - if (!valid.has(p)) songSegmentsCache.delete(p); - } - playlist = playlist.filter((p) => valid.has(p)); - clampPlaylistIndex(); - savePlaylist(); -} - -// ==================== [FIX] 初始化顺序 ==================== -// 1. 加载持久化数据 -// 2. 扫描文件列表 -// 3. 验证已有缓存(关键!在 preGenerateAll 之前) -// 4. 只生成缺失的缓存 -loadHeatMap(); -loadMetadata(); -loadPlaylist(); -scanAudioFiles(); -audioFiles.forEach((f) => ensureMetadata(f)); // 异步补全元数据(封面/时长等) -scanExistingCaches(); // ← 识别已有缓存,设为 ready -preGenerateAll(); // ← 只处理 status 为空或 error 的文件 -enforceCacheSizeLimit(); -sanitizePlaylist(); -startLiveStream(); - -// ==================== 文件监听(防抖)==================== -const watcher = chokidar.watch(AUDIO_DIR, { - ignoreInitial: true, - awaitWriteFinish: { stabilityThreshold: 500 }, -}); -function scheduleScan() { - if (scanDebounceTimer) clearTimeout(scanDebounceTimer); - scanDebounceTimer = setTimeout(() => { - scanAudioFiles(); - audioFiles.forEach((f) => ensureMetadata(f)); - preGenerateAll(); - enforceCacheSizeLimit(); - sanitizePlaylist(); - }, 500); -} -watcher.on("add", scheduleScan); -watcher.on("unlink", scheduleScan); - -// ==================== 认证 ==================== -function authMiddleware(req, res, next) { - if (!AUTH_TOKEN) return next(); - if (req.headers.authorization === `Bearer ${AUTH_TOKEN}`) return next(); - return res.status(401).json({ error: "未授权" }); -} - -// ==================== HTTP API ==================== -const app = express(); -app.use((req, res, next) => { - res.header("Access-Control-Allow-Origin", "*"); - res.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); - res.header("Access-Control-Allow-Headers", "Content-Type, Authorization"); - if (req.method === "OPTIONS") return res.sendStatus(200); - next(); -}); -app.use(express.json()); -app.use(express.static(PUBLIC_DIR)); - -// [FIX] M3U8 禁止浏览器缓存,确保每次都获取最新版本 -app.use( - "/hls", - express.static(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(HLS_CACHE_DIR)); - -app.get("/api/events", (req, res) => { - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }); - res.write(":ok\n\n"); - sseClients.add(res); - req.on("close", () => sseClients.delete(res)); -}); - -app.use("/api", authMiddleware); - -function isValidFileName(f) { - return ( - typeof f === "string" && - f.length > 0 && - f.length < 500 && - !f.includes("..") && - !f.includes("/") && - !f.includes("\\") && - !f.includes("\0") - ); -} - -app.get("/api/resources", (req, res) => { - res.json( - audioFiles.map((f) => { - const m = metadataMap.get(f) || { - name: path.basename(f), - artist: "", - albumImage: "", - duration: null, - }; - let fileSize = 0; - try { - fileSize = fs.statSync(f).size; - } catch (_) {} - return { - file: path.basename(f), - path: f, - title: m.name, - artist: m.artist || "", - albumImage: m.albumImage || "", - duration: m.duration || null, - fileSize, - heat: heatMap.get(f) || 0, - cacheStatus: cacheStatus.get(f) || "unknown", - cacheSize: (() => { - const d = getCacheDir(f); - return fs.existsSync(d) ? getDirSize(d) : 0; - })(), - }; - }), - ); -}); - -// 网易云音源:歌名搜索 -app.get("/api/music/search", async (req, res) => { - const { keyword } = req.query; - if (!keyword || !String(keyword).trim()) - return res.status(400).json({ error: "缺少搜索关键词" }); - try { - res.json(await searchNetEase(String(keyword).trim())); - } catch (e) { - res.status(400).json({ error: e.message }); - } -}); - -// 网易云音源:查询歌曲详情(歌名/作者/专辑图) -app.post("/api/music/detail", async (req, res) => { - const { id } = req.body; - if (!id || !/^\d+$/.test(String(id))) - return res.status(400).json({ error: "无效 id" }); - try { - res.json(await fetchNetEaseDetail(String(id))); - } catch (e) { - res.status(400).json({ error: e.message }); - } -}); - -// 网易云音源:下载入库(保存到 audio/,watcher 自动切片) -app.post("/api/music/download", async (req, res) => { - const { id, name, artist, albumImage } = req.body; - if (!id || !/^\d+$/.test(String(id))) - return res.status(400).json({ error: "无效 id" }); - try { - // 缺省时重新查详情,统一拿到歌名/作者/封面 - let meta = { name, artist: artist || "", albumImage: albumImage || "" }; - if (!meta.name) { - const info = await fetchNetEaseDetail(String(id)); - meta = { - name: info.name, - artist: info.artist || "", - albumImage: info.albumImage || "", - }; - } - const filename = sanitizeFileName( - meta.artist ? `${meta.artist} - ${meta.name}` : meta.name, - ); - if (!filename) return res.status(400).json({ error: "缺少歌名" }); - - // 防重复:同名文件已存在则不覆盖(覆盖触发 change 而非 add,不会重新切片) - if (fs.existsSync(path.join(AUDIO_DIR, filename + ".mp3"))) - return res.status(409).json({ error: `已存在: ${filename}.mp3` }); - - const filePath = await downloadNetEase(String(id), filename); - metadataMap.set(filePath, { ...meta, duration: null }); // 时长稍后由 ensureMetadata 补齐 - saveMetadata(); - res.json({ success: true, file: path.basename(filePath) }); - } catch (e) { - res.status(400).json({ error: e.message }); - } -}); - -app.get("/api/playlist", (req, res) => res.json(getPlaylistSnapshot())); - -app.post("/api/playlist/add", (req, res) => { - const { file } = req.body; - if (!isValidFileName(file)) - return res.status(400).json({ error: "无效文件名" }); - const tp = path.join(AUDIO_DIR, file); - if (!audioFiles.includes(tp)) - return res.status(404).json({ error: "资源不存在" }); - playlist.push(tp); - incrementHeat(tp); - savePlaylist(); - if (cacheStatus.get(tp) !== "ready") triggerPreGenerate(tp); - broadcastSSE("playlist-update", getPlaylistSnapshot()); - res.json({ success: true, playlist: playlist.map((p) => path.basename(p)) }); -}); - -app.post("/api/playlist/remove", (req, res) => { - const { index } = req.body; - if ( - typeof index !== "number" || - !Number.isInteger(index) || - index < 0 || - index >= playlist.length - ) - return res.status(400).json({ error: "无效索引" }); - if (index < currentPlaylistIdx) currentPlaylistIdx--; - else if (index === currentPlaylistIdx) currentSongSegmentIdx = 0; - playlist.splice(index, 1); - if (playlist.length === 0 && audioFiles.length > 0) { - playlist.push(audioFiles[Math.floor(Math.random() * audioFiles.length)]); - currentPlaylistIdx = 0; - currentSongSegmentIdx = 0; - } - clampPlaylistIndex(); - savePlaylist(); - broadcastSSE("playlist-update", getPlaylistSnapshot()); - res.json({ success: true, playlist: playlist.map((p) => path.basename(p)) }); -}); - -app.post("/api/playlist/reorder", (req, res) => { - const { newOrder } = req.body; - if (!Array.isArray(newOrder)) - return res.status(400).json({ error: "无效数据" }); - const valid = new Set(audioFiles); - const cur = playlist[currentPlaylistIdx]; - playlist = newOrder.filter((p) => typeof p === "string" && valid.has(p)); - const ni = playlist.indexOf(cur); - currentPlaylistIdx = ni !== -1 ? ni : 0; - if (ni === -1) currentSongSegmentIdx = 0; - if (playlist.length === 0 && audioFiles.length > 0) { - playlist.push(audioFiles[Math.floor(Math.random() * audioFiles.length)]); - currentPlaylistIdx = 0; - currentSongSegmentIdx = 0; - } - savePlaylist(); - broadcastSSE("playlist-update", getPlaylistSnapshot()); - res.json({ success: true, playlist: playlist.map((p) => path.basename(p)) }); -}); - -app.post("/api/playlist/clear", (req, res) => { - for (const f of playlist) songSegmentsCache.delete(f); - playlist = []; - if (audioFiles.length > 0) - playlist.push(audioFiles[Math.floor(Math.random() * audioFiles.length)]); - currentPlaylistIdx = 0; - currentSongSegmentIdx = 0; - savePlaylist(); - broadcastSSE("playlist-update", getPlaylistSnapshot()); - res.json({ success: true, playlist: playlist.map((p) => path.basename(p)) }); -}); - -app.post("/api/playlist/next", (req, res) => { - if (playlist.length === 0) - return res.status(400).json({ error: "播放列表为空" }); - currentPlaylistIdx = (currentPlaylistIdx + 1) % playlist.length; - currentSongSegmentIdx = 0; - if (cacheStatus.get(playlist[currentPlaylistIdx]) !== "ready") - triggerPreGenerate(playlist[currentPlaylistIdx]); - broadcastSSE("now-playing", { - index: currentPlaylistIdx, - name: path.basename(playlist[currentPlaylistIdx]), - }); - broadcastSSE("playlist-update", getPlaylistSnapshot()); - res.json({ - success: true, - current: path.basename(playlist[currentPlaylistIdx]), - }); -}); - -app.post("/api/playlist/prev", (req, res) => { - if (playlist.length === 0) - return res.status(400).json({ error: "播放列表为空" }); - currentPlaylistIdx = - (currentPlaylistIdx - 1 + playlist.length) % playlist.length; - currentSongSegmentIdx = 0; - if (cacheStatus.get(playlist[currentPlaylistIdx]) !== "ready") - triggerPreGenerate(playlist[currentPlaylistIdx]); - broadcastSSE("now-playing", { - index: currentPlaylistIdx, - name: path.basename(playlist[currentPlaylistIdx]), - }); - broadcastSSE("playlist-update", getPlaylistSnapshot()); - res.json({ - success: true, - current: path.basename(playlist[currentPlaylistIdx]), - }); -}); - -app.post("/api/playlist/jump", (req, res) => { - const { index } = req.body; - if ( - typeof index !== "number" || - !Number.isInteger(index) || - index < 0 || - index >= playlist.length - ) - return res.status(400).json({ error: "无效索引" }); - currentPlaylistIdx = index; - currentSongSegmentIdx = 0; - if (cacheStatus.get(playlist[currentPlaylistIdx]) !== "ready") - triggerPreGenerate(playlist[currentPlaylistIdx]); - broadcastSSE("now-playing", { - index: currentPlaylistIdx, - name: path.basename(playlist[currentPlaylistIdx]), - }); - broadcastSSE("playlist-update", getPlaylistSnapshot()); - res.json({ - success: true, - current: path.basename(playlist[currentPlaylistIdx]), - }); -}); - -app.get("/api/cache/size", (req, res) => { - res.json({ - total: cacheTotalSize, - limit: MAX_CACHE_SIZE, - usedPercent: ((cacheTotalSize / MAX_CACHE_SIZE) * 100).toFixed(2), - }); -}); - -app.post("/api/cache/cleanup", (req, res) => { - enforceCacheSizeLimit(); - res.json({ success: true, total: cacheTotalSize, limit: MAX_CACHE_SIZE }); -}); - -// ==================== 启动 ==================== -server = app.listen(HTTP_PORT, () => { - console.log(`[HTTP] 面板: http://127.0.0.1:${HTTP_PORT}`); - console.log(`[HTTP] 直播: http://127.0.0.1:${HTTP_PORT}/hls/index.m3u8`); - if (AUTH_TOKEN) console.log("[HTTP] 认证已启用"); -}); - -process.on("uncaughtException", (err) => { - console.error("[FATAL]", err.message, err.stack); -}); -process.on("unhandledRejection", (r) => console.error("[WARN]", r)); - -process.on("SIGINT", () => { - console.log("\n[EXIT] 正在关闭..."); - if (liveTimer) clearInterval(liveTimer); - if (heatSaveTimer) clearTimeout(heatSaveTimer); - if (scanDebounceTimer) clearTimeout(scanDebounceTimer); - for (const c of sseClients) - try { - c.end(); - } catch (_) {} - saveHeatMap(); - watcher.close().then(() => server.close(() => process.exit(0))); -}); +"use strict"; +// 新版入口:委托到 src/server.js(模块化重构后的主程序) +require("./src/server").start(); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c20a2af..34ce415 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 9b32b18..ddcc478 100644 --- a/package.json +++ b/package.json @@ -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" } -} +} \ No newline at end of file diff --git a/public/css/style.css b/public/css/style.css index ff7ac98..981d2f3 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -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,") 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; } \ No newline at end of file diff --git a/public/index.html b/public/index.html index e954980..7949ae0 100644 --- a/public/index.html +++ b/public/index.html @@ -9,21 +9,9 @@ Xiao の 电台 - - - - - - + + + @@ -48,15 +36,13 @@ >
- -- - 直播流 -- + + +
@@ -137,6 +123,18 @@ 在网易云音乐中复制歌曲 ID 或完整链接
粘贴到上方输入框即可自动识别 + +
+
+

本地导入

+

上传音频文件,自动识别作者与封面(封面存本地)

+
+ + + + +
+
@@ -332,7 +330,7 @@
- v1.0.0 · Built with ♥ · + v2.0.0 · Built with ♥ · el.remove(), 3000); - } +// ==================== 全局状态 ==================== +let currentStreamId = "main"; +const state = { streams: [], resources: [], playlist: [] }; - // ==================== Navigation ==================== - const tabs = document.querySelectorAll(".nav-tab"); - const pages = document.querySelectorAll(".page"); +// ==================== Toast ==================== +function showToast(msg, type = "") { + const c = document.getElementById("toastContainer"); + const el = document.createElement("div"); + el.className = "toast-msg" + (type ? " " + type : ""); + el.textContent = msg; + c.appendChild(el); + setTimeout(() => el.remove(), 3000); +} - function switchPage(pageId) { - pages.forEach((p) => p.classList.remove("active")); - tabs.forEach((t) => t.classList.remove("active")); - const page = document.getElementById(pageId); - if (page) page.classList.add("active"); - tabs.forEach((t) => { - if (t.dataset.page === pageId) t.classList.add("active"); - }); - // Eager-load data on tab switch - if (pageId === "pageResources") loadResources(); - if (pageId === "pagePlaylist") loadPlaylist(); - } - tabs.forEach((tab) => - tab.addEventListener("click", () => - switchPage(tab.dataset.page), - ), - ); +// ==================== Helpers ==================== +function esc(s) { + const AMP = String.fromCharCode(38); + const map = { "&": AMP + "amp;", "<": AMP + "lt;", ">": AMP + "gt;", '"': AMP + "quot;", "'": AMP + "#39;" }; + return String(s ?? "").replace(/[&<>"']/g, (c) => map[c]); +} +function formatDuration(sec) { + if (!sec && sec !== 0) return "--:--"; + const s = Math.round(sec), m = Math.floor(s / 60), ss = s % 60; + return m + ":" + String(ss).padStart(2, "0"); +} +function formatBytes(b) { + if (!b) return "0 B"; + if (b >= 1048576) return (b / 1048576).toFixed(2) + " MB"; + if (b >= 1024) return (b / 1024).toFixed(1) + " KB"; + return b + " B"; +} - // ==================== Helpers ==================== - function esc(s) { - return String(s ?? "").replace( - /[&<>"']/g, - (c) => - ({ - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", - })[c], - ); - } - function formatDuration(sec) { - if (!sec && sec !== 0) return "--:--"; - const s = Math.round(sec), - m = Math.floor(s / 60), - ss = s % 60; - return m + ":" + String(ss).padStart(2, "0"); - } - function formatBytes(b) { - if (!b) return "0 B"; - if (b >= 1048576) return (b / 1048576).toFixed(2) + " MB"; - if (b >= 1024) return (b / 1024).toFixed(1) + " KB"; - return b + " B"; - } - async function api(url, options = {}) { - const res = await fetch(url, options); - if (!res.ok) throw new Error(await res.text()); - return res.json(); - } +// ==================== API(统一带 cookie,401 跳登录) ==================== +async function api(url, options = {}) { + const res = await fetch(url, { credentials: "same-origin", ...options }); + if (res.status === 401) { + window.location.href = "/login"; + throw new Error("未登录"); + } + const text = await res.text(); + let data = null; + try { data = text ? JSON.parse(text) : null; } catch (_) { data = null; } + if (!res.ok) throw new Error((data && data.error) || text || `HTTP ${res.status}`); + return data; +} +function post(url, body) { + return api(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body || {}), + }); +} - // ==================== ID Extraction ==================== - function extractMusicId(input) { - input = input.trim(); - if (!input) return ""; - if (/^\d+$/.test(input)) return input; - const patterns = [ - /music\.163\.com\/#\/song\?.*id=(\d+)/, - /music\.163\.com\/song\?.*id=(\d+)/, - /music\.163\.com\/song\/(\d+)/, - /y\.music\.163\.com\/m\/song\?.*id=(\d+)/, - /[?&]id=(\d+)/, - /(\d{5,})/, - ]; - for (const p of patterns) { - const m = input.match(p); - if (m) return m[1]; - } - return input; - } +// ==================== Navigation ==================== +const tabs = document.querySelectorAll(".nav-tab"); +const pages = document.querySelectorAll(".page"); +const pageOrder = ["pageSearch", "pageResources", "pagePlaylist", "pageAbout"]; +function switchPage(pageId) { + pages.forEach((p) => p.classList.remove("active")); + tabs.forEach((t) => t.classList.remove("active")); + const page = document.getElementById(pageId); + if (page) page.classList.add("active"); + tabs.forEach((t) => { if (t.dataset.page === pageId) t.classList.add("active"); }); + if (pageId === "pageResources") loadResources(); + if (pageId === "pagePlaylist") loadPlaylist(); +} +tabs.forEach((tab) => tab.addEventListener("click", () => switchPage(tab.dataset.page))); - // ==================== Parse Hint ==================== - const musicIdInput = document.getElementById("musicId"); - const parseHint = document.getElementById("parseHint"); - const parsedIdEl = document.getElementById("parsedId"); - function updateParseHint() { - const val = musicIdInput.value.trim(), - extracted = extractMusicId(val); - if (val && extracted !== val && /^\d+$/.test(extracted)) { - parsedIdEl.textContent = extracted; - parseHint.classList.add("visible"); - } else { - parseHint.classList.remove("visible"); - } - } - musicIdInput.addEventListener("input", updateParseHint); - musicIdInput.addEventListener("paste", () => - requestAnimationFrame(updateParseHint), - ); +// ==================== ID Extraction ==================== +function extractMusicId(input) { + input = input.trim(); + if (!input) return ""; + if (/^\d+$/.test(input)) return input; + const patterns = [ + /music\.163\.com\/#\/song\?.*id=(\d+)/, + /music\.163\.com\/song\?.*id=(\d+)/, + /music\.163\.com\/song\/(\d+)/, + /y\.music\.163\.com\/m\/song\?.*id=(\d+)/, + /[?&]id=(\d+)/, + /(\d{5,})/, + ]; + for (const p of patterns) { const m = input.match(p); if (m) return m[1]; } + return input; +} - // ==================== List Diff ==================== - function reconcileList(container, items, keyOf, create, update) { - const nodes = Array.from( - container.querySelectorAll("[data-key]"), - ); - const byKey = new Map(nodes.map((n) => [n.dataset.key, n])); - const seen = new Set(); - items.forEach((item, idx) => { - const key = String(keyOf(item)); - seen.add(key); - let el = byKey.get(key); - if (!el) { - el = create(item); - el.dataset.key = key; - el.classList.add("enter"); - } - update(el, item, idx); - if (container.children[idx] !== el) - container.insertBefore( - el, - container.children[idx] || null, - ); - }); - nodes.forEach((el) => { - if (!seen.has(el.dataset.key)) { - el.classList.add("leave"); - el.addEventListener("animationend", () => el.remove(), { - once: true, - }); - } - }); - } +// ==================== Streams(多路流) ==================== +async function loadStreams() { + try { + const r = await api("/api/streams"); + state.streams = r.list || []; + const sel = document.getElementById("streamSelect"); + if (!state.streams.some((s) => s.id === currentStreamId)) { + currentStreamId = state.streams[0] ? state.streams[0].id : "main"; + } + sel.innerHTML = state.streams + .map((s) => ``) + .join(""); + const cur = state.streams.find((s) => s.id === currentStreamId); + document.getElementById("liveLink").href = cur ? cur.m3u8 : "/hls/main/index.m3u8"; + } catch (e) { console.error("加载流失败", e); } +} +document.getElementById("streamSelect").addEventListener("change", (e) => { + currentStreamId = e.target.value; + loadStreams(); + loadPlaylist(); + loadResources(); +}); +document.getElementById("liveLink").addEventListener("click", (e) => { + e.preventDefault(); + window.open(e.currentTarget.href, "_blank"); +}); +document.getElementById("btnLogout").addEventListener("click", async () => { + await post("/api/auth/logout").catch(() => {}); + window.location.href = "/login"; +}); - // ==================== Resources ==================== - function renderResItem(item) { - const cover = item.albumImage - ? `` - : `
`; - const bg = item.albumImage - ? `
` - : ""; - const tpl = document.createElement("template"); - tpl.innerHTML = `
${bg} -
${cover} -
-
${esc(item.title)}
-
${esc(item.artist) || "未知作者"}
-
-
- ${item.heat} - -
-
-
- ${formatDuration(item.duration)} - | - ${formatBytes(item.fileSize)} - | - 缓存 ${formatBytes(item.cacheSize)} -
-
`; - return tpl.content.firstElementChild; - } - function updateResItem(el, item) { - const h = el.querySelector(".js-heat"); - if (h.textContent !== String(item.heat)) - h.textContent = item.heat; - const c = el.querySelector(".js-cache"), - cs = formatBytes(item.cacheSize); - if (c.textContent !== cs) c.textContent = cs; - } - let resourceKeyword = ""; - function applyResourceFilter() { - document - .getElementById("resourceList") - .querySelectorAll(".res-item") - .forEach((el) => { - const t = ( - el.querySelector(".res-title")?.textContent || "" - ).toLowerCase(); - const a = ( - el.querySelector(".res-artist")?.textContent || "" - ).toLowerCase(); - el.style.display = - !resourceKeyword || - t.includes(resourceKeyword) || - a.includes(resourceKeyword) - ? "" - : "none"; - }); - } - async function loadResources() { - try { - const resources = await api("/api/resources"); - const container = document.getElementById("resourceList"); - document.getElementById("resourceCount").textContent = - resources.length; - if (resources.length === 0) { - container.innerHTML = `
暂无资源
前往搜索页导入音乐
`; - return; - } - if (!container.querySelector("[data-key]")) - container.innerHTML = ""; - reconcileList( - container, - resources, - (r) => r.file, - renderResItem, - updateResItem, - ); - applyResourceFilter(); - } catch (err) { - console.error("加载资源失败:", err); - } - } +// ==================== List Diff ==================== +function reconcileList(container, items, keyOf, create, update) { + const nodes = Array.from(container.querySelectorAll("[data-key]")); + const byKey = new Map(nodes.map((n) => [n.dataset.key, n])); + const seen = new Set(); + items.forEach((item, idx) => { + const key = String(keyOf(item)); + seen.add(key); + let el = byKey.get(key); + if (!el) { el = create(item); el.dataset.key = key; el.classList.add("enter"); } + update(el, item, idx); + if (container.children[idx] !== el) + container.insertBefore(el, container.children[idx] || null); + }); + nodes.forEach((el) => { + if (!seen.has(el.dataset.key)) { + el.classList.add("leave"); + el.addEventListener("animationend", () => el.remove(), { once: true }); + } + }); +} - // ==================== Playlist ==================== - let sortableInstance = null; - function renderPlItem(item) { - const tpl = document.createElement("template"); - tpl.innerHTML = `
- - -
-
${esc(item.name)}
-
- - -
-
- -
`; - return tpl.content.firstElementChild; - } - function updatePlItem(el, item, idx) { - const numEl = el.querySelector(".pl-num"); - if (numEl.textContent !== String(idx + 1)) - numEl.textContent = idx + 1; - const heatEl = el.querySelector(".js-heat"); - if (heatEl.textContent !== String(item.heat)) - heatEl.textContent = item.heat; - const cacheEl = el.querySelector(".pl-cache"); - const st = - item.cacheStatus === "ready" - ? "ready" - : item.cacheStatus === "pending" - ? "pending" - : "none"; - if (cacheEl.dataset.status !== st) { - cacheEl.dataset.status = st; - cacheEl.className = "pl-cache " + st; - cacheEl.innerHTML = - st === "ready" - ? ' 已缓存' - : st === "pending" - ? ' 生成中' - : ' 未缓存'; - } - el.querySelector("button").setAttribute( - "onclick", - "removeFromPlaylist(" + idx + ")", - ); - } - async function loadPlaylist() { - try { - const playlistData = await api("/api/playlist"); - const container = document.getElementById("playlistList"); - document.getElementById("playlistCount").textContent = - playlistData.length; - if (playlistData.length === 0) { - container.innerHTML = `
播放列表为空
从资源列表中添加音乐
`; - if (sortableInstance) { - sortableInstance.destroy(); - sortableInstance = null; - } - return; - } - if (!container.querySelector("[data-key]")) - container.innerHTML = ""; - reconcileList( - container, - playlistData, - (p) => p.path, - renderPlItem, - updatePlItem, - ); - if (!sortableInstance) { - sortableInstance = new Sortable(container, { - animation: 200, - ghostClass: "sortable-ghost", - handle: ".pl-grip", - delay: 100, - delayOnTouchOnly: true, - onEnd: async () => { - const items = [ - ...container.querySelectorAll(".pl-item"), - ]; - const newOrder = items.map( - (el) => el.dataset.id, - ); - try { - await api("/api/playlist/reorder", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ newOrder }), - }); - items.forEach((el, i) => { - el.querySelector( - ".pl-num", - ).textContent = i + 1; - el.querySelector("button").setAttribute( - "onclick", - "removeFromPlaylist(" + i + ")", - ); - }); - } catch (err) { - console.error("排序更新失败:", err); - loadPlaylist(); - } - }, - }); - } - } catch (err) { - console.error("加载播放列表失败:", err); - } - } +// ==================== Resources ==================== +function renderResItem(item) { + const cover = item.albumImage + ? `` + : `
`; + const bg = item.albumImage + ? `
` + : ""; + const tpl = document.createElement("template"); + tpl.innerHTML = `
${bg} +
${cover} +
+
${esc(item.title)}
+
${esc(item.artist) || "未知作者"}
+
+
+ ${item.heat} + + + +
+
+
+ ${formatDuration(item.duration)} + | + ${formatBytes(item.fileSize)} + | + 缓存 ${formatBytes(item.cacheSize)} +
+
`; + const el = tpl.content.firstElementChild; + el.querySelector(".js-add").addEventListener("click", () => addToPlaylist(item.file)); + el.querySelector(".js-del").addEventListener("click", () => deleteResource(item.file, item.title)); + el.querySelector(".js-enrich").addEventListener("click", () => enrichResource(item.file)); + return el; +} +function updateResItem(el, item) { + const h = el.querySelector(".js-heat"); + if (h.textContent !== String(item.heat)) h.textContent = item.heat; + const c = el.querySelector(".js-cache"), cs = formatBytes(item.cacheSize); + if (c.textContent !== cs) c.textContent = cs; +} +let resourceKeyword = ""; +function applyResourceFilter() { + document.getElementById("resourceList").querySelectorAll(".res-item").forEach((el) => { + const t = (el.querySelector(".res-title")?.textContent || "").toLowerCase(); + const a = (el.querySelector(".res-artist")?.textContent || "").toLowerCase(); + el.style.display = !resourceKeyword || t.includes(resourceKeyword) || a.includes(resourceKeyword) ? "" : "none"; + }); +} +async function loadResources() { + try { + const r = await api("/api/resources"); + const resources = r.list || []; + state.resources = resources; + const container = document.getElementById("resourceList"); + document.getElementById("resourceCount").textContent = resources.length; + if (resources.length === 0) { + container.innerHTML = `
暂无资源
前往搜索页导入音乐
`; + return; + } + if (!container.querySelector("[data-key]")) container.innerHTML = ""; + reconcileList(container, resources, (r) => r.file, renderResItem, updateResItem); + applyResourceFilter(); + } catch (err) { console.error("加载资源失败:", err); } +} - // ==================== Actions ==================== - async function addToPlaylist(fileName) { - try { - await api("/api/playlist/add", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ file: fileName }), - }); - showToast("已添加到播放列表", "success"); - await loadPlaylist(); - await loadResources(); - } catch (err) { - showToast("添加失败: " + err.message, "error"); - } - } - async function removeFromPlaylist(index) { - try { - await api("/api/playlist/remove", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ index }), - }); - await loadPlaylist(); - } catch (err) { - showToast("移除失败: " + err.message, "error"); - } - } - async function clearPlaylist() { - if (!confirm("确定清空播放列表?清空后将自动随机添加一首。")) - return; - try { - await api("/api/playlist/clear", { method: "POST" }); - await loadPlaylist(); - } catch (err) { - showToast("清空失败: " + err.message, "error"); - } - } - - // ==================== Cache Info ==================== - async function loadCacheInfo() { - try { - const info = await api("/api/cache/size"); - document.getElementById("cacheInfo").innerHTML = - ` ${(info.total / 1048576).toFixed(1)}/${(info.limit / 1048576).toFixed(0)}MB (${info.usedPercent}%)`; - } catch (err) { - console.error("加载缓存信息失败:", err); - } - } - - // ==================== Mini Player ==================== // - const musicPlayer = document.getElementById("musicPlayer"); - const miniPlayer = document.getElementById("miniPlayer"); - const mpBody = document.getElementById("mpBody"); - const mpProgress = document.getElementById("mpProgress"); - const mpCover = document.getElementById("mpCover"); - const mpName = document.getElementById("mpName"); - const mpArtist = document.getElementById("mpArtist"); - const mpPlay = document.getElementById("mpPlay"); - const mpTime = document.getElementById("mpTime"); - const mpExpand = document.getElementById("mpExpand"); - - function showMiniPlayer(info) { - mpName.textContent = info.name || "未知"; - mpArtist.textContent = info.artist || "--"; - if (info.albumImage) { - mpCover.src = info.albumImage; - mpCover.style.display = ""; - } else { - mpCover.style.display = "none"; - } - miniPlayer.classList.add("show"); - document.body.classList.add("mini-player-visible"); - } - function hideMiniPlayer() { - miniPlayer.classList.remove("show"); - document.body.classList.remove("mini-player-visible"); - } - - mpPlay.addEventListener("click", () => { - if (musicPlayer.paused) musicPlayer.play(); - else musicPlayer.pause(); +// ==================== Playlist ==================== +let sortableInstance = null; +function renderPlItem(item) { + const tpl = document.createElement("template"); + tpl.innerHTML = `
+ + +
+
${esc(item.title)}${item.isCurrent ? ' 播放中' : ""}
+
+ + +
+
+ +
`; + return tpl.content.firstElementChild; +} +function updatePlItem(el, item, idx) { + const numEl = el.querySelector(".pl-num"); + if (numEl.textContent !== String(idx + 1)) numEl.textContent = idx + 1; + const heatEl = el.querySelector(".js-heat"); + if (heatEl.textContent !== String(item.heat)) heatEl.textContent = item.heat; + const cacheEl = el.querySelector(".pl-cache"); + const st = item.cacheStatus === "ready" ? "ready" : item.cacheStatus === "pending" ? "pending" : "none"; + if (cacheEl.dataset.status !== st) { + cacheEl.dataset.status = st; + cacheEl.className = "pl-cache " + st; + cacheEl.innerHTML = + st === "ready" ? ' 已缓存' + : st === "pending" ? ' 生成中' + : ' 未缓存'; + } + const btn = el.querySelector("button"); + btn.onclick = () => removeFromPlaylist(item.entryId); +} +async function loadPlaylist() { + try { + const r = await api(`/api/streams/${currentStreamId}/playlist`); + const playlistData = r.list || []; + state.playlist = playlistData; + const container = document.getElementById("playlistList"); + document.getElementById("playlistCount").textContent = playlistData.length; + if (playlistData.length === 0) { + container.innerHTML = `
播放列表为空
从资源列表中添加音乐
`; + if (sortableInstance) { sortableInstance.destroy(); sortableInstance = null; } + return; + } + if (!container.querySelector("[data-key]")) container.innerHTML = ""; + reconcileList(container, playlistData, (p) => p.entryId, renderPlItem, updatePlItem); + if (!sortableInstance) { + sortableInstance = new Sortable(container, { + animation: 200, + ghostClass: "sortable-ghost", + handle: ".pl-grip", + delay: 100, + delayOnTouchOnly: true, + onEnd: async () => { + const items = [...container.querySelectorAll(".pl-item")]; + const order = items.map((el) => Number(el.dataset.id)); + try { + await post(`/api/streams/${currentStreamId}/playlist/reorder`, { order }); + items.forEach((el, i) => { + el.querySelector(".pl-num").textContent = i + 1; }); - musicPlayer.addEventListener("play", () => { - mpPlay.innerHTML = ''; - }); - musicPlayer.addEventListener("pause", () => { - mpPlay.innerHTML = ''; - }); - musicPlayer.addEventListener("timeupdate", () => { - if (!musicPlayer.duration) return; - const pct = - (musicPlayer.currentTime / musicPlayer.duration) * 100; - mpProgress.style.width = pct + "%"; - mpTime.textContent = formatDuration(musicPlayer.currentTime); - }); - musicPlayer.addEventListener("ended", () => { - mpPlay.innerHTML = ''; - }); - - mpExpand.addEventListener("click", () => { - switchPage("pageSearch"); - const preview = document.getElementById("musicPreview"); - if (preview.style.display !== "none") - preview.scrollIntoView({ - behavior: "smooth", - block: "center", - }); - }); - - // ---- Drag-to-seek on the entire mini player bar ---- - let isSeeking = false; - function seekFromPointer(e) { - const rect = mpBody.getBoundingClientRect(); - const x = e.touches ? e.touches[0].clientX : e.clientX; - const ratio = Math.max( - 0, - Math.min(1, (x - rect.left) / rect.width), - ); - if (musicPlayer.duration) { - musicPlayer.currentTime = ratio * musicPlayer.duration; - mpProgress.style.width = ratio * 100 + "%"; - } - } - mpBody.addEventListener("mousedown", (e) => { - if (e.target.closest("button")) return; - isSeeking = true; - seekFromPointer(e); - }); - mpBody.addEventListener( - "touchstart", - (e) => { - if (e.target.closest("button")) return; - isSeeking = true; - seekFromPointer(e); - }, - { passive: true }, - ); - document.addEventListener("mousemove", (e) => { - if (isSeeking) seekFromPointer(e); - }); - document.addEventListener( - "touchmove", - (e) => { - if (isSeeking) seekFromPointer(e); - }, - { passive: true }, - ); - document.addEventListener("mouseup", () => { - isSeeking = false; - }); - document.addEventListener("touchend", () => { - isSeeking = false; - }); - - // ==================== Music Search ==================== - let musicInfo = null, - selectedId = null; - - async function showDetail(id) { - const info = await api("/api/music/detail", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ id }), - }); - musicInfo = info; - selectedId = id; - document.getElementById("musicName").textContent = info.name; - document.getElementById("musicArtist").textContent = - info.artist || "未知作者"; - document.getElementById("musicCover").src = - info.albumImage || ""; - - let tags = ""; - if (info.album) - tags += ` ${esc(info.album)}`; - if (info.duration) - tags += ` ${formatDuration(info.duration)}`; - if (info.quality) - tags += ` ${esc(info.quality)}`; - if (info.fileSize) - tags += ` ${formatBytes(info.fileSize)}`; - if (info.publishTime) { - const d = new Date(info.publishTime); - tags += ` ${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, "0")}.${String(d.getDate()).padStart(2, "0")}`; - } - if (id) - tags += ` ${id}`; - document.getElementById("musicDetails").innerHTML = tags; - - document.getElementById("musicPreview").style.display = "block"; - document.getElementById("searchTips").style.display = "none"; - - musicPlayer.pause(); - musicPlayer.src = - "http://music.163.com/song/media/outer/url?id=" + - id + - ".mp3"; - - showMiniPlayer({ - name: info.name, - artist: info.artist, - albumImage: info.albumImage, - }); - mpProgress.style.width = "0%"; - mpTime.textContent = "0:00"; - } - - function renderSearchResults(data, keyword) { - const container = document.getElementById("searchResults"); - musicInfo = null; - selectedId = null; - document.getElementById("musicPreview").style.display = "none"; - - if (!data.list || data.list.length === 0) { - container.innerHTML = `
未找到相关歌曲
换个关键词试试
`; - container.style.display = "block"; - document.getElementById("searchTips").style.display = - "none"; - return; - } - - container.innerHTML = ` -
"${esc(keyword)}" 共 ${data.total} 首相关歌曲,点击选择:
-
${data.list - .map( - (item, i) => ` -
-
-
${esc(item.name)}
-
${esc(item.artists.join(" / ") || "未知作者")}
-
- ${item.alias && item.alias[0] ? `
${esc(item.alias[0])}
` : ""} -
${formatDuration(item.duration / 1000)}
-
`, - ) - .join("")}
`; - container.style.display = "block"; - document.getElementById("searchTips").style.display = "none"; - - container.querySelectorAll(".sr-item").forEach((el) => { - el.addEventListener("click", () => { - container - .querySelectorAll(".sr-item") - .forEach((x) => x.classList.remove("active")); - el.classList.add("active"); - showDetail(el.dataset.id).catch((err) => - showToast("查询失败: " + err.message, "error"), - ); - }); - }); - } - - async function searchMusic() { - const rawInput = musicIdInput.value.trim(); - if (!rawInput) - return showToast("请输入歌曲 ID、链接或歌名", "error"); - const id = extractMusicId(rawInput); - const btn = document.getElementById("btnSearch"); - btn.disabled = true; - btn.textContent = "查询中..."; - try { - if (id && /^\d+$/.test(id)) { - document.getElementById("searchResults").style.display = - "none"; - await showDetail(id); - } else { - renderSearchResults( - await api( - "/api/music/search?keyword=" + - encodeURIComponent(rawInput), - ), - rawInput, - ); - } - } catch (err) { - showToast("查询失败: " + err.message, "error"); - } finally { - btn.disabled = false; - btn.textContent = "查询"; - } - } - - async function downloadMusic() { - if (!musicInfo || !selectedId) return; - const btn = document.getElementById("btnDownload"); - btn.disabled = true; - btn.innerHTML = ' 下载中'; - try { - const r = await api("/api/music/download", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - id: selectedId, - name: musicInfo.name, - artist: musicInfo.artist, - albumImage: musicInfo.albumImage, - }), - }); - musicIdInput.value = ""; - musicInfo = null; - selectedId = null; - document.getElementById("musicPreview").style.display = - "none"; - document.getElementById("searchResults").style.display = - "none"; - document.getElementById("searchTips").style.display = - "block"; - parseHint.classList.remove("visible"); - loadResources(); - showToast("已下载: " + r.file, "success"); - } catch (err) { - showToast("下载失败: " + err.message, "error"); - } finally { - btn.disabled = false; - btn.innerHTML = - ' 下载到资源库'; - } - } - - // ==================== Event Listeners ==================== - document - .getElementById("btnSearch") - .addEventListener("click", searchMusic); - document - .getElementById("btnDownload") - .addEventListener("click", downloadMusic); - document - .getElementById("btnClear") - .addEventListener("click", clearPlaylist); - musicIdInput.addEventListener("keydown", (e) => { - if (e.key === "Enter") searchMusic(); - }); - musicPlayer.addEventListener("error", () => - showToast("试听失败:该歌曲可能无版权", "error"), - ); - document - .getElementById("resourceSearch") - .addEventListener("input", (e) => { - resourceKeyword = e.target.value.trim().toLowerCase(); - applyResourceFilter(); - }); - - // ==================== Keyboard Shortcuts ==================== - document.addEventListener("keydown", (e) => { - if ( - e.target.tagName === "INPUT" || - e.target.tagName === "TEXTAREA" - ) - return; - if (e.code === "Space") { - e.preventDefault(); - if (musicPlayer.paused) musicPlayer.play(); - else musicPlayer.pause(); - } - if (e.code === "ArrowRight") - musicPlayer.currentTime = Math.min( - musicPlayer.duration || 0, - musicPlayer.currentTime + 5, - ); - if (e.code === "ArrowLeft") - musicPlayer.currentTime = Math.max( - 0, - musicPlayer.currentTime - 5, - ); - }); - - // ==================== Swipe Navigation ==================== - let touchStartX = 0, - touchStartY = 0, - touchStartTime = 0; - const pageOrder = [ - "pageSearch", - "pageResources", - "pagePlaylist", - "pageAbout", - ]; - document.addEventListener( - "touchstart", - (e) => { - touchStartX = e.touches[0].clientX; - touchStartY = e.touches[0].clientY; - touchStartTime = Date.now(); - }, - { passive: true }, - ); - document.addEventListener( - "touchend", - (e) => { - const dx = e.changedTouches[0].clientX - touchStartX; - const dy = e.changedTouches[0].clientY - touchStartY; - const dt = Date.now() - touchStartTime; - if ( - Math.abs(dx) < 60 || - Math.abs(dx) < Math.abs(dy) * 1.5 || - dt > 500 - ) - return; - const idx = pageOrder.findIndex((id) => - document - .getElementById(id) - ?.classList.contains("active"), - ); - if (idx < 0) return; - if (dx < 0 && idx < pageOrder.length - 1) - switchPage(pageOrder[idx + 1]); - else if (dx > 0 && idx > 0) switchPage(pageOrder[idx - 1]); - }, - { passive: true }, - ); - - // ==================== Init ==================== - loadResources(); + } catch (err) { + console.error("排序更新失败:", err); loadPlaylist(); - loadCacheInfo(); - setInterval(() => { - loadResources(); - loadPlaylist(); - loadCacheInfo(); - }, 5000); + } + }, + }); + } + } catch (err) { console.error("加载播放列表失败:", err); } +} + +// ==================== Actions ==================== +async function addToPlaylist(file) { + try { + await post(`/api/streams/${currentStreamId}/playlist/add`, { file }); + showToast("已添加到播放列表", "success"); + await loadPlaylist(); + await loadResources(); + } catch (err) { showToast("添加失败: " + err.message, "error"); } +} +async function removeFromPlaylist(entryId) { + try { + await post(`/api/streams/${currentStreamId}/playlist/remove`, { entryId }); + await loadPlaylist(); + } catch (err) { showToast("移除失败: " + err.message, "error"); } +} +async function clearPlaylist() { + if (!confirm("确定清空当前频道的播放列表?清空后将自动随机添加一首。")) return; + try { + await post(`/api/streams/${currentStreamId}/playlist/clear`); + await loadPlaylist(); + } catch (err) { showToast("清空失败: " + err.message, "error"); } +} +async function deleteResource(file, title) { + if (!confirm(`确定删除「${title}」?\n将同时删除音频文件、切片缓存与播放列表引用。`)) return; + try { + await post("/api/music/delete", { file }); + showToast("已删除: " + title, "success"); + await loadResources(); + await loadPlaylist(); + } catch (err) { showToast("删除失败: " + err.message, "error"); } +} +async function enrichResource(file) { + try { + showToast("正在重新匹配..."); + await post("/api/music/enrich", { file }); + await loadResources(); + showToast("匹配完成", "success"); + } catch (err) { showToast("匹配失败: " + err.message, "error"); } +} + +// ==================== Cache Info ==================== +async function loadCacheInfo() { + try { + const info = await api("/api/system/cache"); + document.getElementById("cacheInfo").innerHTML = + ` ${(info.total / 1048576).toFixed(1)}/${(info.limit / 1048576).toFixed(0)}MB (${info.usedPercent}%)`; + } catch (err) { console.error("加载缓存信息失败:", err); } +} + +// ==================== Mini Player ==================== +const musicPlayer = document.getElementById("musicPlayer"); +const miniPlayer = document.getElementById("miniPlayer"); +const mpBody = document.getElementById("mpBody"); +const mpProgress = document.getElementById("mpProgress"); +const mpCover = document.getElementById("mpCover"); +const mpName = document.getElementById("mpName"); +const mpArtist = document.getElementById("mpArtist"); +const mpPlay = document.getElementById("mpPlay"); +const mpTime = document.getElementById("mpTime"); +const mpExpand = document.getElementById("mpExpand"); + +function showMiniPlayer(info) { + mpName.textContent = info.name || "未知"; + mpArtist.textContent = info.artist || "--"; + if (info.albumImage) { mpCover.src = info.albumImage; mpCover.style.display = ""; } + else mpCover.style.display = "none"; + miniPlayer.classList.add("show"); + document.body.classList.add("mini-player-visible"); +} +function hideMiniPlayer() { + miniPlayer.classList.remove("show"); + document.body.classList.remove("mini-player-visible"); +} +mpPlay.addEventListener("click", () => { musicPlayer.paused ? musicPlayer.play() : musicPlayer.pause(); }); +musicPlayer.addEventListener("play", () => { mpPlay.innerHTML = ''; }); +musicPlayer.addEventListener("pause", () => { mpPlay.innerHTML = ''; }); +musicPlayer.addEventListener("timeupdate", () => { + if (!musicPlayer.duration) return; + mpProgress.style.width = (musicPlayer.currentTime / musicPlayer.duration) * 100 + "%"; + mpTime.textContent = formatDuration(musicPlayer.currentTime); +}); +musicPlayer.addEventListener("ended", () => { mpPlay.innerHTML = ''; }); +mpExpand.addEventListener("click", () => { + switchPage("pageSearch"); + const preview = document.getElementById("musicPreview"); + if (preview.style.display !== "none") preview.scrollIntoView({ behavior: "smooth", block: "center" }); +}); + +let isSeeking = false; +function seekFromPointer(e) { + const rect = mpBody.getBoundingClientRect(); + const x = e.touches ? e.touches[0].clientX : e.clientX; + const ratio = Math.max(0, Math.min(1, (x - rect.left) / rect.width)); + if (musicPlayer.duration) { + musicPlayer.currentTime = ratio * musicPlayer.duration; + mpProgress.style.width = ratio * 100 + "%"; + } +} +mpBody.addEventListener("mousedown", (e) => { if (e.target.closest("button")) return; isSeeking = true; seekFromPointer(e); }); +mpBody.addEventListener("touchstart", (e) => { if (e.target.closest("button")) return; isSeeking = true; seekFromPointer(e); }, { passive: true }); +document.addEventListener("mousemove", (e) => { if (isSeeking) seekFromPointer(e); }); +document.addEventListener("touchmove", (e) => { if (isSeeking) seekFromPointer(e); }, { passive: true }); +document.addEventListener("mouseup", () => { isSeeking = false; }); +document.addEventListener("touchend", () => { isSeeking = false; }); + +// ==================== Music Search ==================== +let musicInfo = null, selectedId = null; +const musicIdInput = document.getElementById("musicId"); +const parseHint = document.getElementById("parseHint"); +const parsedIdEl = document.getElementById("parsedId"); + +function updateParseHint() { + const val = musicIdInput.value.trim(), extracted = extractMusicId(val); + if (val && extracted !== val && /^\d+$/.test(extracted)) { + parsedIdEl.textContent = extracted; + parseHint.classList.add("visible"); + } else parseHint.classList.remove("visible"); +} +musicIdInput.addEventListener("input", updateParseHint); +musicIdInput.addEventListener("paste", () => requestAnimationFrame(updateParseHint)); + +async function showDetail(id) { + const info = await api("/api/music/detail", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id }), + }); + musicInfo = info; selectedId = id; + document.getElementById("musicName").textContent = info.name; + document.getElementById("musicArtist").textContent = info.artist || "未知作者"; + document.getElementById("musicCover").src = info.albumImage || ""; + let tags = ""; + if (info.album) tags += ` ${esc(info.album)}`; + if (info.duration) tags += ` ${formatDuration(info.duration)}`; + if (id) tags += ` ${id}`; + document.getElementById("musicDetails").innerHTML = tags; + document.getElementById("musicPreview").style.display = "block"; + document.getElementById("searchTips").style.display = "none"; + musicPlayer.pause(); + musicPlayer.src = "http://music.163.com/song/media/outer/url?id=" + id + ".mp3"; + showMiniPlayer({ name: info.name, artist: info.artist, albumImage: info.albumImage }); + mpProgress.style.width = "0%"; + mpTime.textContent = "0:00"; +} + +function renderSearchResults(data, keyword) { + const container = document.getElementById("searchResults"); + musicInfo = null; selectedId = null; + document.getElementById("musicPreview").style.display = "none"; + if (!data.list || data.list.length === 0) { + container.innerHTML = `
未找到相关歌曲
换个关键词试试
`; + container.style.display = "block"; + document.getElementById("searchTips").style.display = "none"; + return; + } + container.innerHTML = ` +
"${esc(keyword)}" 共 ${data.total} 首相关歌曲,点击选择:
+
${data.list.map((item, i) => ` +
+
+
${esc(item.name)}
+
${esc((item.artists || []).join(" / ") || "未知作者")}
+
+ ${item.alias && item.alias[0] ? `
${esc(item.alias[0])}
` : ""} +
${formatDuration(item.duration / 1000)}
+
`).join("")}
`; + container.style.display = "block"; + document.getElementById("searchTips").style.display = "none"; + container.querySelectorAll(".sr-item").forEach((el) => { + el.addEventListener("click", () => { + container.querySelectorAll(".sr-item").forEach((x) => x.classList.remove("active")); + el.classList.add("active"); + showDetail(el.dataset.id).catch((err) => showToast("查询失败: " + err.message, "error")); + }); + }); +} + +async function searchMusic() { + const rawInput = musicIdInput.value.trim(); + if (!rawInput) return showToast("请输入歌曲 ID、链接或歌名", "error"); + const id = extractMusicId(rawInput); + const btn = document.getElementById("btnSearch"); + btn.disabled = true; btn.textContent = "查询中..."; + try { + if (id && /^\d+$/.test(id)) { + document.getElementById("searchResults").style.display = "none"; + await showDetail(id); + } else { + renderSearchResults(await api("/api/music/search?keyword=" + encodeURIComponent(rawInput)), rawInput); + } + } catch (err) { showToast("查询失败: " + err.message, "error"); } + finally { btn.disabled = false; btn.textContent = "查询"; } +} + +async function downloadMusic() { + if (!musicInfo || !selectedId) return; + const btn = document.getElementById("btnDownload"); + btn.disabled = true; btn.innerHTML = ' 下载中'; + try { + const r = await post("/api/music/download", { + id: selectedId, name: musicInfo.name, + artist: musicInfo.artist, albumImage: musicInfo.albumImage, + }); + musicIdInput.value = ""; musicInfo = null; selectedId = null; + document.getElementById("musicPreview").style.display = "none"; + document.getElementById("searchResults").style.display = "none"; + document.getElementById("searchTips").style.display = "block"; + parseHint.classList.remove("visible"); + loadResources(); + showToast("已下载: " + r.file, "success"); + } catch (err) { showToast("下载失败: " + err.message, "error"); } + finally { btn.disabled = false; btn.innerHTML = ' 下载到资源库'; } +} + +// ==================== Local Import ==================== +const importFileInput = document.getElementById("importFile"); +document.getElementById("btnImport").addEventListener("click", () => importFileInput.click()); +importFileInput.addEventListener("change", async () => { + const f = importFileInput.files[0]; + if (!f) return; + const title = document.getElementById("importTitle").value.trim(); + const artist = document.getElementById("importArtist").value.trim(); + const prog = document.getElementById("importProgress"); + const btn = document.getElementById("btnImport"); + btn.disabled = true; prog.textContent = "上传中..."; + const fd = new FormData(); + fd.append("audio", f); + fd.append("title", title); + fd.append("artist", artist); + try { + const res = await fetch("/api/music/import", { method: "POST", body: fd, credentials: "same-origin" }); + if (res.status === 401) { window.location.href = "/login"; return; } + const data = await res.json(); + if (!res.ok || !data.ok) throw new Error(data.error || "导入失败"); + prog.textContent = "导入成功,正在匹配元数据..."; + showToast("已导入: " + data.file, "success"); + document.getElementById("importTitle").value = ""; + document.getElementById("importArtist").value = ""; + importFileInput.value = ""; + await loadResources(); + prog.textContent = ""; + } catch (err) { + prog.textContent = ""; + showToast("导入失败: " + err.message, "error"); + } finally { btn.disabled = false; } +}); + +// ==================== SSE ==================== +function connectSSE() { + const es = new EventSource("/api/events"); + es.addEventListener("cache-status", () => { loadResources(); loadPlaylist(); }); + es.addEventListener("playlist-update", () => loadPlaylist()); + es.addEventListener("song-finished", () => loadPlaylist()); + es.addEventListener("now-playing", () => loadPlaylist()); + es.onerror = () => { es.close(); setTimeout(connectSSE, 5000); }; +} + +// ==================== Event Listeners ==================== +document.getElementById("btnSearch").addEventListener("click", searchMusic); +document.getElementById("btnDownload").addEventListener("click", downloadMusic); +document.getElementById("btnClear").addEventListener("click", clearPlaylist); +musicIdInput.addEventListener("keydown", (e) => { if (e.key === "Enter") searchMusic(); }); +musicPlayer.addEventListener("error", () => showToast("试听失败:该歌曲可能无版权", "error")); +document.getElementById("resourceSearch").addEventListener("input", (e) => { + resourceKeyword = e.target.value.trim().toLowerCase(); + applyResourceFilter(); +}); + +document.addEventListener("keydown", (e) => { + if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return; + if (e.code === "Space") { e.preventDefault(); musicPlayer.paused ? musicPlayer.play() : musicPlayer.pause(); } + if (e.code === "ArrowRight") musicPlayer.currentTime = Math.min(musicPlayer.duration || 0, musicPlayer.currentTime + 5); + if (e.code === "ArrowLeft") musicPlayer.currentTime = Math.max(0, musicPlayer.currentTime - 5); +}); + +// ==================== Swipe Navigation ==================== +let touchStartX = 0, touchStartY = 0, touchStartTime = 0; +document.addEventListener("touchstart", (e) => { + touchStartX = e.touches[0].clientX; touchStartY = e.touches[0].clientY; touchStartTime = Date.now(); +}, { passive: true }); +document.addEventListener("touchend", (e) => { + const dx = e.changedTouches[0].clientX - touchStartX; + const dy = e.changedTouches[0].clientY - touchStartY; + const dt = Date.now() - touchStartTime; + if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5 || dt > 500) return; + const idx = pageOrder.findIndex((id) => document.getElementById(id)?.classList.contains("active")); + if (idx < 0) return; + if (dx < 0 && idx < pageOrder.length - 1) switchPage(pageOrder[idx + 1]); + else if (dx > 0 && idx > 0) switchPage(pageOrder[idx - 1]); +}, { passive: true }); + +// ==================== Init ==================== +(async function init() { + await loadStreams(); + await loadResources(); + await loadPlaylist(); + loadCacheInfo(); + connectSSE(); + setInterval(() => { loadResources(); loadPlaylist(); loadCacheInfo(); }, 8000); +})(); \ No newline at end of file diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..db35d96 --- /dev/null +++ b/public/login.html @@ -0,0 +1,142 @@ + + + + + + 登录 · 音乐在线电台 + + + + +
+ +

音乐在线电台

+
请输入访问密码
+
+ + + +
+
+
+ + + \ No newline at end of file diff --git a/scripts/fetch_vendor.py b/scripts/fetch_vendor.py new file mode 100644 index 0000000..0109d36 --- /dev/null +++ b/scripts/fetch_vendor.py @@ -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() \ No newline at end of file diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..4dc7148 --- /dev/null +++ b/src/auth.js @@ -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, +}; \ No newline at end of file diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..29d495c --- /dev/null +++ b/src/config.js @@ -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; \ No newline at end of file diff --git a/src/db.js b/src/db.js new file mode 100644 index 0000000..94328bd --- /dev/null +++ b/src/db.js @@ -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 }; \ No newline at end of file diff --git a/src/livestream.js b/src/livestream.js new file mode 100644 index 0000000..8e3548c --- /dev/null +++ b/src/livestream.js @@ -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, +}; \ No newline at end of file diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 0000000..e90ca6a --- /dev/null +++ b/src/logger.js @@ -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), +}; \ No newline at end of file diff --git a/src/media.js b/src/media.js new file mode 100644 index 0000000..25221a9 --- /dev/null +++ b/src/media.js @@ -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, +}; \ No newline at end of file diff --git a/src/metadata.js b/src/metadata.js new file mode 100644 index 0000000..dae9829 --- /dev/null +++ b/src/metadata.js @@ -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, +}; \ No newline at end of file diff --git a/src/routes/auth.js b/src/routes/auth.js new file mode 100644 index 0000000..c529cde --- /dev/null +++ b/src/routes/auth.js @@ -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; \ No newline at end of file diff --git a/src/routes/music.js b/src/routes/music.js new file mode 100644 index 0000000..66eb6c4 --- /dev/null +++ b/src/routes/music.js @@ -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; \ No newline at end of file diff --git a/src/routes/resources.js b/src/routes/resources.js new file mode 100644 index 0000000..f70b351 --- /dev/null +++ b/src/routes/resources.js @@ -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; \ No newline at end of file diff --git a/src/routes/streams.js b/src/routes/streams.js new file mode 100644 index 0000000..f7f5eb7 --- /dev/null +++ b/src/routes/streams.js @@ -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; \ No newline at end of file diff --git a/src/routes/system.js b/src/routes/system.js new file mode 100644 index 0000000..8af4c5e --- /dev/null +++ b/src/routes/system.js @@ -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; \ No newline at end of file diff --git a/src/scanner.js b/src/scanner.js new file mode 100644 index 0000000..b78071d --- /dev/null +++ b/src/scanner.js @@ -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 }; \ No newline at end of file diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..113c0bc --- /dev/null +++ b/src/server.js @@ -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 }; \ No newline at end of file diff --git a/src/sse.js b/src/sse.js new file mode 100644 index 0000000..2606a96 --- /dev/null +++ b/src/sse.js @@ -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 }; \ No newline at end of file diff --git a/src/store.js b/src/store.js new file mode 100644 index 0000000..53e8a9c --- /dev/null +++ b/src/store.js @@ -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, +}; \ No newline at end of file diff --git a/src/util.js b/src/util.js new file mode 100644 index 0000000..31d52ff --- /dev/null +++ b/src/util.js @@ -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, +}; \ No newline at end of file diff --git a/test_api.py b/test_api.py new file mode 100644 index 0000000..ed7cf86 --- /dev/null +++ b/test_api.py @@ -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) \ No newline at end of file diff --git a/test_e2e.py b/test_e2e.py new file mode 100644 index 0000000..070d067 --- /dev/null +++ b/test_e2e.py @@ -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) \ No newline at end of file diff --git a/test_write.py b/test_write.py new file mode 100644 index 0000000..9025e76 --- /dev/null +++ b/test_write.py @@ -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) \ No newline at end of file